diff --git a/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/SelectionPage.xaml index fcafdbb..676b2e6 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 5f6edb6..8b6fc07 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -300,6 +300,11 @@ public bool CanPaste set => SetValue(CanPasteProperty, value); } + /// + /// 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. /// @@ -365,6 +370,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 a value that indicates whether the TableView should force select the Row or Cell depending on the SelectionUnit /// @@ -404,6 +418,16 @@ public bool ForceRowOrCellSelectionOnContextRequested /// 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. /// @@ -928,6 +952,23 @@ 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) + { + // 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; + } + } + /// /// Handles changes to the CanFilterColumns property. /// diff --git a/src/TableView.cs b/src/TableView.cs index 28c83ff..e9ee5e5 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; @@ -39,6 +40,15 @@ public partial class TableView : ListView private bool _isItemsSourceSuspended; private readonly List _rows = []; private readonly CollectionView _collectionView = []; + private Border? _dragRectangle; + private Point? _dragStartPoint; + private bool _cellSelectionDirty; + private Point? _lastDragCanvasPoint; + private DispatcherTimer? _autoScrollTimer; + private double _autoScrollVerticalDelta; + private double _autoScrollHorizontalDelta; + private double _dragStartVerticalOffset; + private double _dragStartHorizontalOffset; /// /// Initializes a new instance of the TableView class. @@ -342,6 +352,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; _scrollViewer?.Loaded += OnScrollViewerLoaded; if (IsLoaded) @@ -389,6 +401,9 @@ private void OnLoaded(object sender, RoutedEventArgs e) /// private void OnUnloaded(object sender, RoutedEventArgs e) { + EndDragSelection(); + StopAutoScroll(); + if (IsEditing && CurrentCellSlot.HasValue && GetCellFromSlot(CurrentCellSlot.Value) is { } currentCell) { currentCell.EndEditing(TableViewEditAction.Commit); @@ -1270,8 +1285,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 (IsDragSelecting) + { + var cell = GetCellFromSlot(newSlot.Value); + cell?.ApplyCurrentCellState(skipFocus: true); + } + else + { + var cell = await ScrollCellIntoView(newSlot.Value); + cell?.ApplyCurrentCellState(); + } } } @@ -1280,8 +1305,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)]; @@ -1294,7 +1324,10 @@ private void OnCellSelectionChanged() } InvokeCellSelectionChangedEvent(oldSelection); - }); + })) + { + _cellSelectionDirty = false; + } } /// @@ -1311,6 +1344,329 @@ private void InvokeCellSelectionChangedEvent(HashSet oldSelec } } + /// + /// Starts drag selection tracking, auto-scroll, and optionally the drag rectangle visual. + /// + /// The starting point relative to the drag rectangle canvas. + internal void StartDragSelection(Point startPoint) + { + if (SelectionMode is not (ListViewSelectionMode.Multiple or ListViewSelectionMode.Extended)) + { + return; + } + + // Guard against re-entry (e.g., multi-touch) to prevent double ViewChanged subscription + if (IsDragSelecting) + { + EndDragSelection(); + } + + IsDragSelecting = true; + _lastDragCanvasPoint = startPoint; + _dragStartVerticalOffset = _scrollViewer?.VerticalOffset ?? 0; + _dragStartHorizontalOffset = HorizontalOffset; + + 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 visual and auto-scroll during drag selection. + /// + /// The current pointer position relative to the drag rectangle canvas. + internal void UpdateDragRectangleVisual(Point currentPoint) + { + if (!IsDragSelecting) + { + return; + } + + _lastDragCanvasPoint = 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); + } + + /// + /// 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; + + 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, right - left); + _dragRectangle.Height = Math.Max(0, bottom - top); + } + + /// + /// Manages auto-scroll behavior when the pointer is near the top or bottom edge during drag selection. + /// + private void UpdateAutoScroll(Point canvasPoint) + { + if (_scrollViewer is null) return; + + const double edgeThreshold = 40; + const double maxScrollSpeed = 20; + + 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 - (viewportHeight - edgeThreshold)) / edgeThreshold); + vDelta = proximity * maxScrollSpeed; + } + else if (canvasPoint.Y < edgeThreshold) + { + var proximity = Math.Min(1.0, (edgeThreshold - canvasPoint.Y) / edgeThreshold); + vDelta = -(proximity * maxScrollSpeed); + } + + if (canvasPoint.X > viewportWidth - edgeThreshold) + { + 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); + hDelta = -(proximity * maxScrollSpeed); + } + + if (Math.Abs(vDelta) > 0.5 || Math.Abs(hDelta) > 0.5) + { + _autoScrollVerticalDelta = vDelta; + _autoScrollHorizontalDelta = hDelta; + if (_autoScrollTimer is null) + { + _autoScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _autoScrollTimer.Tick += OnAutoScrollTimerTick; + } + + _autoScrollTimer.Start(); + } + else + { + StopAutoScroll(); + } + } + + /// + /// Handles the auto-scroll timer tick to scroll the view and update drag selection. + /// + private void OnAutoScrollTimerTick(object? sender, object e) + { + if (!IsDragSelecting || _scrollViewer is null) + { + StopAutoScroll(); + return; + } + + 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; + } + } + + // 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; + } + + // 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(); + } + } + + /// + /// Stops the auto-scroll timer. + /// + private void StopAutoScroll() + { + if (_autoScrollTimer is not null) + { + _autoScrollTimer.Stop(); + _autoScrollTimer.Tick -= OnAutoScrollTimerTick; + _autoScrollTimer = null; + } + } + + /// + /// Handles ScrollViewer.ViewChanged during drag to re-evaluate selection when scroll position changes. + /// + private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerViewChangedEventArgs e) + { + 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) + { + 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) + { + // Element not in visual tree during container recycling + } + } + + /// + /// Ends drag selection tracking, auto-scroll, and hides the drag rectangle if visible. + /// + internal async void EndDragSelection() + { + if (!IsDragSelecting) return; + + StopAutoScroll(); + + if (_scrollViewer is not null) + { + _scrollViewer.ViewChanged -= OnScrollViewerViewChangedDuringDrag; + } + + if (_dragRectangle is not null) + { + _dragRectangle.Visibility = Visibility.Collapsed; + } + + IsDragSelecting = false; + _dragStartPoint = 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 (Exception) + { + // Focus restoration is best-effort after drag ends + } + } + /// /// Scrolls the specified cell slot into view. /// diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 21ac1ed..bed74cc 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -249,6 +249,14 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) 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); + } } } @@ -264,11 +272,20 @@ protected override void OnPointerReleased(PointerRoutedEventArgs e) 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) { @@ -276,6 +293,19 @@ protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) if (PointerCaptures?.Any() is true) { + // 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) @@ -336,6 +366,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 void OnDoubleTapped(DoubleTappedRoutedEventArgs e) { @@ -520,12 +568,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 31d47b4..0668511 100644 --- a/src/Themes/Resources.xaml +++ b/src/Themes/Resources.xaml @@ -74,6 +74,9 @@ + + + 0 @@ -143,6 +146,9 @@ + + + 0 @@ -212,6 +218,9 @@ + + + 0 diff --git a/src/Themes/TableView.xaml b/src/Themes/TableView.xaml index 8fcee42..a393ec3 100644 --- a/src/Themes/TableView.xaml +++ b/src/Themes/TableView.xaml @@ -126,6 +126,17 @@ Template="{StaticResource TableViewScrollViewerTemplate}"> + + + + diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs new file mode 100644 index 0000000..a85a3ac --- /dev/null +++ b/tests/DragSelectionRectangleTests.cs @@ -0,0 +1,195 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Linq; +using System.Threading.Tasks; +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; + } + + 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() + { + 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 async Task StartDragSelection_SetsIsDragging_WhenExtendedMode() + { + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); + Assert.IsFalse(tv.IsDragSelecting); + + tv.StartDragSelection(new Point(10, 10)); + + Assert.IsTrue(tv.IsDragSelecting); + + tv.EndDragSelection(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } + + [UITestMethod] + public void StartDragSelection_DoesNotStart_WhenSingleMode() + { + var tv = CreateTableView(ListViewSelectionMode.Single); + + tv.StartDragSelection(new Point(10, 10)); + + Assert.IsFalse(tv.IsDragSelecting); + } + + [UITestMethod] + public void StartDragSelection_DoesNotStart_WhenNoneMode() + { + var tv = CreateTableView(ListViewSelectionMode.None); + + tv.StartDragSelection(new Point(10, 10)); + + Assert.IsFalse(tv.IsDragSelecting); + } + + [UITestMethod] + public async Task StartDragSelection_StartsButNoRectangle_WhenShowDragRectangleIsFalse() + { + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); + tv.ShowDragRectangle = false; + + 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); + + tv.EndDragSelection(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } + + [UITestMethod] + public async Task StartDragSelection_Starts_InMultipleMode() + { + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Multiple); + + tv.StartDragSelection(new Point(10, 10)); + + Assert.IsTrue(tv.IsDragSelecting); + + tv.EndDragSelection(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } + + [UITestMethod] + public async Task EndDragSelection_ResetsIsDragging() + { + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); + tv.StartDragSelection(new Point(10, 10)); + Assert.IsTrue(tv.IsDragSelecting); + + tv.EndDragSelection(); + + Assert.IsFalse(tv.IsDragSelecting); + + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } + + [UITestMethod] + public void EndDragSelection_IsIdempotent() + { + var tv = CreateTableView(ListViewSelectionMode.Extended); + tv.StartDragSelection(new Point(10, 10)); + + tv.EndDragSelection(); + tv.EndDragSelection(); // second call should not throw + + Assert.IsFalse(tv.IsDragSelecting); + } + + [UITestMethod] + public async Task ShowDragRectangle_SetFalse_HidesRectangleButKeepsDragActive() + { + var tv = await CreateAndLoadTableView(ListViewSelectionMode.Extended); + tv.StartDragSelection(new Point(10, 10)); + Assert.IsTrue(tv.IsDragSelecting); + + tv.ShowDragRectangle = false; + + // Drag selection and auto-scroll remain active, but rectangle visual is hidden + Assert.IsTrue(tv.IsDragSelecting); + + tv.EndDragSelection(); + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } + + [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 StartDragSelection_StartsEvenWhenCanvasIsNull() + { + // Before OnApplyTemplate, DragRectangleCanvas is null + var tv = CreateTableView(ListViewSelectionMode.Extended); + 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); + + tv.EndDragSelection(); + } + + [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.IsDragSelecting); + } +}