From b738e251a18a2958acd56f8b82d262c619c2e0ad Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Thu, 9 Apr 2026 00:52:49 +0500 Subject: [PATCH 01/10] Drag Selection Rectangle Signed-off-by: Godly Alias --- .../Pages/SelectionPage.xaml | 9 +- src/TableView.Properties.cs | 25 +++ src/TableView.cs | 179 +++++++++++++++++- src/TableViewCell.cs | 59 +++++- src/Themes/Resources.xaml | 9 + src/Themes/TableView.xaml | 11 ++ 6 files changed, 284 insertions(+), 8 deletions(-) diff --git a/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml index fcafdbb0..676b2e61 100644 --- a/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml +++ b/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml @@ -36,6 +36,10 @@ OnContent="True" OffContent="False" IsOn="{Binding IsReadOnly, Mode=TwoWay, ElementName=tableView}" /> + @@ -79,7 +83,8 @@ ItemsSource="{Binding Items}" SelectionMode="$(SelectionMode)" SelectionUnit="$(SelectionUnit)" - IsReadOnly="$(IsReadOnly)" /> + IsReadOnly="$(IsReadOnly)" + ShowDragRectangle="$(ShowDragRectangle)" /> @@ -89,6 +94,8 @@ Value="{x:Bind tableView.SelectionUnit, Mode=OneWay}" /> + diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index d90e7d26..d1d44b9c 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -266,6 +266,11 @@ public partial class TableView /// public static readonly DependencyProperty ShowFilterItemsCountProperty = DependencyProperty.Register(nameof(ShowFilterItemsCount), typeof(bool), typeof(TableView), new PropertyMetadata(false)); + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty ShowDragRectangleProperty = DependencyProperty.Register(nameof(ShowDragRectangle), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowDragRectangleChanged)); + /// /// Gets or sets a value indicating whether opening the column filter over header right-click is enabled. /// @@ -331,6 +336,15 @@ public bool ShowFilterItemsCount set => SetValue(ShowFilterItemsCountProperty, value); } + /// + /// Gets or sets a value indicating whether the drag selection rectangle is shown during cell drag selection. + /// + public bool ShowDragRectangle + { + get => (bool)GetValue(ShowDragRectangleProperty); + set => SetValue(ShowDragRectangleProperty, value); + } + /// /// Gets or sets the selection start cell slot. /// @@ -874,6 +888,17 @@ private static void OnIsReadOnlyChanged(DependencyObject d, DependencyPropertyCh } } + /// + /// Handles changes to the ShowDragRectangle property. + /// + private static void OnShowDragRectangleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TableView tableView && e.NewValue is false) + { + tableView.EndDragRectangle(); + } + } + /// /// Handles changes to the CanFilterColumns property. /// diff --git a/src/TableView.cs b/src/TableView.cs index e21a6d3e..e41a44bd 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -37,6 +37,12 @@ public partial class TableView : ListView private bool _ensureColumns = true; private readonly List _rows = []; private readonly CollectionView _collectionView = []; + internal Canvas? _dragRectangleCanvas; + private Border? _dragRectangle; + private Point? _dragStartPoint; + internal bool _isDragging; + private TableViewCellSlot? _lastDragSelectionSlot; + private bool _cellSelectionDirty; /// /// Initializes a new instance of the TableView class. @@ -282,6 +288,8 @@ protected async override void OnApplyTemplate() _headerRow = GetTemplateChild("HeaderRow") as TableViewHeaderRow; _scrollViewer = GetTemplateChild("ScrollViewer") as ScrollViewer; _headerRowDefinition = GetTemplateChild("HeaderRowDefinition") as RowDefinition; + _dragRectangleCanvas = GetTemplateChild("DragRectangleCanvas") as Canvas; + _dragRectangle = GetTemplateChild("DragRectangle") as Border; if (_scrollViewer is not null) _scrollViewer.Loaded += OnScrollViewerLoaded; if (IsLoaded) @@ -1157,8 +1165,13 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel /// private void OnCellSelectionChanged() { - DispatcherQueue.TryEnqueue(() => + if (_cellSelectionDirty) return; + _cellSelectionDirty = true; + + if (!DispatcherQueue.TryEnqueue(() => { + _cellSelectionDirty = false; + var oldSelection = SelectedCells; SelectedCells = [.. SelectedCellRanges.SelectMany(x => x)]; @@ -1171,7 +1184,10 @@ private void OnCellSelectionChanged() } InvokeCellSelectionChangedEvent(oldSelection); - }); + })) + { + _cellSelectionDirty = false; + } } /// @@ -1188,6 +1204,165 @@ private void InvokeCellSelectionChangedEvent(HashSet oldSelec } } + /// + /// Starts showing the drag selection rectangle at the specified position. + /// + /// The starting point relative to the drag rectangle canvas. + internal void StartDragRectangle(Point startPoint) + { + if (!ShowDragRectangle || _dragRectangleCanvas is null || _dragRectangle is null || + SelectionMode is not (ListViewSelectionMode.Multiple or ListViewSelectionMode.Extended)) + { + return; + } + + _dragStartPoint = startPoint; + _isDragging = true; + _lastDragSelectionSlot = null; + + Canvas.SetLeft(_dragRectangle, startPoint.X); + Canvas.SetTop(_dragRectangle, startPoint.Y); + _dragRectangle.Width = 0; + _dragRectangle.Height = 0; + + _dragRectangleCanvas.Visibility = Visibility.Visible; + } + + /// + /// Updates the drag selection rectangle to reflect the current pointer position. + /// + /// The current pointer position relative to the drag rectangle canvas. + internal void UpdateDragRectangle(Point currentPoint) + { + if (!_isDragging || _dragStartPoint is null || _dragRectangleCanvas is null || _dragRectangle is null) + { + return; + } + + var startPoint = _dragStartPoint.Value; + + // Compute edges and clamp to canvas bounds + var left = Math.Max(0, Math.Min(startPoint.X, currentPoint.X)); + var top = Math.Max(0, Math.Min(startPoint.Y, currentPoint.Y)); + var right = Math.Min(_dragRectangleCanvas.ActualWidth, Math.Max(startPoint.X, currentPoint.X)); + var bottom = Math.Min(_dragRectangleCanvas.ActualHeight, Math.Max(startPoint.Y, currentPoint.Y)); + var width = Math.Max(0, right - left); + var height = Math.Max(0, bottom - top); + + Canvas.SetLeft(_dragRectangle, left); + Canvas.SetTop(_dragRectangle, top); + _dragRectangle.Width = Math.Max(0, width); + _dragRectangle.Height = Math.Max(0, height); + + SelectCellsInDragRectangle(); + } + + /// + /// Selects all cells that intersect with the current drag rectangle. + /// Uses column widths and row positions for O(columns + rows) performance + /// instead of visual tree hit-testing. + /// + private void SelectCellsInDragRectangle() + { + if (!_isDragging || _dragRectangle is null || _dragRectangleCanvas is null || + _scrollViewer is null || SelectionStartCellSlot is null) + { + return; + } + + var rectLeft = Canvas.GetLeft(_dragRectangle); + var rectTop = Canvas.GetTop(_dragRectangle); + var rectWidth = _dragRectangle.Width; + var rectHeight = _dragRectangle.Height; + + if (rectWidth <= 0 || rectHeight <= 0) return; + + var rectRight = rectLeft + rectWidth; + var rectBottom = rectTop + rectHeight; + + // Find columns that intersect the rectangle using accumulated widths. + // Frozen columns are not shifted by HorizontalOffset since they stay fixed. + var headersOffset = CellsHorizontalOffset; + var colLeft = headersOffset; + var minCol = -1; + var maxCol = -1; + var visibleColumns = Columns.VisibleColumns; + var frozenColumnCount = FrozenColumnCount; + + for (var i = 0; i < visibleColumns.Count; i++) + { + if (i == frozenColumnCount) + { + colLeft -= HorizontalOffset; + } + + var colRight = colLeft + visibleColumns[i].ActualWidth; + + if (colRight > rectLeft && colLeft < rectRight) + { + if (minCol == -1) minCol = i; + maxCol = i; + } + + colLeft = colRight; + } + + if (minCol == -1) return; + + // Find rows that intersect the rectangle using visible row containers + var minRow = -1; + var maxRow = -1; + + foreach (var row in _rows) + { + try + { + var rowTop = row.TransformToVisual(_dragRectangleCanvas).TransformPoint(default).Y; + var rowBottom = rowTop + row.ActualHeight; + + if (rowBottom > rectTop && rowTop < rectBottom) + { + if (minRow == -1 || row.Index < minRow) minRow = row.Index; + if (row.Index > maxRow) maxRow = row.Index; + } + } + catch (ArgumentException) + { + continue; + } + } + + if (minRow == -1) return; + + // Compute the end slot farthest from selection start + var start = SelectionStartCellSlot.Value; + var endRow = Math.Abs(maxRow - start.Row) >= Math.Abs(minRow - start.Row) ? maxRow : minRow; + var endCol = Math.Abs(maxCol - start.Column) >= Math.Abs(minCol - start.Column) ? maxCol : minCol; + var endSlot = new TableViewCellSlot(endRow, endCol); + + // Skip if selection hasn't changed + if (_lastDragSelectionSlot == endSlot) return; + _lastDragSelectionSlot = endSlot; + + var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); + MakeSelection(endSlot, true, ctrlKey); + } + + /// + /// Ends and hides the drag selection rectangle. + /// + internal void EndDragRectangle() + { + if (_dragRectangleCanvas is not null) + { + _dragRectangleCanvas.Visibility = Visibility.Collapsed; + } + + _isDragging = false; + _dragStartPoint = null; + _lastDragSelectionSlot = null; + } + /// /// Scrolls the specified cell slot into view. /// diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 758c3b14..3d207bd1 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -241,6 +241,17 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) TableView.SelectionStartCellSlot = TableView.SelectionUnit is not TableViewSelectionUnit.Row || !IsReadOnly ? Slot : default; ; TableView.SelectionStartRowIndex = Index; CapturePointer(e.Pointer); + + // Only start drag rectangle when cell selection is possible + if (TableView.SelectionStartCellSlot.HasValue && TableView.SelectionStartCellSlot.Value.IsValid(TableView)) + { + var point = e.GetCurrentPoint(this).Position; + var canvasPoint = TransformPointToCanvas(point); + if (canvasPoint.HasValue) + { + TableView.StartDragRectangle(canvasPoint.Value); + } + } } } @@ -256,11 +267,20 @@ protected override void OnPointerReleased(PointerRoutedEventArgs e) TableView.SelectionStartRowIndex = cell?.Slot.Row; } + TableView?.EndDragRectangle(); ReleasePointerCaptures(); e.Handled = true; } + /// + protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) + { + base.OnPointerCaptureLost(e); + + TableView?.EndDragRectangle(); + } + /// protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) { @@ -268,12 +288,23 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) if (PointerCaptures?.Any() is true) { - var cell = FindCell(e.Position); - - if (cell is not null && cell.Slot != TableView?.CurrentCellSlot) + if (TableView?._isDragging is true) { - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - TableView?.MakeSelection(cell.Slot, true, ctrlKey); + var canvasPoint = TransformPointToCanvas(e.Position); + if (canvasPoint.HasValue) + { + TableView.UpdateDragRectangle(canvasPoint.Value); + } + } + else + { + var cell = FindCell(e.Position); + + if (cell is not null && cell.Slot != TableView?.CurrentCellSlot) + { + var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); + TableView?.MakeSelection(cell.Slot, true, ctrlKey); + } } } } @@ -308,6 +339,24 @@ private double GetHorizontalGridlineHeight() .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 async void OnDoubleTapped(DoubleTappedRoutedEventArgs e) { diff --git a/src/Themes/Resources.xaml b/src/Themes/Resources.xaml index 31d47b4a..6d78ed97 100644 --- a/src/Themes/Resources.xaml +++ b/src/Themes/Resources.xaml @@ -74,6 +74,9 @@ + + + 4 @@ -143,6 +146,9 @@ + + + 4 @@ -212,6 +218,9 @@ + + + 4 diff --git a/src/Themes/TableView.xaml b/src/Themes/TableView.xaml index 8fcee42e..8fbbd273 100644 --- a/src/Themes/TableView.xaml +++ b/src/Themes/TableView.xaml @@ -126,6 +126,17 @@ Template="{StaticResource TableViewScrollViewerTemplate}"> + + + + From ab6db6ee3bff9e1a9b14b4a63c6ca1d28d4289d0 Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Thu, 9 Apr 2026 11:24:05 +0500 Subject: [PATCH 02/10] Refining implementation Signed-off-by: Godly Alias --- src/TableView.cs | 251 +++++++++++++++++---------- src/TableViewCell.cs | 22 +-- src/Themes/Resources.xaml | 6 +- tests/DragSelectionRectangleTests.cs | 197 +++++++++++++++++++++ 4 files changed, 374 insertions(+), 102 deletions(-) create mode 100644 tests/DragSelectionRectangleTests.cs diff --git a/src/TableView.cs b/src/TableView.cs index e41a44bd..05488649 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -41,8 +41,12 @@ public partial class TableView : ListView private Border? _dragRectangle; private Point? _dragStartPoint; internal bool _isDragging; - private TableViewCellSlot? _lastDragSelectionSlot; private bool _cellSelectionDirty; + private Point? _lastDragCanvasPoint; + private DispatcherTimer? _autoScrollTimer; + private double _autoScrollDelta; + private double _dragStartVerticalOffset; + private double _dragStartHorizontalOffset; /// /// Initializes a new instance of the TableView class. @@ -342,6 +346,9 @@ private void OnLoaded(object sender, RoutedEventArgs e) /// private void OnUnloaded(object sender, RoutedEventArgs e) { + EndDragRectangle(); + StopAutoScroll(); + if (IsEditing && CurrentCellSlot.HasValue && GetCellFromSlot(CurrentCellSlot.Value) is { } currentCell) { currentCell.EndEditing(TableViewEditAction.Commit); @@ -1155,8 +1162,18 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel if (newSlot.HasValue) { - var cell = await ScrollCellIntoView(newSlot.Value); - cell?.ApplyCurrentCellState(); + // 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 (_isDragging) + { + var cell = GetCellFromSlot(newSlot.Value); + cell?.ApplyCurrentCellState(skipFocus: true); + } + else + { + var cell = await ScrollCellIntoView(newSlot.Value); + cell?.ApplyCurrentCellState(); + } } } @@ -1216,9 +1233,17 @@ SelectionMode is not (ListViewSelectionMode.Multiple or ListViewSelectionMode.Ex return; } + // Guard against re-entry (e.g., multi-touch) to prevent double ViewChanged subscription + if (_isDragging) + { + EndDragRectangle(); + } + _dragStartPoint = startPoint; _isDragging = true; - _lastDragSelectionSlot = null; + _lastDragCanvasPoint = startPoint; + _dragStartVerticalOffset = _scrollViewer?.VerticalOffset ?? 0; + _dragStartHorizontalOffset = HorizontalOffset; Canvas.SetLeft(_dragRectangle, startPoint.X); Canvas.SetTop(_dragRectangle, startPoint.Y); @@ -1226,133 +1251,169 @@ SelectionMode is not (ListViewSelectionMode.Multiple or ListViewSelectionMode.Ex _dragRectangle.Height = 0; _dragRectangleCanvas.Visibility = Visibility.Visible; + + if (_scrollViewer is not null) + { + _scrollViewer.ViewChanged += OnScrollViewerViewChangedDuringDrag; + } } /// - /// Updates the drag selection rectangle to reflect the current pointer position. + /// Updates the drag rectangle visual and auto-scroll. Selection is handled separately via FindCell+MakeSelection. /// /// The current pointer position relative to the drag rectangle canvas. - internal void UpdateDragRectangle(Point currentPoint) + internal void UpdateDragRectangleVisual(Point currentPoint) { if (!_isDragging || _dragStartPoint is null || _dragRectangleCanvas is null || _dragRectangle is null) { return; } - var startPoint = _dragStartPoint.Value; + _lastDragCanvasPoint = currentPoint; + PositionDragRectangle(currentPoint); + UpdateAutoScroll(currentPoint); + } + + /// + /// 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. + /// + private void PositionDragRectangle(Point currentPoint) + { + if (_dragStartPoint is null || _dragRectangleCanvas is null || _dragRectangle is null) return; + + // Adjust the start point by how much the view has scrolled since drag began. + // This makes the rectangle extend naturally as content scrolls. + var verticalScrollDelta = (_scrollViewer?.VerticalOffset ?? 0) - _dragStartVerticalOffset; + var horizontalScrollDelta = HorizontalOffset - _dragStartHorizontalOffset; + var adjustedStartY = _dragStartPoint.Value.Y - verticalScrollDelta; + var adjustedStartX = _dragStartPoint.Value.X - horizontalScrollDelta; + + var canvasWidth = _dragRectangleCanvas.ActualWidth; + var canvasHeight = _dragRectangleCanvas.ActualHeight; - // Compute edges and clamp to canvas bounds - var left = Math.Max(0, Math.Min(startPoint.X, currentPoint.X)); - var top = Math.Max(0, Math.Min(startPoint.Y, currentPoint.Y)); - var right = Math.Min(_dragRectangleCanvas.ActualWidth, Math.Max(startPoint.X, currentPoint.X)); - var bottom = Math.Min(_dragRectangleCanvas.ActualHeight, Math.Max(startPoint.Y, currentPoint.Y)); - var width = Math.Max(0, right - left); - var height = Math.Max(0, bottom - top); + var left = Math.Max(0, Math.Min(adjustedStartX, currentPoint.X)); + var top = Math.Max(0, Math.Min(adjustedStartY, currentPoint.Y)); + var right = Math.Min(canvasWidth, Math.Max(adjustedStartX, currentPoint.X)); + var bottom = Math.Min(canvasHeight, Math.Max(adjustedStartY, currentPoint.Y)); Canvas.SetLeft(_dragRectangle, left); Canvas.SetTop(_dragRectangle, top); - _dragRectangle.Width = Math.Max(0, width); - _dragRectangle.Height = Math.Max(0, height); - - SelectCellsInDragRectangle(); + _dragRectangle.Width = Math.Max(0, right - left); + _dragRectangle.Height = Math.Max(0, bottom - top); } /// - /// Selects all cells that intersect with the current drag rectangle. - /// Uses column widths and row positions for O(columns + rows) performance - /// instead of visual tree hit-testing. + /// Manages auto-scroll behavior when the pointer is near the top or bottom edge during drag selection. /// - private void SelectCellsInDragRectangle() + private void UpdateAutoScroll(Point canvasPoint) { - if (!_isDragging || _dragRectangle is null || _dragRectangleCanvas is null || - _scrollViewer is null || SelectionStartCellSlot is null) - { - return; - } - - var rectLeft = Canvas.GetLeft(_dragRectangle); - var rectTop = Canvas.GetTop(_dragRectangle); - var rectWidth = _dragRectangle.Width; - var rectHeight = _dragRectangle.Height; + if (_scrollViewer is null || _dragRectangleCanvas is null) return; - if (rectWidth <= 0 || rectHeight <= 0) return; + const double edgeThreshold = 40; + const double maxScrollSpeed = 20; + var canvasHeight = _dragRectangleCanvas.ActualHeight; + double scrollDelta = 0; - var rectRight = rectLeft + rectWidth; - var rectBottom = rectTop + rectHeight; - - // Find columns that intersect the rectangle using accumulated widths. - // Frozen columns are not shifted by HorizontalOffset since they stay fixed. - var headersOffset = CellsHorizontalOffset; - var colLeft = headersOffset; - var minCol = -1; - var maxCol = -1; - var visibleColumns = Columns.VisibleColumns; - var frozenColumnCount = FrozenColumnCount; - - for (var i = 0; i < visibleColumns.Count; i++) + if (canvasPoint.Y > canvasHeight - edgeThreshold) { - if (i == frozenColumnCount) - { - colLeft -= HorizontalOffset; - } + var proximity = Math.Min(1.0, (canvasPoint.Y - (canvasHeight - edgeThreshold)) / edgeThreshold); + scrollDelta = proximity * maxScrollSpeed; + } + else if (canvasPoint.Y < edgeThreshold) + { + var proximity = Math.Min(1.0, (edgeThreshold - canvasPoint.Y) / edgeThreshold); + scrollDelta = -(proximity * maxScrollSpeed); + } - var colRight = colLeft + visibleColumns[i].ActualWidth; + if (canvasPoint.X > _dragRectangleCanvas.ActualWidth - edgeThreshold) + { + var proximity = Math.Min(1.0, (canvasPoint.X - (_dragRectangleCanvas.ActualWidth - edgeThreshold)) / edgeThreshold); + var hDelta = proximity * maxScrollSpeed; + SetValue(HorizontalOffsetProperty, Math.Clamp(HorizontalOffset + hDelta, 0, _scrollViewer.ScrollableWidth)); + } + else if (canvasPoint.X < edgeThreshold) + { + var proximity = Math.Min(1.0, (edgeThreshold - canvasPoint.X) / edgeThreshold); + var hDelta = -(proximity * maxScrollSpeed); + SetValue(HorizontalOffsetProperty, Math.Clamp(HorizontalOffset + hDelta, 0, _scrollViewer.ScrollableWidth)); + } - if (colRight > rectLeft && colLeft < rectRight) + if (Math.Abs(scrollDelta) > 0.5) + { + _autoScrollDelta = scrollDelta; + if (_autoScrollTimer is null) { - if (minCol == -1) minCol = i; - maxCol = i; + _autoScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _autoScrollTimer.Tick += OnAutoScrollTimerTick; } - colLeft = colRight; + _autoScrollTimer.Start(); } + else + { + StopAutoScroll(); + } + } - if (minCol == -1) return; + /// + /// Handles the auto-scroll timer tick to scroll the view and update drag selection. + /// + private void OnAutoScrollTimerTick(object? sender, object e) + { + if (!_isDragging || _scrollViewer is null) + { + StopAutoScroll(); + return; + } - // Find rows that intersect the rectangle using visible row containers - var minRow = -1; - var maxRow = -1; + var newOffset = Math.Clamp( + _scrollViewer.VerticalOffset + _autoScrollDelta, + 0, + _scrollViewer.ScrollableHeight); - foreach (var row in _rows) + if (Math.Abs(newOffset - _scrollViewer.VerticalOffset) < 0.5) { - try - { - var rowTop = row.TransformToVisual(_dragRectangleCanvas).TransformPoint(default).Y; - var rowBottom = rowTop + row.ActualHeight; - - if (rowBottom > rectTop && rowTop < rectBottom) - { - if (minRow == -1 || row.Index < minRow) minRow = row.Index; - if (row.Index > maxRow) maxRow = row.Index; - } - } - catch (ArgumentException) - { - continue; - } + StopAutoScroll(); + return; } - if (minRow == -1) return; + _scrollViewer.ChangeView(null, newOffset, null, true); + } - // Compute the end slot farthest from selection start - var start = SelectionStartCellSlot.Value; - var endRow = Math.Abs(maxRow - start.Row) >= Math.Abs(minRow - start.Row) ? maxRow : minRow; - var endCol = Math.Abs(maxCol - start.Column) >= Math.Abs(minCol - start.Column) ? maxCol : minCol; - var endSlot = new TableViewCellSlot(endRow, endCol); + /// + /// Stops the auto-scroll timer. + /// + private void StopAutoScroll() + { + _autoScrollTimer?.Stop(); + } - // Skip if selection hasn't changed - if (_lastDragSelectionSlot == endSlot) return; - _lastDragSelectionSlot = endSlot; + /// + /// Handles ScrollViewer.ViewChanged during drag to re-evaluate selection when scroll position changes. + /// + private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerViewChangedEventArgs e) + { + if (!_isDragging || _lastDragCanvasPoint is null) return; - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - MakeSelection(endSlot, true, ctrlKey); + // Reposition the rectangle using scroll-adjusted start point + PositionDragRectangle(_lastDragCanvasPoint.Value); } /// /// Ends and hides the drag selection rectangle. /// - internal void EndDragRectangle() + internal async void EndDragRectangle() { + if (!_isDragging) return; + + StopAutoScroll(); + + if (_scrollViewer is not null) + { + _scrollViewer.ViewChanged -= OnScrollViewerViewChangedDuringDrag; + } + if (_dragRectangleCanvas is not null) { _dragRectangleCanvas.Visibility = Visibility.Collapsed; @@ -1360,7 +1421,21 @@ internal void EndDragRectangle() _isDragging = false; _dragStartPoint = null; - _lastDragSelectionSlot = null; + _lastDragCanvasPoint = null; + + // Restore focus and scroll to the current cell now that dragging has ended + try + { + if (CurrentCellSlot.HasValue) + { + var cell = await ScrollCellIntoView(CurrentCellSlot.Value); + cell?.ApplyCurrentCellState(); + } + } + catch + { + // Focus restoration is best-effort after drag ends + } } /// diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 3d207bd1..22c13300 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -288,23 +288,23 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) if (PointerCaptures?.Any() is true) { + // Update drag rectangle visual and auto-scroll (cheap — just Canvas property sets + edge math) if (TableView?._isDragging is true) { var canvasPoint = TransformPointToCanvas(e.Position); if (canvasPoint.HasValue) { - TableView.UpdateDragRectangle(canvasPoint.Value); + TableView.UpdateDragRectangleVisual(canvasPoint.Value); } } - else - { - var cell = FindCell(e.Position); - if (cell is not null && cell.Slot != TableView?.CurrentCellSlot) - { - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - TableView?.MakeSelection(cell.Slot, true, ctrlKey); - } + // Selection via FindCell — same proven path whether rectangle is on or off + var cell = FindCell(e.Position); + + if (cell is not null && cell.Slot != TableView?.CurrentCellSlot) + { + var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); + TableView?.MakeSelection(cell.Slot, true, ctrlKey); } } } @@ -539,12 +539,12 @@ internal void ApplySelectionState() /// /// Applies the current cell state to the cell. /// - internal async void ApplyCurrentCellState() + internal async void ApplyCurrentCellState(bool skipFocus = false) { var stateName = IsCurrent ? VisualStates.StateCurrent : VisualStates.StateRegular; VisualStates.GoToState(this, false, stateName); - if (IsCurrent) + if (IsCurrent && !skipFocus) { Focus(FocusState.Pointer); diff --git a/src/Themes/Resources.xaml b/src/Themes/Resources.xaml index 6d78ed97..06685111 100644 --- a/src/Themes/Resources.xaml +++ b/src/Themes/Resources.xaml @@ -76,7 +76,7 @@ - 4 + 0 @@ -148,7 +148,7 @@ - 4 + 0 @@ -220,7 +220,7 @@ - 4 + 0 diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs new file mode 100644 index 00000000..e3545af9 --- /dev/null +++ b/tests/DragSelectionRectangleTests.cs @@ -0,0 +1,197 @@ +using Microsoft.UI.Xaml.Controls; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Collections.ObjectModel; +using System.Linq; +using Windows.Foundation; + +namespace WinUI.TableView.Tests; + +[TestClass] +public class DragSelectionRectangleTests +{ + private static TableView CreateTableView(ListViewSelectionMode selectionMode = ListViewSelectionMode.Extended) + { + var tv = new TableView + { + ItemsSource = Enumerable.Range(0, 20).ToList(), + SelectionMode = selectionMode, + }; + tv.Columns.Add(new TableViewTextColumn { Header = "Col1" }); + tv.Columns.Add(new TableViewTextColumn { Header = "Col2" }); + return tv; + } + + [UITestMethod] + public void ShowDragRectangle_DefaultsToTrue() + { + var tv = new TableView(); + Assert.IsTrue(tv.ShowDragRectangle); + } + + [UITestMethod] + public void ShowDragRectangle_CanBeSetToFalse() + { + var tv = new TableView(); + tv.ShowDragRectangle = false; + Assert.IsFalse(tv.ShowDragRectangle); + } + + [UITestMethod] + public void StartDragRectangle_SetsIsDragging_WhenExtendedMode() + { + var tv = CreateTableView(ListViewSelectionMode.Extended); + Assert.IsFalse(tv._isDragging); + + tv.StartDragRectangle(new Point(10, 10)); + + Assert.IsTrue(tv._isDragging); + } + + [UITestMethod] + public void StartDragRectangle_DoesNotStart_WhenSingleMode() + { + var tv = CreateTableView(ListViewSelectionMode.Single); + + tv.StartDragRectangle(new Point(10, 10)); + + Assert.IsFalse(tv._isDragging); + } + + [UITestMethod] + public void StartDragRectangle_DoesNotStart_WhenNoneMode() + { + var tv = CreateTableView(ListViewSelectionMode.None); + + tv.StartDragRectangle(new Point(10, 10)); + + Assert.IsFalse(tv._isDragging); + } + + [UITestMethod] + public void StartDragRectangle_DoesNotStart_WhenShowDragRectangleIsFalse() + { + var tv = CreateTableView(ListViewSelectionMode.Extended); + tv.ShowDragRectangle = false; + + tv.StartDragRectangle(new Point(10, 10)); + + Assert.IsFalse(tv._isDragging); + } + + [UITestMethod] + public void StartDragRectangle_Starts_InMultipleMode() + { + var tv = CreateTableView(ListViewSelectionMode.Multiple); + + tv.StartDragRectangle(new Point(10, 10)); + + Assert.IsTrue(tv._isDragging); + } + + [UITestMethod] + public void EndDragRectangle_ResetsIsDragging() + { + var tv = CreateTableView(ListViewSelectionMode.Extended); + tv.StartDragRectangle(new Point(10, 10)); + Assert.IsTrue(tv._isDragging); + + tv.EndDragRectangle(); + + Assert.IsFalse(tv._isDragging); + } + + [UITestMethod] + public void EndDragRectangle_IsIdempotent() + { + var tv = CreateTableView(ListViewSelectionMode.Extended); + tv.StartDragRectangle(new Point(10, 10)); + + tv.EndDragRectangle(); + tv.EndDragRectangle(); // second call should not throw + + Assert.IsFalse(tv._isDragging); + } + + [UITestMethod] + public void ShowDragRectangle_SetFalse_EndsDragIfActive() + { + var tv = CreateTableView(ListViewSelectionMode.Extended); + tv.StartDragRectangle(new Point(10, 10)); + Assert.IsTrue(tv._isDragging); + + tv.ShowDragRectangle = false; + + Assert.IsFalse(tv._isDragging); + } + + [TestMethod] + public void TableViewCellSlot_Equality_SameValues_AreEqual() + { + var slot1 = new TableViewCellSlot(3, 5); + var slot2 = new TableViewCellSlot(3, 5); + Assert.AreEqual(slot1, slot2); + Assert.IsTrue(slot1 == slot2); + } + + [TestMethod] + public void TableViewCellSlot_Equality_DifferentValues_AreNotEqual() + { + var slot1 = new TableViewCellSlot(3, 5); + var slot2 = new TableViewCellSlot(4, 5); + Assert.AreNotEqual(slot1, slot2); + Assert.IsTrue(slot1 != slot2); + + var slot3 = new TableViewCellSlot(3, 6); + Assert.AreNotEqual(slot1, slot3); + } + + [TestMethod] + public void TableViewCellSlot_HashCode_SameValues_SameHash() + { + var slot1 = new TableViewCellSlot(3, 5); + var slot2 = new TableViewCellSlot(3, 5); + Assert.AreEqual(slot1.GetHashCode(), slot2.GetHashCode()); + } + + [UITestMethod] + public void ShowDragRectangleProperty_IsRegistered() + { + var tv = new TableView(); + var value = tv.GetValue(TableView.ShowDragRectangleProperty); + Assert.IsInstanceOfType(value, typeof(bool)); + Assert.IsTrue((bool)value); + } + + [UITestMethod] + public void ShowDragRectangleProperty_CanBeSetViaDP() + { + var tv = new TableView(); + tv.SetValue(TableView.ShowDragRectangleProperty, false); + Assert.IsFalse(tv.ShowDragRectangle); + } + + [UITestMethod] + public void StartDragRectangle_DoesNotStart_WhenCanvasIsNull() + { + // Before OnApplyTemplate, _dragRectangleCanvas is null + var tv = CreateTableView(ListViewSelectionMode.Extended); + Assert.IsNull(tv._dragRectangleCanvas); + + tv.StartDragRectangle(new Point(10, 10)); + + // Should not crash, and should not set _isDragging since canvas is null + Assert.IsFalse(tv._isDragging); + } + + [UITestMethod] + public void UpdateDragRectangleVisual_DoesNotCrash_WhenNotDragging() + { + var tv = CreateTableView(); + + // Should be a no-op, not throw + tv.UpdateDragRectangleVisual(new Point(50, 50)); + + Assert.IsFalse(tv._isDragging); + } +} From 6ba4a6ed9f1df9b7a3d44eab64a5da522db16fc3 Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Thu, 9 Apr 2026 17:12:19 +0500 Subject: [PATCH 03/10] Fix for drag selection during scroll Signed-off-by: Godly Alias --- src/TableView.cs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/TableViewCell.cs | 4 +++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/TableView.cs b/src/TableView.cs index 05488649..960b7c2b 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -3,6 +3,7 @@ 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; @@ -1398,6 +1399,48 @@ private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerVie // Reposition the rectangle using scroll-adjusted start point PositionDragRectangle(_lastDragCanvasPoint.Value); + + // Update selection for newly visible rows during auto-scroll or mouse-wheel scroll + var cell = FindCellAtCanvasPoint(_lastDragCanvasPoint.Value); + if (cell is not null && cell.Slot != CurrentCellSlot) + { + var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); + MakeSelection(cell.Slot, true, ctrlKey); + } + } + + /// + /// Finds the cell at the specified canvas point using visual tree hit-testing. + /// Clamps the point to the canvas bounds so that out-of-viewport positions + /// resolve to the nearest edge cell. + /// + private TableViewCell? FindCellAtCanvasPoint(Point canvasPoint) + { + if (_dragRectangleCanvas is null || _scrollViewer is null) return null; + + // Clamp to canvas bounds so out-of-viewport positions find the edge cell + var clampedPoint = new Point( + Math.Clamp(canvasPoint.X, 1, Math.Max(1, _dragRectangleCanvas.ActualWidth - 1)), + Math.Clamp(canvasPoint.Y, 1, Math.Max(1, _dragRectangleCanvas.ActualHeight - 1))); + + try + { + var screenPoint = _dragRectangleCanvas.TransformToVisual(null).TransformPoint(clampedPoint); +#if WINDOWS + return VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer) +#else + return VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer, true) + .OfType() + .Where(x => x.Name is "Content") + .Select(x => x.FindAscendant() is { } cell ? cell : default) +#endif + .OfType() + .FirstOrDefault(); + } + catch (ArgumentException) + { + return null; + } } /// diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 22c13300..fc158722 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -298,7 +298,9 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) } } - // Selection via FindCell — same proven path whether rectangle is on or off + // 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) From fc34cd8ad5965e808521bd8d72f132084e4f2d7f Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Thu, 9 Apr 2026 17:13:59 +0500 Subject: [PATCH 04/10] Fix for tests Signed-off-by: Godly Alias --- tests/DragSelectionRectangleTests.cs | 29 ---------------------------- 1 file changed, 29 deletions(-) diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs index e3545af9..11d72379 100644 --- a/tests/DragSelectionRectangleTests.cs +++ b/tests/DragSelectionRectangleTests.cs @@ -125,35 +125,6 @@ public void ShowDragRectangle_SetFalse_EndsDragIfActive() Assert.IsFalse(tv._isDragging); } - [TestMethod] - public void TableViewCellSlot_Equality_SameValues_AreEqual() - { - var slot1 = new TableViewCellSlot(3, 5); - var slot2 = new TableViewCellSlot(3, 5); - Assert.AreEqual(slot1, slot2); - Assert.IsTrue(slot1 == slot2); - } - - [TestMethod] - public void TableViewCellSlot_Equality_DifferentValues_AreNotEqual() - { - var slot1 = new TableViewCellSlot(3, 5); - var slot2 = new TableViewCellSlot(4, 5); - Assert.AreNotEqual(slot1, slot2); - Assert.IsTrue(slot1 != slot2); - - var slot3 = new TableViewCellSlot(3, 6); - Assert.AreNotEqual(slot1, slot3); - } - - [TestMethod] - public void TableViewCellSlot_HashCode_SameValues_SameHash() - { - var slot1 = new TableViewCellSlot(3, 5); - var slot2 = new TableViewCellSlot(3, 5); - Assert.AreEqual(slot1.GetHashCode(), slot2.GetHashCode()); - } - [UITestMethod] public void ShowDragRectangleProperty_IsRegistered() { From 37df8965ef70c804c5815734c106490b99ffef6a Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Fri, 10 Apr 2026 14:56:28 +0500 Subject: [PATCH 05/10] Fix for tests Signed-off-by: Godly Alias --- tests/DragSelectionRectangleTests.cs | 34 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs index 11d72379..a62001f2 100644 --- a/tests/DragSelectionRectangleTests.cs +++ b/tests/DragSelectionRectangleTests.cs @@ -3,6 +3,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; using System.Collections.ObjectModel; using System.Linq; +using System.Threading.Tasks; using Windows.Foundation; namespace WinUI.TableView.Tests; @@ -22,6 +23,13 @@ private static TableView CreateTableView(ListViewSelectionMode selectionMode = L return tv; } + private static async Task CreateAndLoadTableView(ListViewSelectionMode selectionMode = ListViewSelectionMode.Extended) + { + var tv = CreateTableView(selectionMode); + await UnitTestApp.Current.MainWindow.LoadTestContentAsync(tv); + return tv; + } + [UITestMethod] public void ShowDragRectangle_DefaultsToTrue() { @@ -38,14 +46,17 @@ public void ShowDragRectangle_CanBeSetToFalse() } [UITestMethod] - public void StartDragRectangle_SetsIsDragging_WhenExtendedMode() + public async Task StartDragRectangle_SetsIsDragging_WhenExtendedMode() { - var tv = CreateTableView(ListViewSelectionMode.Extended); + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); Assert.IsFalse(tv._isDragging); tv.StartDragRectangle(new Point(10, 10)); Assert.IsTrue(tv._isDragging); + + tv.EndDragRectangle(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] @@ -80,25 +91,30 @@ public void StartDragRectangle_DoesNotStart_WhenShowDragRectangleIsFalse() } [UITestMethod] - public void StartDragRectangle_Starts_InMultipleMode() + public async Task StartDragRectangle_Starts_InMultipleMode() { - var tv = CreateTableView(ListViewSelectionMode.Multiple); + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Multiple); tv.StartDragRectangle(new Point(10, 10)); Assert.IsTrue(tv._isDragging); + + tv.EndDragRectangle(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] - public void EndDragRectangle_ResetsIsDragging() + public async Task EndDragRectangle_ResetsIsDragging() { - var tv = CreateTableView(ListViewSelectionMode.Extended); + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); tv.StartDragRectangle(new Point(10, 10)); Assert.IsTrue(tv._isDragging); tv.EndDragRectangle(); Assert.IsFalse(tv._isDragging); + + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] @@ -114,15 +130,17 @@ public void EndDragRectangle_IsIdempotent() } [UITestMethod] - public void ShowDragRectangle_SetFalse_EndsDragIfActive() + public async Task ShowDragRectangle_SetFalse_EndsDragIfActive() { - var tv = CreateTableView(ListViewSelectionMode.Extended); + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); tv.StartDragRectangle(new Point(10, 10)); Assert.IsTrue(tv._isDragging); tv.ShowDragRectangle = false; Assert.IsFalse(tv._isDragging); + + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] From 4000f1d2b804bfad8afc4da2a0c9cce6346aecc9 Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Thu, 16 Apr 2026 00:32:01 +0500 Subject: [PATCH 06/10] Changes to split auto scroll & Drag selection rectangle Signed-off-by: Godly Alias --- src/TableView.Properties.cs | 10 +- src/TableView.cs | 219 +++++++++++++++++---------- src/TableViewCell.cs | 21 ++- src/Themes/TableView.xaml | 6 +- tests/DragSelectionRectangleTests.cs | 93 +++++++----- 5 files changed, 213 insertions(+), 136 deletions(-) diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index d1d44b9c..4826b8e9 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -269,7 +269,7 @@ public partial class TableView /// /// Identifies the dependency property. /// - public static readonly DependencyProperty ShowDragRectangleProperty = DependencyProperty.Register(nameof(ShowDragRectangle), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowDragRectangleChanged)); + public static readonly DependencyProperty ShowDragRectangleProperty = DependencyProperty.Register(nameof(ShowDragRectangle), typeof(bool), typeof(TableView), new PropertyMetadata(false, OnShowDragRectangleChanged)); /// /// Gets or sets a value indicating whether opening the column filter over header right-click is enabled. @@ -895,7 +895,13 @@ private static void OnShowDragRectangleChanged(DependencyObject d, DependencyPro { if (d is TableView tableView && e.NewValue is false) { - tableView.EndDragRectangle(); + // Hide the rectangle visual but don't stop drag selection or auto-scroll + if (tableView._dragRectangle is not null) + { + tableView._dragRectangle.Visibility = Visibility.Collapsed; + } + + tableView._dragStartPoint = null; } } diff --git a/src/TableView.cs b/src/TableView.cs index 960b7c2b..5adc5888 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -41,11 +41,12 @@ public partial class TableView : ListView internal Canvas? _dragRectangleCanvas; private Border? _dragRectangle; private Point? _dragStartPoint; - internal bool _isDragging; + internal bool _isDragSelecting; private bool _cellSelectionDirty; private Point? _lastDragCanvasPoint; private DispatcherTimer? _autoScrollTimer; - private double _autoScrollDelta; + private double _autoScrollVerticalDelta; + private double _autoScrollHorizontalDelta; private double _dragStartVerticalOffset; private double _dragStartHorizontalOffset; @@ -347,7 +348,7 @@ private void OnLoaded(object sender, RoutedEventArgs e) /// private void OnUnloaded(object sender, RoutedEventArgs e) { - EndDragRectangle(); + EndDragSelection(); StopAutoScroll(); if (IsEditing && CurrentCellSlot.HasValue && GetCellFromSlot(CurrentCellSlot.Value) is { } currentCell) @@ -1165,7 +1166,7 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel { // 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 (_isDragging) + if (_isDragSelecting) { var cell = GetCellFromSlot(newSlot.Value); cell?.ApplyCurrentCellState(skipFocus: true); @@ -1223,55 +1224,65 @@ private void InvokeCellSelectionChangedEvent(HashSet oldSelec } /// - /// Starts showing the drag selection rectangle at the specified position. + /// Starts drag selection tracking, auto-scroll, and optionally the drag rectangle visual. /// /// The starting point relative to the drag rectangle canvas. - internal void StartDragRectangle(Point startPoint) + internal void StartDragSelection(Point startPoint) { - if (!ShowDragRectangle || _dragRectangleCanvas is null || _dragRectangle is null || - SelectionMode is not (ListViewSelectionMode.Multiple or ListViewSelectionMode.Extended)) + if (SelectionMode is not (ListViewSelectionMode.Multiple or ListViewSelectionMode.Extended)) { return; } // Guard against re-entry (e.g., multi-touch) to prevent double ViewChanged subscription - if (_isDragging) + if (_isDragSelecting) { - EndDragRectangle(); + EndDragSelection(); } - _dragStartPoint = startPoint; - _isDragging = true; + _isDragSelecting = true; _lastDragCanvasPoint = startPoint; _dragStartVerticalOffset = _scrollViewer?.VerticalOffset ?? 0; _dragStartHorizontalOffset = HorizontalOffset; - Canvas.SetLeft(_dragRectangle, startPoint.X); - Canvas.SetTop(_dragRectangle, startPoint.Y); - _dragRectangle.Width = 0; - _dragRectangle.Height = 0; - - _dragRectangleCanvas.Visibility = Visibility.Visible; - if (_scrollViewer is not null) { _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) + { + _dragStartPoint = startPoint; + + Canvas.SetLeft(_dragRectangle, startPoint.X); + Canvas.SetTop(_dragRectangle, startPoint.Y); + _dragRectangle.Width = 0; + _dragRectangle.Height = 0; + + _dragRectangle.Visibility = Visibility.Visible; + } } /// - /// Updates the drag rectangle visual and auto-scroll. Selection is handled separately via FindCell+MakeSelection. + /// Updates the drag visual and auto-scroll during drag selection. /// /// The current pointer position relative to the drag rectangle canvas. internal void UpdateDragRectangleVisual(Point currentPoint) { - if (!_isDragging || _dragStartPoint is null || _dragRectangleCanvas is null || _dragRectangle is null) + if (!_isDragSelecting) { return; } _lastDragCanvasPoint = currentPoint; - PositionDragRectangle(currentPoint); + + // Update the rectangle visual if it's active + if (_dragStartPoint is not null && _dragRectangleCanvas is not null && _dragRectangle is not null) + { + PositionDragRectangle(currentPoint); + } + UpdateAutoScroll(currentPoint); } @@ -1309,40 +1320,42 @@ private void PositionDragRectangle(Point currentPoint) /// private void UpdateAutoScroll(Point canvasPoint) { - if (_scrollViewer is null || _dragRectangleCanvas is null) return; + if (_scrollViewer is null) return; const double edgeThreshold = 40; const double maxScrollSpeed = 20; - var canvasHeight = _dragRectangleCanvas.ActualHeight; - double scrollDelta = 0; - if (canvasPoint.Y > canvasHeight - edgeThreshold) + var viewportHeight = _scrollViewer.ViewportHeight; + var viewportWidth = _scrollViewer.ViewportWidth; + double vDelta = 0; + double hDelta = 0; + + if (canvasPoint.Y > viewportHeight - edgeThreshold) { - var proximity = Math.Min(1.0, (canvasPoint.Y - (canvasHeight - edgeThreshold)) / edgeThreshold); - scrollDelta = proximity * maxScrollSpeed; + var proximity = Math.Min(1.0, (canvasPoint.Y - (viewportHeight - edgeThreshold)) / edgeThreshold); + vDelta = proximity * maxScrollSpeed; } else if (canvasPoint.Y < edgeThreshold) { var proximity = Math.Min(1.0, (edgeThreshold - canvasPoint.Y) / edgeThreshold); - scrollDelta = -(proximity * maxScrollSpeed); + vDelta = -(proximity * maxScrollSpeed); } - if (canvasPoint.X > _dragRectangleCanvas.ActualWidth - edgeThreshold) + if (canvasPoint.X > viewportWidth - edgeThreshold) { - var proximity = Math.Min(1.0, (canvasPoint.X - (_dragRectangleCanvas.ActualWidth - edgeThreshold)) / edgeThreshold); - var hDelta = proximity * maxScrollSpeed; - SetValue(HorizontalOffsetProperty, Math.Clamp(HorizontalOffset + hDelta, 0, _scrollViewer.ScrollableWidth)); + var proximity = Math.Min(1.0, (canvasPoint.X - (viewportWidth - edgeThreshold)) / edgeThreshold); + hDelta = proximity * maxScrollSpeed; } else if (canvasPoint.X < edgeThreshold) { var proximity = Math.Min(1.0, (edgeThreshold - canvasPoint.X) / edgeThreshold); - var hDelta = -(proximity * maxScrollSpeed); - SetValue(HorizontalOffsetProperty, Math.Clamp(HorizontalOffset + hDelta, 0, _scrollViewer.ScrollableWidth)); + hDelta = -(proximity * maxScrollSpeed); } - if (Math.Abs(scrollDelta) > 0.5) + if (Math.Abs(vDelta) > 0.5 || Math.Abs(hDelta) > 0.5) { - _autoScrollDelta = scrollDelta; + _autoScrollVerticalDelta = vDelta; + _autoScrollHorizontalDelta = hDelta; if (_autoScrollTimer is null) { _autoScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; @@ -1362,24 +1375,62 @@ private void UpdateAutoScroll(Point canvasPoint) /// private void OnAutoScrollTimerTick(object? sender, object e) { - if (!_isDragging || _scrollViewer is null) + if (!_isDragSelecting || _scrollViewer is null) { StopAutoScroll(); return; } - var newOffset = Math.Clamp( - _scrollViewer.VerticalOffset + _autoScrollDelta, - 0, - _scrollViewer.ScrollableHeight); + var scrolled = false; + + // Vertical auto-scroll via ChangeView + if (Math.Abs(_autoScrollVerticalDelta) > 0.5) + { + var newOffset = Math.Clamp( + _scrollViewer.VerticalOffset + _autoScrollVerticalDelta, + 0, + _scrollViewer.ScrollableHeight); + + if (Math.Abs(newOffset - _scrollViewer.VerticalOffset) >= 0.5) + { + _scrollViewer.ChangeView(null, newOffset, null, true); + scrolled = true; + } + } - if (Math.Abs(newOffset - _scrollViewer.VerticalOffset) < 0.5) + // Horizontal auto-scroll via HorizontalOffset DP + if (Math.Abs(_autoScrollHorizontalDelta) > 0.5) + { + var newOffset = Math.Clamp( + HorizontalOffset + _autoScrollHorizontalDelta, + 0, + _scrollViewer.ScrollableWidth); + + if (Math.Abs(newOffset - HorizontalOffset) >= 0.5) + { + SetValue(HorizontalOffsetProperty, newOffset); + scrolled = true; + } + } + + if (!scrolled) { StopAutoScroll(); return; } - _scrollViewer.ChangeView(null, newOffset, null, true); + // 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) + { + if (_dragStartPoint is not null && _dragRectangleCanvas is not null && _dragRectangle is not null) + { + PositionDragRectangle(_lastDragCanvasPoint.Value); + } + + SelectCellAtDragPoint(); + } } /// @@ -1387,7 +1438,12 @@ private void OnAutoScrollTimerTick(object? sender, object e) /// private void StopAutoScroll() { - _autoScrollTimer?.Stop(); + if (_autoScrollTimer is not null) + { + _autoScrollTimer.Stop(); + _autoScrollTimer.Tick -= OnAutoScrollTimerTick; + _autoScrollTimer = null; + } } /// @@ -1395,60 +1451,69 @@ private void StopAutoScroll() /// private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerViewChangedEventArgs e) { - if (!_isDragging || _lastDragCanvasPoint is null) return; - - // Reposition the rectangle using scroll-adjusted start point - PositionDragRectangle(_lastDragCanvasPoint.Value); + if (!_isDragSelecting || _lastDragCanvasPoint is null) return; - // Update selection for newly visible rows during auto-scroll or mouse-wheel scroll - var cell = FindCellAtCanvasPoint(_lastDragCanvasPoint.Value); - if (cell is not null && cell.Slot != CurrentCellSlot) + // Reposition the rectangle using scroll-adjusted start point (if rectangle is active) + if (_dragStartPoint is not null && _dragRectangleCanvas is not null && _dragRectangle is not null) { - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - MakeSelection(cell.Slot, true, ctrlKey); + PositionDragRectangle(_lastDragCanvasPoint.Value); } + + // Update selection for newly visible rows during auto-scroll + SelectCellAtDragPoint(); } /// - /// Finds the cell at the specified canvas point using visual tree hit-testing. - /// Clamps the point to the canvas bounds so that out-of-viewport positions - /// resolve to the nearest edge cell. + /// 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 TableViewCell? FindCellAtCanvasPoint(Point canvasPoint) + private void SelectCellAtDragPoint() { - if (_dragRectangleCanvas is null || _scrollViewer is null) return null; + if (_scrollViewer is null || _lastDragCanvasPoint is null || _dragRectangleCanvas is null) + { + return; + } - // Clamp to canvas bounds so out-of-viewport positions find the edge cell + // 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, 1, Math.Max(1, _dragRectangleCanvas.ActualWidth - 1)), - Math.Clamp(canvasPoint.Y, 1, Math.Max(1, _dragRectangleCanvas.ActualHeight - 1))); + 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 - return VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer) + var cell = VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer) #else - return VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer, true) - .OfType() - .Where(x => x.Name is "Content") - .Select(x => x.FindAscendant() is { } cell ? cell : default) + 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(); + .OfType() + .FirstOrDefault(); + + if (cell is not null && cell.Slot != CurrentCellSlot) + { + var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); + MakeSelection(cell.Slot, true, ctrlKey); + } } catch (ArgumentException) { - return null; + // Element not in visual tree during container recycling } } /// - /// Ends and hides the drag selection rectangle. + /// Ends drag selection tracking, auto-scroll, and hides the drag rectangle if visible. /// - internal async void EndDragRectangle() + internal async void EndDragSelection() { - if (!_isDragging) return; + if (!_isDragSelecting) return; StopAutoScroll(); @@ -1457,12 +1522,12 @@ internal async void EndDragRectangle() _scrollViewer.ViewChanged -= OnScrollViewerViewChangedDuringDrag; } - if (_dragRectangleCanvas is not null) + if (_dragRectangle is not null) { - _dragRectangleCanvas.Visibility = Visibility.Collapsed; + _dragRectangle.Visibility = Visibility.Collapsed; } - _isDragging = false; + _isDragSelecting = false; _dragStartPoint = null; _lastDragCanvasPoint = null; @@ -1475,7 +1540,7 @@ internal async void EndDragRectangle() cell?.ApplyCurrentCellState(); } } - catch + catch (Exception) { // Focus restoration is best-effort after drag ends } diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index fc158722..1e1cef26 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -242,15 +242,12 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) TableView.SelectionStartRowIndex = Index; CapturePointer(e.Pointer); - // Only start drag rectangle when cell selection is possible - if (TableView.SelectionStartCellSlot.HasValue && TableView.SelectionStartCellSlot.Value.IsValid(TableView)) + // Start drag selection (auto-scroll + optional rectangle visual) + var point = e.GetCurrentPoint(this).Position; + var canvasPoint = TransformPointToCanvas(point); + if (canvasPoint.HasValue) { - var point = e.GetCurrentPoint(this).Position; - var canvasPoint = TransformPointToCanvas(point); - if (canvasPoint.HasValue) - { - TableView.StartDragRectangle(canvasPoint.Value); - } + TableView.StartDragSelection(canvasPoint.Value); } } } @@ -267,7 +264,7 @@ protected override void OnPointerReleased(PointerRoutedEventArgs e) TableView.SelectionStartRowIndex = cell?.Slot.Row; } - TableView?.EndDragRectangle(); + TableView?.EndDragSelection(); ReleasePointerCaptures(); e.Handled = true; @@ -278,7 +275,7 @@ protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) { base.OnPointerCaptureLost(e); - TableView?.EndDragRectangle(); + TableView?.EndDragSelection(); } /// @@ -288,8 +285,8 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) if (PointerCaptures?.Any() is true) { - // Update drag rectangle visual and auto-scroll (cheap — just Canvas property sets + edge math) - if (TableView?._isDragging is true) + // Update drag rectangle visual and auto-scroll + if (TableView?._isDragSelecting is true) { var canvasPoint = TransformPointToCanvas(e.Position); if (canvasPoint.HasValue) diff --git a/src/Themes/TableView.xaml b/src/Themes/TableView.xaml index 8fbbd273..a393ec33 100644 --- a/src/Themes/TableView.xaml +++ b/src/Themes/TableView.xaml @@ -129,13 +129,13 @@ + IsHitTestVisible="False"> + CornerRadius="{ThemeResource TableViewDragRectangleCornerRadius}" + Visibility="Collapsed" /> diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs index a62001f2..f36d503b 100644 --- a/tests/DragSelectionRectangleTests.cs +++ b/tests/DragSelectionRectangleTests.cs @@ -1,7 +1,7 @@ +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; -using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Windows.Foundation; @@ -31,10 +31,10 @@ private static async Task CreateAndLoadTableView(ListViewSelectionMod } [UITestMethod] - public void ShowDragRectangle_DefaultsToTrue() + public void ShowDragRectangle_DefaultsToFalse() { var tv = new TableView(); - Assert.IsTrue(tv.ShowDragRectangle); + Assert.IsFalse(tv.ShowDragRectangle); } [UITestMethod] @@ -46,100 +46,107 @@ public void ShowDragRectangle_CanBeSetToFalse() } [UITestMethod] - public async Task StartDragRectangle_SetsIsDragging_WhenExtendedMode() + public async Task StartDragSelection_SetsIsDragging_WhenExtendedMode() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); - Assert.IsFalse(tv._isDragging); + Assert.IsFalse(tv._isDragSelecting); - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); - Assert.IsTrue(tv._isDragging); + Assert.IsTrue(tv._isDragSelecting); - tv.EndDragRectangle(); + tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] - public void StartDragRectangle_DoesNotStart_WhenSingleMode() + public void StartDragSelection_DoesNotStart_WhenSingleMode() { var tv = CreateTableView(ListViewSelectionMode.Single); - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); - Assert.IsFalse(tv._isDragging); + Assert.IsFalse(tv._isDragSelecting); } [UITestMethod] - public void StartDragRectangle_DoesNotStart_WhenNoneMode() + public void StartDragSelection_DoesNotStart_WhenNoneMode() { var tv = CreateTableView(ListViewSelectionMode.None); - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); - Assert.IsFalse(tv._isDragging); + Assert.IsFalse(tv._isDragSelecting); } [UITestMethod] - public void StartDragRectangle_DoesNotStart_WhenShowDragRectangleIsFalse() + public async Task StartDragSelection_StartsButNoRectangle_WhenShowDragRectangleIsFalse() { - var tv = CreateTableView(ListViewSelectionMode.Extended); + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); tv.ShowDragRectangle = false; - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); - Assert.IsFalse(tv._isDragging); + // Drag selection and auto-scroll are active, but rectangle visual is not shown + Assert.IsTrue(tv._isDragSelecting); + Assert.AreEqual(Visibility.Collapsed, tv._dragRectangleCanvas?.Children.OfType().FirstOrDefault()?.Visibility); + + tv.EndDragSelection(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] - public async Task StartDragRectangle_Starts_InMultipleMode() + public async Task StartDragSelection_Starts_InMultipleMode() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Multiple); - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); - Assert.IsTrue(tv._isDragging); + Assert.IsTrue(tv._isDragSelecting); - tv.EndDragRectangle(); + tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] - public async Task EndDragRectangle_ResetsIsDragging() + public async Task EndDragSelection_ResetsIsDragging() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); - tv.StartDragRectangle(new Point(10, 10)); - Assert.IsTrue(tv._isDragging); + tv.StartDragSelection(new Point(10, 10)); + Assert.IsTrue(tv._isDragSelecting); - tv.EndDragRectangle(); + tv.EndDragSelection(); - Assert.IsFalse(tv._isDragging); + Assert.IsFalse(tv._isDragSelecting); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } [UITestMethod] - public void EndDragRectangle_IsIdempotent() + public void EndDragSelection_IsIdempotent() { var tv = CreateTableView(ListViewSelectionMode.Extended); - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); - tv.EndDragRectangle(); - tv.EndDragRectangle(); // second call should not throw + tv.EndDragSelection(); + tv.EndDragSelection(); // second call should not throw - Assert.IsFalse(tv._isDragging); + Assert.IsFalse(tv._isDragSelecting); } [UITestMethod] - public async Task ShowDragRectangle_SetFalse_EndsDragIfActive() + public async Task ShowDragRectangle_SetFalse_HidesRectangleButKeepsDragActive() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); - tv.StartDragRectangle(new Point(10, 10)); - Assert.IsTrue(tv._isDragging); + tv.StartDragSelection(new Point(10, 10)); + Assert.IsTrue(tv._isDragSelecting); tv.ShowDragRectangle = false; - Assert.IsFalse(tv._isDragging); + // Drag selection and auto-scroll remain active, but rectangle visual is hidden + Assert.IsTrue(tv._isDragSelecting); + tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } @@ -149,7 +156,7 @@ public void ShowDragRectangleProperty_IsRegistered() var tv = new TableView(); var value = tv.GetValue(TableView.ShowDragRectangleProperty); Assert.IsInstanceOfType(value, typeof(bool)); - Assert.IsTrue((bool)value); + Assert.IsFalse((bool)value); } [UITestMethod] @@ -161,16 +168,18 @@ public void ShowDragRectangleProperty_CanBeSetViaDP() } [UITestMethod] - public void StartDragRectangle_DoesNotStart_WhenCanvasIsNull() + public void StartDragSelection_StartsEvenWhenCanvasIsNull() { // Before OnApplyTemplate, _dragRectangleCanvas is null var tv = CreateTableView(ListViewSelectionMode.Extended); Assert.IsNull(tv._dragRectangleCanvas); - tv.StartDragRectangle(new Point(10, 10)); + tv.StartDragSelection(new Point(10, 10)); + + // Drag selection and auto-scroll start even without canvas (rectangle visual just won't show) + Assert.IsTrue(tv._isDragSelecting); - // Should not crash, and should not set _isDragging since canvas is null - Assert.IsFalse(tv._isDragging); + tv.EndDragSelection(); } [UITestMethod] @@ -181,6 +190,6 @@ public void UpdateDragRectangleVisual_DoesNotCrash_WhenNotDragging() // Should be a no-op, not throw tv.UpdateDragRectangleVisual(new Point(50, 50)); - Assert.IsFalse(tv._isDragging); + Assert.IsFalse(tv._isDragSelecting); } } From dd77a1450d039a3bf0c13cf97e5918ecb8b7cd89 Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Fri, 12 Jun 2026 21:10:37 +0600 Subject: [PATCH 07/10] Convert internal drag-selection fields to internal properties Address PR review feedback: change the _dragRectangleCanvas and _isDragSelecting internal fields into internal properties (DragRectangleCanvas and IsDragSelecting) with private setters, following the codebase convention for internal members. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/TableView.Properties.cs | 10 ++++++++++ src/TableView.cs | 38 ++++++++++++++++++------------------- src/TableViewCell.cs | 6 +++--- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 4826b8e9..7f320f6a 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -375,6 +375,16 @@ public bool ShowDragRectangle /// internal bool IsEditing { get; private set; } + /// + /// Gets the canvas that hosts the drag selection rectangle. + /// + internal Canvas? DragRectangleCanvas { get; private set; } + + /// + /// Gets a value indicating whether a drag selection is currently in progress. + /// + internal bool IsDragSelecting { get; private set; } + /// /// Gets the visibility states of details pane for each item. /// diff --git a/src/TableView.cs b/src/TableView.cs index 5adc5888..63cf424d 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -38,10 +38,8 @@ public partial class TableView : ListView private bool _ensureColumns = true; private readonly List _rows = []; private readonly CollectionView _collectionView = []; - internal Canvas? _dragRectangleCanvas; private Border? _dragRectangle; private Point? _dragStartPoint; - internal bool _isDragSelecting; private bool _cellSelectionDirty; private Point? _lastDragCanvasPoint; private DispatcherTimer? _autoScrollTimer; @@ -294,7 +292,7 @@ protected async override void OnApplyTemplate() _headerRow = GetTemplateChild("HeaderRow") as TableViewHeaderRow; _scrollViewer = GetTemplateChild("ScrollViewer") as ScrollViewer; _headerRowDefinition = GetTemplateChild("HeaderRowDefinition") as RowDefinition; - _dragRectangleCanvas = GetTemplateChild("DragRectangleCanvas") as Canvas; + DragRectangleCanvas = GetTemplateChild("DragRectangleCanvas") as Canvas; _dragRectangle = GetTemplateChild("DragRectangle") as Border; if (_scrollViewer is not null) _scrollViewer.Loaded += OnScrollViewerLoaded; @@ -1166,7 +1164,7 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel { // 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) + if (IsDragSelecting) { var cell = GetCellFromSlot(newSlot.Value); cell?.ApplyCurrentCellState(skipFocus: true); @@ -1235,12 +1233,12 @@ internal void StartDragSelection(Point startPoint) } // Guard against re-entry (e.g., multi-touch) to prevent double ViewChanged subscription - if (_isDragSelecting) + if (IsDragSelecting) { EndDragSelection(); } - _isDragSelecting = true; + IsDragSelecting = true; _lastDragCanvasPoint = startPoint; _dragStartVerticalOffset = _scrollViewer?.VerticalOffset ?? 0; _dragStartHorizontalOffset = HorizontalOffset; @@ -1251,7 +1249,7 @@ internal void StartDragSelection(Point startPoint) } // Show the drag rectangle visual if enabled and template parts are available - if (ShowDragRectangle && _dragRectangleCanvas is not null && _dragRectangle is not null) + if (ShowDragRectangle && DragRectangleCanvas is not null && _dragRectangle is not null) { _dragStartPoint = startPoint; @@ -1270,7 +1268,7 @@ internal void StartDragSelection(Point startPoint) /// The current pointer position relative to the drag rectangle canvas. internal void UpdateDragRectangleVisual(Point currentPoint) { - if (!_isDragSelecting) + if (!IsDragSelecting) { return; } @@ -1278,7 +1276,7 @@ internal void UpdateDragRectangleVisual(Point currentPoint) _lastDragCanvasPoint = currentPoint; // Update the rectangle visual if it's active - if (_dragStartPoint is not null && _dragRectangleCanvas is not null && _dragRectangle is not null) + if (_dragStartPoint is not null && DragRectangleCanvas is not null && _dragRectangle is not null) { PositionDragRectangle(currentPoint); } @@ -1292,7 +1290,7 @@ internal void UpdateDragRectangleVisual(Point currentPoint) /// private void PositionDragRectangle(Point currentPoint) { - if (_dragStartPoint is null || _dragRectangleCanvas is null || _dragRectangle is null) return; + if (_dragStartPoint is null || DragRectangleCanvas is null || _dragRectangle is null) return; // Adjust the start point by how much the view has scrolled since drag began. // This makes the rectangle extend naturally as content scrolls. @@ -1301,8 +1299,8 @@ private void PositionDragRectangle(Point currentPoint) var adjustedStartY = _dragStartPoint.Value.Y - verticalScrollDelta; var adjustedStartX = _dragStartPoint.Value.X - horizontalScrollDelta; - var canvasWidth = _dragRectangleCanvas.ActualWidth; - var canvasHeight = _dragRectangleCanvas.ActualHeight; + var canvasWidth = DragRectangleCanvas.ActualWidth; + var canvasHeight = DragRectangleCanvas.ActualHeight; var left = Math.Max(0, Math.Min(adjustedStartX, currentPoint.X)); var top = Math.Max(0, Math.Min(adjustedStartY, currentPoint.Y)); @@ -1375,7 +1373,7 @@ private void UpdateAutoScroll(Point canvasPoint) /// private void OnAutoScrollTimerTick(object? sender, object e) { - if (!_isDragSelecting || _scrollViewer is null) + if (!IsDragSelecting || _scrollViewer is null) { StopAutoScroll(); return; @@ -1424,7 +1422,7 @@ private void OnAutoScrollTimerTick(object? sender, object e) // Vertical scroll fires ViewChanged which handles it via OnScrollViewerViewChangedDuringDrag. if (Math.Abs(_autoScrollHorizontalDelta) > 0.5 && _lastDragCanvasPoint is not null) { - if (_dragStartPoint is not null && _dragRectangleCanvas is not null && _dragRectangle is not null) + if (_dragStartPoint is not null && DragRectangleCanvas is not null && _dragRectangle is not null) { PositionDragRectangle(_lastDragCanvasPoint.Value); } @@ -1451,10 +1449,10 @@ private void StopAutoScroll() /// private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerViewChangedEventArgs e) { - if (!_isDragSelecting || _lastDragCanvasPoint is null) return; + if (!IsDragSelecting || _lastDragCanvasPoint is null) return; // Reposition the rectangle using scroll-adjusted start point (if rectangle is active) - if (_dragStartPoint is not null && _dragRectangleCanvas is not null && _dragRectangle is not null) + if (_dragStartPoint is not null && DragRectangleCanvas is not null && _dragRectangle is not null) { PositionDragRectangle(_lastDragCanvasPoint.Value); } @@ -1469,7 +1467,7 @@ private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerVie /// private void SelectCellAtDragPoint() { - if (_scrollViewer is null || _lastDragCanvasPoint is null || _dragRectangleCanvas is null) + if (_scrollViewer is null || _lastDragCanvasPoint is null || DragRectangleCanvas is null) { return; } @@ -1484,7 +1482,7 @@ private void SelectCellAtDragPoint() try { - var screenPoint = _dragRectangleCanvas.TransformToVisual(null).TransformPoint(clampedPoint); + var screenPoint = DragRectangleCanvas.TransformToVisual(null).TransformPoint(clampedPoint); #if WINDOWS var cell = VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer) #else @@ -1513,7 +1511,7 @@ private void SelectCellAtDragPoint() /// internal async void EndDragSelection() { - if (!_isDragSelecting) return; + if (!IsDragSelecting) return; StopAutoScroll(); @@ -1527,7 +1525,7 @@ internal async void EndDragSelection() _dragRectangle.Visibility = Visibility.Collapsed; } - _isDragSelecting = false; + IsDragSelecting = false; _dragStartPoint = null; _lastDragCanvasPoint = null; diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 1e1cef26..b01cf837 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -286,7 +286,7 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) if (PointerCaptures?.Any() is true) { // Update drag rectangle visual and auto-scroll - if (TableView?._isDragSelecting is true) + if (TableView?.IsDragSelecting is true) { var canvasPoint = TransformPointToCanvas(e.Position); if (canvasPoint.HasValue) @@ -343,11 +343,11 @@ private double GetHorizontalGridlineHeight() /// private Point? TransformPointToCanvas(Point position) { - if (TableView?._dragRectangleCanvas is null) return null; + if (TableView?.DragRectangleCanvas is null) return null; try { - var transform = TransformToVisual(TableView._dragRectangleCanvas); + var transform = TransformToVisual(TableView.DragRectangleCanvas); return transform.TransformPoint(position); } catch (ArgumentException) From 7c1f23982f51fe60fe42047442b6a5cfb4580a7f Mon Sep 17 00:00:00 2001 From: "Godly T.Alias" Date: Fri, 12 Jun 2026 21:05:41 +0530 Subject: [PATCH 08/10] Change default value of ShowDragRectangle property to true --- src/TableView.Properties.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 588ccba6..8b6fc070 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -303,7 +303,7 @@ public bool CanPaste /// /// Identifies the dependency property. /// - public static readonly DependencyProperty ShowDragRectangleProperty = DependencyProperty.Register(nameof(ShowDragRectangle), typeof(bool), typeof(TableView), new PropertyMetadata(false, OnShowDragRectangleChanged)); + public static readonly DependencyProperty ShowDragRectangleProperty = DependencyProperty.Register(nameof(ShowDragRectangle), typeof(bool), typeof(TableView), new PropertyMetadata(true, OnShowDragRectangleChanged)); /// /// Gets or sets a value indicating whether opening the column filter over header right-click is enabled. From 0ae40ae810db4ac1ded694e4cddf917397b74676 Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Fri, 12 Jun 2026 22:28:11 +0600 Subject: [PATCH 09/10] Update drag selection tests to use renamed internal properties Fixes CI build break: the internal fields _isDragSelecting and _dragRectangleCanvas were converted to internal properties (IsDragSelecting, DragRectangleCanvas), so update the test references accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/DragSelectionRectangleTests.cs | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs index f36d503b..237ed0eb 100644 --- a/tests/DragSelectionRectangleTests.cs +++ b/tests/DragSelectionRectangleTests.cs @@ -49,11 +49,11 @@ public void ShowDragRectangle_CanBeSetToFalse() public async Task StartDragSelection_SetsIsDragging_WhenExtendedMode() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); - Assert.IsFalse(tv._isDragSelecting); + Assert.IsFalse(tv.IsDragSelecting); tv.StartDragSelection(new Point(10, 10)); - Assert.IsTrue(tv._isDragSelecting); + Assert.IsTrue(tv.IsDragSelecting); tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); @@ -66,7 +66,7 @@ public void StartDragSelection_DoesNotStart_WhenSingleMode() tv.StartDragSelection(new Point(10, 10)); - Assert.IsFalse(tv._isDragSelecting); + Assert.IsFalse(tv.IsDragSelecting); } [UITestMethod] @@ -76,7 +76,7 @@ public void StartDragSelection_DoesNotStart_WhenNoneMode() tv.StartDragSelection(new Point(10, 10)); - Assert.IsFalse(tv._isDragSelecting); + Assert.IsFalse(tv.IsDragSelecting); } [UITestMethod] @@ -88,8 +88,8 @@ public async Task StartDragSelection_StartsButNoRectangle_WhenShowDragRectangleI tv.StartDragSelection(new Point(10, 10)); // Drag selection and auto-scroll are active, but rectangle visual is not shown - Assert.IsTrue(tv._isDragSelecting); - Assert.AreEqual(Visibility.Collapsed, tv._dragRectangleCanvas?.Children.OfType().FirstOrDefault()?.Visibility); + Assert.IsTrue(tv.IsDragSelecting); + Assert.AreEqual(Visibility.Collapsed, tv.DragRectangleCanvas?.Children.OfType().FirstOrDefault()?.Visibility); tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); @@ -102,7 +102,7 @@ public async Task StartDragSelection_Starts_InMultipleMode() tv.StartDragSelection(new Point(10, 10)); - Assert.IsTrue(tv._isDragSelecting); + Assert.IsTrue(tv.IsDragSelecting); tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); @@ -113,11 +113,11 @@ public async Task EndDragSelection_ResetsIsDragging() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); tv.StartDragSelection(new Point(10, 10)); - Assert.IsTrue(tv._isDragSelecting); + Assert.IsTrue(tv.IsDragSelecting); tv.EndDragSelection(); - Assert.IsFalse(tv._isDragSelecting); + Assert.IsFalse(tv.IsDragSelecting); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); } @@ -131,7 +131,7 @@ public void EndDragSelection_IsIdempotent() tv.EndDragSelection(); tv.EndDragSelection(); // second call should not throw - Assert.IsFalse(tv._isDragSelecting); + Assert.IsFalse(tv.IsDragSelecting); } [UITestMethod] @@ -139,12 +139,12 @@ public async Task ShowDragRectangle_SetFalse_HidesRectangleButKeepsDragActive() { var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); tv.StartDragSelection(new Point(10, 10)); - Assert.IsTrue(tv._isDragSelecting); + Assert.IsTrue(tv.IsDragSelecting); tv.ShowDragRectangle = false; // Drag selection and auto-scroll remain active, but rectangle visual is hidden - Assert.IsTrue(tv._isDragSelecting); + Assert.IsTrue(tv.IsDragSelecting); tv.EndDragSelection(); await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); @@ -170,14 +170,14 @@ public void ShowDragRectangleProperty_CanBeSetViaDP() [UITestMethod] public void StartDragSelection_StartsEvenWhenCanvasIsNull() { - // Before OnApplyTemplate, _dragRectangleCanvas is null + // Before OnApplyTemplate, DragRectangleCanvas is null var tv = CreateTableView(ListViewSelectionMode.Extended); - Assert.IsNull(tv._dragRectangleCanvas); + Assert.IsNull(tv.DragRectangleCanvas); tv.StartDragSelection(new Point(10, 10)); // Drag selection and auto-scroll start even without canvas (rectangle visual just won't show) - Assert.IsTrue(tv._isDragSelecting); + Assert.IsTrue(tv.IsDragSelecting); tv.EndDragSelection(); } @@ -190,6 +190,6 @@ public void UpdateDragRectangleVisual_DoesNotCrash_WhenNotDragging() // Should be a no-op, not throw tv.UpdateDragRectangleVisual(new Point(50, 50)); - Assert.IsFalse(tv._isDragSelecting); + Assert.IsFalse(tv.IsDragSelecting); } } From 1ce7123b7b6d35d5ffeb1cc43b03a4444a2279c6 Mon Sep 17 00:00:00 2001 From: Godly Alias Date: Fri, 12 Jun 2026 22:40:42 +0600 Subject: [PATCH 10/10] Align ShowDragRectangle default tests with new true default Following the change of ShowDragRectangle's default value to true, update the default-value assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/DragSelectionRectangleTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs index 237ed0eb..a85a3ace 100644 --- a/tests/DragSelectionRectangleTests.cs +++ b/tests/DragSelectionRectangleTests.cs @@ -31,10 +31,10 @@ private static async Task CreateAndLoadTableView(ListViewSelectionMod } [UITestMethod] - public void ShowDragRectangle_DefaultsToFalse() + public void ShowDragRectangle_DefaultsToTrue() { var tv = new TableView(); - Assert.IsFalse(tv.ShowDragRectangle); + Assert.IsTrue(tv.ShowDragRectangle); } [UITestMethod] @@ -156,7 +156,7 @@ public void ShowDragRectangleProperty_IsRegistered() var tv = new TableView(); var value = tv.GetValue(TableView.ShowDragRectangleProperty); Assert.IsInstanceOfType(value, typeof(bool)); - Assert.IsFalse((bool)value); + Assert.IsTrue((bool)value); } [UITestMethod]