ListPage.xaml.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System.Diagnostics; 6 using CommunityToolkit.Mvvm.Messaging; 7 using ManagedCommon; 8 using Microsoft.CmdPal.Core.ViewModels; 9 using Microsoft.CmdPal.Core.ViewModels.Commands; 10 using Microsoft.CmdPal.Core.ViewModels.Messages; 11 using Microsoft.CmdPal.UI.Helpers; 12 using Microsoft.CmdPal.UI.Messages; 13 using Microsoft.CmdPal.UI.ViewModels; 14 using Microsoft.Extensions.DependencyInjection; 15 using Microsoft.UI.Xaml; 16 using Microsoft.UI.Xaml.Controls; 17 using Microsoft.UI.Xaml.Controls.Primitives; 18 using Microsoft.UI.Xaml.Input; 19 using Microsoft.UI.Xaml.Media; 20 using Microsoft.UI.Xaml.Navigation; 21 using Windows.ApplicationModel.DataTransfer; 22 using Windows.Foundation; 23 using Windows.System; 24 25 namespace Microsoft.CmdPal.UI; 26 27 public sealed partial class ListPage : Page, 28 IRecipient<NavigateNextCommand>, 29 IRecipient<NavigatePreviousCommand>, 30 IRecipient<NavigateLeftCommand>, 31 IRecipient<NavigateRightCommand>, 32 IRecipient<NavigatePageDownCommand>, 33 IRecipient<NavigatePageUpCommand>, 34 IRecipient<ActivateSelectedListItemMessage>, 35 IRecipient<ActivateSecondaryCommandMessage> 36 { 37 private InputSource _lastInputSource; 38 39 internal ListViewModel? ViewModel 40 { 41 get => (ListViewModel?)GetValue(ViewModelProperty); 42 set => SetValue(ViewModelProperty, value); 43 } 44 45 // Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc... 46 public static readonly DependencyProperty ViewModelProperty = 47 DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged)); 48 49 private ListViewBase ItemView 50 { 51 get 52 { 53 return ViewModel?.IsGridView == true ? ItemsGrid : ItemsList; 54 } 55 } 56 57 public ListPage() 58 { 59 this.InitializeComponent(); 60 this.NavigationCacheMode = NavigationCacheMode.Disabled; 61 this.ItemView.Loaded += Items_Loaded; 62 this.ItemView.PreviewKeyDown += Items_PreviewKeyDown; 63 this.ItemView.PointerPressed += Items_PointerPressed; 64 } 65 66 protected override void OnNavigatedTo(NavigationEventArgs e) 67 { 68 if (e.Parameter is not AsyncNavigationRequest navigationRequest) 69 { 70 throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}"); 71 } 72 73 if (navigationRequest.TargetViewModel is not ListViewModel listViewModel) 74 { 75 throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ListViewModel)}"); 76 } 77 78 ViewModel = listViewModel; 79 80 if (e.NavigationMode == NavigationMode.Back) 81 { 82 // Must dispatch the selection to run at a lower priority; otherwise, GetFirstSelectableIndex 83 // may return an incorrect index because item containers are not yet rendered. 84 _ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => 85 { 86 var firstUsefulIndex = GetFirstSelectableIndex(); 87 if (firstUsefulIndex != -1) 88 { 89 ItemView.SelectedIndex = firstUsefulIndex; 90 } 91 }); 92 } 93 94 // RegisterAll isn't AOT compatible 95 WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this); 96 WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this); 97 WeakReferenceMessenger.Default.Register<NavigateLeftCommand>(this); 98 WeakReferenceMessenger.Default.Register<NavigateRightCommand>(this); 99 WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this); 100 WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this); 101 WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this); 102 WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this); 103 104 base.OnNavigatedTo(e); 105 } 106 107 protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) 108 { 109 base.OnNavigatingFrom(e); 110 111 WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this); 112 WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this); 113 WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this); 114 WeakReferenceMessenger.Default.Unregister<NavigateRightCommand>(this); 115 WeakReferenceMessenger.Default.Unregister<NavigatePageDownCommand>(this); 116 WeakReferenceMessenger.Default.Unregister<NavigatePageUpCommand>(this); 117 WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this); 118 WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this); 119 120 if (ViewModel is not null) 121 { 122 ViewModel.PropertyChanged -= ViewModel_PropertyChanged; 123 ViewModel.ItemsUpdated -= Page_ItemsUpdated; 124 } 125 126 if (e.NavigationMode != NavigationMode.New) 127 { 128 ViewModel?.SafeCleanup(); 129 CleanupHelper.Cleanup(this); 130 } 131 132 // Clean-up event listeners 133 ViewModel = null; 134 135 GC.Collect(); 136 } 137 138 /// <summary> 139 /// Finds the index of the first item in the list that is not a separator. 140 /// Returns -1 if the list is empty or only contains separators. 141 /// </summary> 142 private int GetFirstSelectableIndex() 143 { 144 var items = ItemView.Items; 145 if (items is null || items.Count == 0) 146 { 147 return -1; 148 } 149 150 for (var i = 0; i < items.Count; i++) 151 { 152 if (!IsSeparator(items[i])) 153 { 154 return i; 155 } 156 } 157 158 return -1; 159 } 160 161 [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")] 162 private void Items_ItemClick(object sender, ItemClickEventArgs e) 163 { 164 if (e.ClickedItem is ListItemViewModel item) 165 { 166 if (_lastInputSource == InputSource.Keyboard) 167 { 168 ViewModel?.InvokeItemCommand.Execute(item); 169 return; 170 } 171 172 var settings = App.Current.Services.GetService<SettingsModel>()!; 173 if (settings.SingleClickActivates) 174 { 175 ViewModel?.InvokeItemCommand.Execute(item); 176 } 177 else 178 { 179 ViewModel?.UpdateSelectedItemCommand.Execute(item); 180 WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>(); 181 } 182 } 183 } 184 185 private void Items_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) 186 { 187 if (ItemView.SelectedItem is ListItemViewModel vm) 188 { 189 var settings = App.Current.Services.GetService<SettingsModel>()!; 190 if (!settings.SingleClickActivates) 191 { 192 ViewModel?.InvokeItemCommand.Execute(vm); 193 } 194 } 195 } 196 197 [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")] 198 private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e) 199 { 200 var vm = ViewModel; 201 var li = ItemView.SelectedItem as ListItemViewModel; 202 _ = Task.Run(() => 203 { 204 vm?.UpdateSelectedItemCommand.Execute(li); 205 }); 206 207 // There's mysterious behavior here, where the selection seemingly 208 // changes to _nothing_ when we're backspacing to a single character. 209 // And at that point, seemingly the item that's getting removed is not 210 // a member of FilteredItems. Very bizarre. 211 // 212 // Might be able to fix in the future by stashing the removed item 213 // here, then in Page_ItemsUpdated trying to select that cached item if 214 // it's in the list (otherwise, clear the cache), but that seems 215 // aggressively BODGY for something that mostly just works today. 216 if (ItemView.SelectedItem is not null && !IsSeparator(ItemView.SelectedItem)) 217 { 218 var items = ItemView.Items; 219 var firstUsefulIndex = GetFirstSelectableIndex(); 220 var shouldScroll = false; 221 222 if (e.RemovedItems.Count > 0) 223 { 224 shouldScroll = true; 225 } 226 else if (ItemView.SelectedIndex > firstUsefulIndex) 227 { 228 shouldScroll = true; 229 } 230 231 if (shouldScroll) 232 { 233 ItemView.ScrollIntoView(ItemView.SelectedItem); 234 } 235 236 // Automation notification for screen readers 237 var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView); 238 if (listViewPeer is not null && li is not null) 239 { 240 UIHelper.AnnounceActionForAccessibility( 241 ItemsList, 242 li.Title, 243 "CommandPaletteSelectedItemChanged"); 244 } 245 } 246 } 247 248 private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e) 249 { 250 if (e.OriginalSource is FrameworkElement element && 251 element.DataContext is ListItemViewModel item) 252 { 253 if (ItemView.SelectedItem != item) 254 { 255 ItemView.SelectedItem = item; 256 } 257 258 ViewModel?.UpdateSelectedItemCommand.Execute(item); 259 260 var pos = e.GetPosition(element); 261 262 _ = DispatcherQueue.TryEnqueue( 263 () => 264 { 265 WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>( 266 new OpenContextMenuMessage( 267 element, 268 Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, 269 pos, 270 ContextMenuFilterLocation.Top)); 271 }); 272 } 273 } 274 275 private void Items_Loaded(object sender, RoutedEventArgs e) 276 { 277 // Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid) 278 var listViewScrollViewer = FindScrollViewer(this.ItemView); 279 280 if (listViewScrollViewer is not null) 281 { 282 listViewScrollViewer.ViewChanged += ListViewScrollViewer_ViewChanged; 283 } 284 } 285 286 private void ListViewScrollViewer_ViewChanged(object? sender, ScrollViewerViewChangedEventArgs e) 287 { 288 var scrollView = sender as ScrollViewer; 289 if (scrollView is null) 290 { 291 return; 292 } 293 294 // When we get to the bottom, request more from the extension, if they 295 // have more to give us. 296 // We're checking when we get to 80% of the scroll height, to give the 297 // extension a bit of a heads-up before the user actually gets there. 298 if (scrollView.VerticalOffset >= (scrollView.ScrollableHeight * .8)) 299 { 300 ViewModel?.LoadMoreIfNeeded(); 301 } 302 } 303 304 public void Receive(NavigateNextCommand message) 305 { 306 // Note: We may want to just have the notion of a 'SelectedCommand' in our VM 307 // And then have these commands manipulate that state being bound to the UI instead 308 // We may want to see how other non-list UIs need to behave to make this decision 309 // At least it's decoupled from the SearchBox now :) 310 if (ViewModel?.IsGridView == true) 311 { 312 // For grid views, use spatial navigation (down) 313 HandleGridArrowNavigation(VirtualKey.Down); 314 } 315 else 316 { 317 // For list views, use simple linear navigation 318 NavigateDown(); 319 } 320 } 321 322 public void Receive(NavigatePreviousCommand message) 323 { 324 if (ViewModel?.IsGridView == true) 325 { 326 // For grid views, use spatial navigation (up) 327 HandleGridArrowNavigation(VirtualKey.Up); 328 } 329 else 330 { 331 NavigateUp(); 332 } 333 } 334 335 public void Receive(NavigateLeftCommand message) 336 { 337 // For grid views, use spatial navigation. For list views, just move up. 338 if (ViewModel?.IsGridView == true) 339 { 340 HandleGridArrowNavigation(VirtualKey.Left); 341 } 342 else 343 { 344 // In list view, left arrow doesn't navigate 345 // This maintains consistency with the SearchBar behavior 346 } 347 } 348 349 public void Receive(NavigateRightCommand message) 350 { 351 // For grid views, use spatial navigation. For list views, just move down. 352 if (ViewModel?.IsGridView == true) 353 { 354 HandleGridArrowNavigation(VirtualKey.Right); 355 } 356 else 357 { 358 // In list view, right arrow doesn't navigate 359 // This maintains consistency with the SearchBar behavior 360 } 361 } 362 363 public void Receive(ActivateSelectedListItemMessage message) 364 { 365 if (ViewModel?.ShowEmptyContent ?? false) 366 { 367 ViewModel?.InvokeItemCommand.Execute(null); 368 } 369 else if (ItemView.SelectedItem is ListItemViewModel item) 370 { 371 ViewModel?.InvokeItemCommand.Execute(item); 372 } 373 } 374 375 public void Receive(ActivateSecondaryCommandMessage message) 376 { 377 if (ViewModel?.ShowEmptyContent ?? false) 378 { 379 ViewModel?.InvokeSecondaryCommandCommand.Execute(null); 380 } 381 else if (ItemView.SelectedItem is ListItemViewModel item) 382 { 383 ViewModel?.InvokeSecondaryCommandCommand.Execute(item); 384 } 385 } 386 387 public void Receive(NavigatePageDownCommand message) 388 { 389 var indexes = CalculateTargetIndexPageUpDownScrollTo(true); 390 if (indexes is null) 391 { 392 return; 393 } 394 395 if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) 396 { 397 ItemView.SelectedIndex = indexes.Value.TargetIndex; 398 if (ItemView.SelectedItem is not null) 399 { 400 ItemView.ScrollIntoView(ItemView.SelectedItem); 401 } 402 } 403 } 404 405 public void Receive(NavigatePageUpCommand message) 406 { 407 var indexes = CalculateTargetIndexPageUpDownScrollTo(false); 408 if (indexes is null) 409 { 410 return; 411 } 412 413 if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) 414 { 415 ItemView.SelectedIndex = indexes.Value.TargetIndex; 416 if (ItemView.SelectedItem is not null) 417 { 418 ItemView.ScrollIntoView(ItemView.SelectedItem); 419 } 420 } 421 } 422 423 /// <summary> 424 /// Calculates the item index to target when performing a page up or page down 425 /// navigation. The calculation attempts to estimate how many items fit into 426 /// the visible viewport by measuring actual container heights currently visible 427 /// within the internal ScrollViewer. If measurements are not available a 428 /// fallback estimate is used. 429 /// </summary> 430 /// <param name="isPageDown">True to calculate a page-down target, false for page-up.</param> 431 /// <returns> 432 /// A tuple containing the current index and the calculated target index, or null 433 /// if a valid calculation could not be performed (for example, missing ScrollViewer). 434 /// </returns> 435 private (int CurrentIndex, int TargetIndex)? CalculateTargetIndexPageUpDownScrollTo(bool isPageDown) 436 { 437 var scroll = FindScrollViewer(ItemView); 438 if (scroll is null) 439 { 440 return null; 441 } 442 443 var viewportHeight = scroll.ViewportHeight; 444 if (viewportHeight <= 0) 445 { 446 return null; 447 } 448 449 var currentIndex = ItemView.SelectedIndex < 0 ? 0 : ItemView.SelectedIndex; 450 var itemCount = ItemView.Items.Count; 451 452 // Compute visible item heights within the ScrollViewer viewport 453 const int firstVisibleIndexNotFound = -1; 454 var firstVisibleIndex = firstVisibleIndexNotFound; 455 var visibleHeights = new List<double>(itemCount); 456 457 for (var i = 0; i < itemCount; i++) 458 { 459 if (ItemView.ContainerFromIndex(i) is FrameworkElement container) 460 { 461 try 462 { 463 var transform = container.TransformToVisual(scroll); 464 var topLeft = transform.TransformPoint(new Point(0, 0)); 465 var bottom = topLeft.Y + container.ActualHeight; 466 467 // If any part of the container is inside the viewport, consider it visible 468 if (topLeft.Y >= 0 && bottom <= viewportHeight) 469 { 470 if (firstVisibleIndex == firstVisibleIndexNotFound) 471 { 472 firstVisibleIndex = i; 473 } 474 475 visibleHeights.Add(container.ActualHeight > 0 ? container.ActualHeight : 0); 476 } 477 } 478 catch 479 { 480 // ignore transform errors and continue 481 } 482 } 483 } 484 485 var itemsPerPage = 0; 486 487 // Calculate how many items fit in the viewport based on their actual heights 488 if (visibleHeights.Count > 0) 489 { 490 double accumulated = 0; 491 for (var i = 0; i < visibleHeights.Count; i++) 492 { 493 accumulated += visibleHeights[i] <= 0 ? 1 : visibleHeights[i]; 494 itemsPerPage++; 495 if (accumulated >= viewportHeight) 496 { 497 break; 498 } 499 } 500 } 501 else 502 { 503 // fallback: estimate using first measured container height 504 double itemHeight = 0; 505 for (var i = currentIndex; i < itemCount; i++) 506 { 507 if (ItemView.ContainerFromIndex(i) is FrameworkElement { ActualHeight: > 0 } c) 508 { 509 itemHeight = c.ActualHeight; 510 break; 511 } 512 } 513 514 if (itemHeight <= 0) 515 { 516 itemHeight = 1; 517 } 518 519 itemsPerPage = Math.Max(1, (int)Math.Floor(viewportHeight / itemHeight)); 520 } 521 522 var targetIndex = isPageDown 523 ? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage)) 524 : Math.Max(0, currentIndex - Math.Max(1, itemsPerPage)); 525 526 return (currentIndex, targetIndex); 527 } 528 529 private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 530 { 531 if (d is ListPage @this) 532 { 533 if (e.OldValue is ListViewModel old) 534 { 535 old.PropertyChanged -= @this.ViewModel_PropertyChanged; 536 old.ItemsUpdated -= @this.Page_ItemsUpdated; 537 } 538 539 if (e.NewValue is ListViewModel page) 540 { 541 page.PropertyChanged += @this.ViewModel_PropertyChanged; 542 page.ItemsUpdated += @this.Page_ItemsUpdated; 543 } 544 else if (e.NewValue is null) 545 { 546 Logger.LogDebug("cleared view model"); 547 } 548 } 549 } 550 551 // Called after we've finished updating the whole list for either a 552 // GetItems or a change in the filter. 553 private void Page_ItemsUpdated(ListViewModel sender, object args) 554 { 555 // If for some reason, we don't have a selected item, fix that. 556 // 557 // It's important to do this here, because once there's no selection 558 // (which can happen as the list updates) we won't get an 559 // ItemView_SelectionChanged again to give us another chance to change 560 // the selection from null -> something. Better to just update the 561 // selection once, at the end of all the updating. 562 // The selection logic must be deferred to the DispatcherQueue 563 // to ensure the UI has processed the updated ItemsSource binding, 564 // preventing ItemView.Items from appearing empty/null immediately after update. 565 _ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => 566 { 567 var items = ItemView.Items; 568 569 // If the list is null or empty, clears the selection and return 570 if (items is null || items.Count == 0) 571 { 572 ItemView.SelectedIndex = -1; 573 return; 574 } 575 576 // Finds the first item that is not a separator 577 var firstUsefulIndex = GetFirstSelectableIndex(); 578 579 // If there is only separators in the list, don't select anything. 580 if (firstUsefulIndex == -1) 581 { 582 ItemView.SelectedIndex = -1; 583 584 return; 585 } 586 587 var shouldUpdateSelection = false; 588 589 // If it's a top level list update we force the reset to the top useful item 590 if (!sender.IsNested) 591 { 592 shouldUpdateSelection = true; 593 } 594 595 // No current selection or current selection is null 596 else if (ItemView.SelectedItem is null) 597 { 598 shouldUpdateSelection = true; 599 } 600 601 // The current selected item is a separator 602 else if (IsSeparator(ItemView.SelectedItem)) 603 { 604 shouldUpdateSelection = true; 605 } 606 607 // The selected item does not exist in the new list 608 else if (!items.Contains(ItemView.SelectedItem)) 609 { 610 shouldUpdateSelection = true; 611 } 612 613 if (shouldUpdateSelection) 614 { 615 if (firstUsefulIndex != -1) 616 { 617 ItemView.SelectedIndex = firstUsefulIndex; 618 } 619 } 620 }); 621 } 622 623 private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) 624 { 625 var prop = e.PropertyName; 626 if (prop == nameof(ViewModel.FilteredItems)) 627 { 628 Debug.WriteLine($"ViewModel.FilteredItems {ItemView.SelectedItem}"); 629 } 630 } 631 632 private static ScrollViewer? FindScrollViewer(DependencyObject parent) 633 { 634 if (parent is ScrollViewer viewer) 635 { 636 return viewer; 637 } 638 639 for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) 640 { 641 var child = VisualTreeHelper.GetChild(parent, i); 642 var result = FindScrollViewer(child); 643 if (result is not null) 644 { 645 return result; 646 } 647 } 648 649 return null; 650 } 651 652 // Find a logical neighbor in the requested direction using containers' positions. 653 private void HandleGridArrowNavigation(VirtualKey key) 654 { 655 if (ItemView.Items.Count == 0) 656 { 657 // No items, goodbye. 658 return; 659 } 660 661 var currentIndex = ItemView.SelectedIndex; 662 if (currentIndex < 0) 663 { 664 // -1 is a valid value (no item currently selected) 665 currentIndex = 0; 666 ItemView.SelectedIndex = 0; 667 } 668 669 try 670 { 671 // Try to compute using container positions; if not available, fall back to simple +/-1. 672 var currentContainer = ItemView.ContainerFromIndex(currentIndex) as FrameworkElement; 673 if (currentContainer is not null && currentContainer.ActualWidth != 0 && currentContainer.ActualHeight != 0) 674 { 675 // Use center of current container as reference 676 var curPoint = currentContainer.TransformToVisual(ItemView).TransformPoint(new Point(0, 0)); 677 var curCenterX = curPoint.X + (currentContainer.ActualWidth / 2.0); 678 var curCenterY = curPoint.Y + (currentContainer.ActualHeight / 2.0); 679 680 var bestScore = double.MaxValue; 681 var bestIndex = currentIndex; 682 683 for (var i = 0; i < ItemView.Items.Count; i++) 684 { 685 if (i == currentIndex) 686 { 687 continue; 688 } 689 690 if (IsSeparator(ItemView.Items[i])) 691 { 692 continue; 693 } 694 695 if (ItemView.ContainerFromIndex(i) is FrameworkElement c && c.ActualWidth > 0 && c.ActualHeight > 0) 696 { 697 var p = c.TransformToVisual(ItemView).TransformPoint(new Point(0, 0)); 698 var centerX = p.X + (c.ActualWidth / 2.0); 699 var centerY = p.Y + (c.ActualHeight / 2.0); 700 701 var dx = centerX - curCenterX; 702 var dy = centerY - curCenterY; 703 704 var candidate = false; 705 var score = double.MaxValue; 706 707 switch (key) 708 { 709 case VirtualKey.Left: 710 if (dx < 0) 711 { 712 candidate = true; 713 score = Math.Abs(dy) + (Math.Abs(dx) * 0.7); 714 } 715 716 break; 717 case VirtualKey.Right: 718 if (dx > 0) 719 { 720 candidate = true; 721 score = Math.Abs(dy) + (Math.Abs(dx) * 0.7); 722 } 723 724 break; 725 case VirtualKey.Up: 726 if (dy < 0) 727 { 728 candidate = true; 729 score = Math.Abs(dx) + (Math.Abs(dy) * 0.7); 730 } 731 732 break; 733 case VirtualKey.Down: 734 if (dy > 0) 735 { 736 candidate = true; 737 score = Math.Abs(dx) + (Math.Abs(dy) * 0.7); 738 } 739 740 break; 741 } 742 743 if (candidate && score < bestScore) 744 { 745 bestScore = score; 746 bestIndex = i; 747 } 748 } 749 } 750 751 if (bestIndex != currentIndex) 752 { 753 ItemView.SelectedIndex = bestIndex; 754 ItemView.ScrollIntoView(ItemView.SelectedItem); 755 } 756 757 return; 758 } 759 } 760 catch 761 { 762 // ignore transform errors and fall back 763 } 764 765 // fallback linear behavior 766 var fallback = key switch 767 { 768 VirtualKey.Left => Math.Max(0, currentIndex - 1), 769 VirtualKey.Right => Math.Min(ItemView.Items.Count - 1, currentIndex + 1), 770 VirtualKey.Up => Math.Max(0, currentIndex - 1), 771 VirtualKey.Down => Math.Min(ItemView.Items.Count - 1, currentIndex + 1), 772 _ => currentIndex, 773 }; 774 if (fallback != currentIndex) 775 { 776 ItemView.SelectedIndex = fallback; 777 ItemView.ScrollIntoView(ItemView.SelectedItem); 778 } 779 } 780 781 private void Items_OnContextRequested(UIElement sender, ContextRequestedEventArgs e) 782 { 783 var (item, element) = e.OriginalSource switch 784 { 785 // caused by keyboard shortcut (e.g. Context menu key or Shift+F10) 786 SelectorItem selectorItem => (ItemView.ItemFromContainer(selectorItem) as ListItemViewModel, selectorItem), 787 788 // caused by right-click on the ListViewItem 789 FrameworkElement { DataContext: ListItemViewModel itemViewModel } frameworkElement => (itemViewModel, frameworkElement), 790 791 _ => (null, null), 792 }; 793 794 if (item is null || element is null) 795 { 796 return; 797 } 798 799 if (ItemView.SelectedItem != item) 800 { 801 ItemView.SelectedItem = item; 802 } 803 804 if (!e.TryGetPosition(element, out var pos)) 805 { 806 pos = new(0, element.ActualHeight); 807 } 808 809 _ = DispatcherQueue.TryEnqueue( 810 () => 811 { 812 WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>( 813 new OpenContextMenuMessage( 814 element, 815 Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, 816 pos, 817 ContextMenuFilterLocation.Top)); 818 }); 819 e.Handled = true; 820 } 821 822 private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e) 823 { 824 _ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>()); 825 } 826 827 private void Items_PointerPressed(object sender, PointerRoutedEventArgs e) => _lastInputSource = InputSource.Pointer; 828 829 private void Items_PreviewKeyDown(object sender, KeyRoutedEventArgs e) 830 { 831 // Track keyboard as the last input source for activation logic. 832 if (e.Key is VirtualKey.Enter or VirtualKey.Space) 833 { 834 _lastInputSource = InputSource.Keyboard; 835 return; 836 } 837 838 // Handle arrow navigation when we're showing a grid. 839 if (ViewModel?.IsGridView == true) 840 { 841 switch (e.Key) 842 { 843 case VirtualKey.Left: 844 case VirtualKey.Right: 845 case VirtualKey.Up: 846 case VirtualKey.Down: 847 _lastInputSource = InputSource.Keyboard; 848 HandleGridArrowNavigation(e.Key); 849 e.Handled = true; 850 break; 851 } 852 } 853 } 854 855 /// <summary> 856 /// Code stealed from <see cref="Controls.ContextMenu.NavigateUp"/> 857 /// </summary> 858 private void NavigateUp() 859 { 860 var newIndex = ItemView.SelectedIndex; 861 862 if (ItemView.SelectedIndex > 0) 863 { 864 newIndex--; 865 866 while ( 867 newIndex >= 0 && 868 IsSeparator(ItemView.Items[newIndex]) && 869 newIndex != ItemView.SelectedIndex) 870 { 871 newIndex--; 872 } 873 874 if (newIndex < 0) 875 { 876 newIndex = ItemView.Items.Count - 1; 877 878 while ( 879 newIndex >= 0 && 880 IsSeparator(ItemView.Items[newIndex]) && 881 newIndex != ItemView.SelectedIndex) 882 { 883 newIndex--; 884 } 885 } 886 } 887 else 888 { 889 newIndex = ItemView.Items.Count - 1; 890 } 891 892 ItemView.SelectedIndex = newIndex; 893 } 894 895 private void Items_DragItemsStarting(object sender, DragItemsStartingEventArgs e) 896 { 897 try 898 { 899 if (e.Items.FirstOrDefault() is not ListItemViewModel item || item.DataPackage is null) 900 { 901 e.Cancel = true; 902 return; 903 } 904 905 // copy properties 906 foreach (var (key, value) in item.DataPackage.Properties) 907 { 908 try 909 { 910 e.Data.Properties[key] = value; 911 } 912 catch (Exception) 913 { 914 // noop - skip any properties that fail 915 } 916 } 917 918 // setup e.Data formats as deferred renderers to read from the item's DataPackage 919 foreach (var format in item.DataPackage.AvailableFormats) 920 { 921 try 922 { 923 e.Data.SetDataProvider(format, request => DelayRenderer(request, item, format)); 924 } 925 catch (Exception) 926 { 927 // noop - skip any formats that fail 928 } 929 } 930 931 WeakReferenceMessenger.Default.Send(new DragStartedMessage()); 932 } 933 catch (Exception ex) 934 { 935 WeakReferenceMessenger.Default.Send(new DragCompletedMessage()); 936 Logger.LogError("Failed to start dragging an item", ex); 937 } 938 } 939 940 private static void DelayRenderer(DataProviderRequest request, ListItemViewModel item, string format) 941 { 942 var deferral = request.GetDeferral(); 943 try 944 { 945 item.DataPackage?.GetDataAsync(format) 946 .AsTask() 947 .ContinueWith(dataTask => 948 { 949 try 950 { 951 if (dataTask.IsCompletedSuccessfully) 952 { 953 request.SetData(dataTask.Result); 954 } 955 else if (dataTask.IsFaulted && dataTask.Exception is not null) 956 { 957 Logger.LogError($"Failed to get data for format '{format}' during drag-and-drop", dataTask.Exception); 958 } 959 } 960 finally 961 { 962 deferral.Complete(); 963 } 964 }); 965 } 966 catch (Exception ex) 967 { 968 Logger.LogError($"Failed to set data for format '{format}' during drag-and-drop", ex); 969 deferral.Complete(); 970 } 971 } 972 973 private void Items_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) 974 { 975 WeakReferenceMessenger.Default.Send(new DragCompletedMessage()); 976 } 977 978 /// <summary> 979 /// Code stealed from <see cref="Controls.ContextMenu.NavigateDown"/> 980 /// </summary> 981 private void NavigateDown() 982 { 983 var newIndex = ItemView.SelectedIndex; 984 985 if (ItemView.SelectedIndex == ItemView.Items.Count - 1) 986 { 987 newIndex = 0; 988 while ( 989 newIndex < ItemView.Items.Count && 990 IsSeparator(ItemView.Items[newIndex])) 991 { 992 newIndex++; 993 } 994 995 if (newIndex >= ItemView.Items.Count) 996 { 997 return; 998 } 999 } 1000 else 1001 { 1002 newIndex++; 1003 1004 while ( 1005 newIndex < ItemView.Items.Count && 1006 IsSeparator(ItemView.Items[newIndex]) && 1007 newIndex != ItemView.SelectedIndex) 1008 { 1009 newIndex++; 1010 } 1011 1012 if (newIndex >= ItemView.Items.Count) 1013 { 1014 newIndex = 0; 1015 1016 while ( 1017 newIndex < ItemView.Items.Count && 1018 IsSeparator(ItemView.Items[newIndex]) && 1019 newIndex != ItemView.SelectedIndex) 1020 { 1021 newIndex++; 1022 } 1023 } 1024 } 1025 1026 ItemView.SelectedIndex = newIndex; 1027 } 1028 1029 /// <summary> 1030 /// Code stealed from <see cref="Controls.ContextMenu.IsSeparator(object)"/> 1031 /// </summary> 1032 private bool IsSeparator(object? item) => item is ListItemViewModel li && li.IsSectionOrSeparator; 1033 1034 private enum InputSource 1035 { 1036 None, 1037 Keyboard, 1038 Pointer, 1039 } 1040 }