/ src / modules / cmdpal / Microsoft.CmdPal.UI / ExtViews / ListPage.xaml.cs
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  }