From 3bcb60b3c9d6f47b57d5a27fcb9086756b37df92 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 15 Oct 2026 10:25:17 -0400 Subject: [PATCH 1/2] Let users reorder the navigation pages by dragging them in the sidebar The Software Catalog added two pages to the sidebar and pushed the existing ones down, so users lost the Ctrl+number layout they were used to. The order is now theirs to choose; the default stays the current one. - Drag a page in the navigation panel to move it (mouse, pen or touch), or press Alt+Up/Down (Option on macOS) on a focused page. Escape cancels a drag. Ctrl/Cmd+number, Ctrl+Tab cycling, the accelerator metadata and the macOS View menu all follow the new order, and keyboard moves are announced to screen readers. - The order is stored in the NavigationPageOrder setting. A default order is stored as unset, so users who never reorder follow future default changes. The stored value always lists the catalog pages too, so an order chosen while the catalog is disabled keeps those pages in place when it is re-enabled. Unknown or duplicate names are ignored and missing pages are appended. - Settings > User interface gets a "Navigation menu order" card with Reset. - The nav ListBox now uses a plain StackPanel: with the default VirtualizingStackPanel, clearing and re-adding the ListBoxItems (which are their own containers) left them detached, so every page vanished after a reorder. - Drag positions are measured against the items panel, so scrolling the sidebar mid-drag still drops where the pointer is, and the sidebar only listens to its view model while attached. - SvgIcon's static theme brushes are now immutable. The mutable ones were bound to whichever UI thread first rendered an icon, which broke any headless test session that rendered icons after another one. Tests: SidebarNavigationOrderTests drives the real SidebarView headlessly (mouse drag both ways, touch drag including a scrollable sidebar, Escape, Alt+arrows with focus kept, the announcement, scrolling mid-drag, detach and reattach), and all of them fail if the StackPanel panel is removed. SoftwareCatalogTests covers resolving, merging and serializing the stored order. --- src/Languages/lang_en.json | 5 +- .../Infrastructure/NavigationPageOrder.cs | 48 +++ .../Infrastructure/SettingsSearchIndex.cs | 1 + .../ViewModels/MainWindowViewModel.cs | 16 +- .../ViewModels/SidebarViewModel.cs | 16 + .../Views/Controls/SvgIcon.cs | 5 +- .../Views/MainWindow.MacMenu.cs | 44 ++- .../Views/MainWindow.axaml.cs | 6 +- .../Pages/SettingsPages/Interface_P.axaml | 7 + .../Pages/SettingsPages/Interface_P.axaml.cs | 5 + src/UniGetUI.Avalonia/Views/SidebarView.axaml | 6 + .../Views/SidebarView.axaml.cs | 303 +++++++++++++++++- .../SettingsEngine_Names.cs | 2 + .../SidebarNavigationOrderTests.cs | 262 +++++++++++++++ src/UniGetUI.Tests/SoftwareCatalogTests.cs | 56 +++- 15 files changed, 740 insertions(+), 42 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs create mode 100644 src/UniGetUI.Tests/SidebarNavigationOrderTests.cs diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 98227dbc73..aa210483a0 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1974,5 +1974,8 @@ "Previous screenshot": "Previous screenshot", "Next screenshot": "Next screenshot", "Screenshot {0} of {1}": "Screenshot {0} of {1}", - "Screenshot {0} of {1}, current": "Screenshot {0} of {1}, current" + "Screenshot {0} of {1}, current": "Screenshot {0} of {1}, current", + "Navigation menu order": "Navigation menu order", + "Drag the pages in the navigation menu, or press {0} on a focused page, to reorder them. Keyboard shortcuts follow the same order.": "Drag the pages in the navigation menu, or press {0} on a focused page, to reorder them. Keyboard shortcuts follow the same order.", + "{0} moved to position {1} of {2}": "{0} moved to position {1} of {2}" } diff --git a/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs b/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs new file mode 100644 index 0000000000..acdb1901cf --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs @@ -0,0 +1,48 @@ +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.SettingsEngine; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class NavigationPageOrder +{ + public static PageType[] DefaultPages(bool catalogEnabled) => catalogEnabled + ? [PageType.Catalog, PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, PageType.CatalogEditor] + : [PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles]; + + public static PageType[] Get(bool catalogEnabled) + => Resolve(Settings.GetValue(Settings.K.NavigationPageOrder), catalogEnabled); + + public static void Save(IReadOnlyList visiblePages) + => Settings.SetValue( + Settings.K.NavigationPageOrder, + Serialize(Merge(Get(catalogEnabled: true), visiblePages))); + + public static void Reset() => Settings.SetValue(Settings.K.NavigationPageOrder, ""); + + internal static PageType[] Resolve(string stored, bool catalogEnabled) + { + PageType[] defaults = DefaultPages(catalogEnabled); + PageType[] ordered = stored + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(name => Enum.TryParse(name, out PageType page) ? page : PageType.Null) + .Where(defaults.Contains) + .Distinct() + .ToArray(); + return [.. ordered, .. defaults.Where(page => !ordered.Contains(page))]; + } + + internal static PageType[] Merge(PageType[] fullOrder, IReadOnlyList visiblePages) + { + PageType[] merged = [.. fullOrder]; + int next = 0; + for (int i = 0; i < merged.Length && next < visiblePages.Count; i++) + { + if (visiblePages.Contains(merged[i])) + merged[i] = visiblePages[next++]; + } + return merged; + } + + internal static string Serialize(PageType[] fullOrder) + => fullOrder.SequenceEqual(DefaultPages(catalogEnabled: true)) ? "" : string.Join(',', fullOrder); +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 7802a52b99..8c8a0b7aef 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -54,6 +54,7 @@ private sealed record Entry( new("Application theme:", ["appearance", "theme", "dark", "light", "mica"], typeof(Interface_P), "ThemeSelector"), new("UniGetUI startup page:", ["startup page", "default page", "home page", "landing page"], typeof(Interface_P), "StartupPageSelector"), new("Navigation menu:", ["sidebar", "nav menu", "navigation", "layout", "docked", "overlay"], typeof(Interface_P), "NavMenuModeSelector"), + new("Navigation menu order", ["sidebar", "nav menu", "navigation", "order", "reorder", "drag", "tabs", "pages", "shortcuts"], typeof(Interface_P), "NavigationOrderCard"), new("Automatically switch to software rendering when Windows has no hardware GPU", ["rendering", "gpu", "software rendering"], typeof(Interface_P), "InterfaceRenderingCard"), new("Close UniGetUI to the system tray", ["system tray", "background", "minimize to tray"], typeof(Interface_P), "DisableSystemTrayCard"), new("Manage UniGetUI autostart behaviour", ["autostart", "run at login", "startup"], typeof(Interface_P), "EditAutostartSettings"), diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index bd2f19cefa..6dc39332d5 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -696,22 +696,18 @@ private Control GetPageForType(PageType type) => /// The navigation pages in sidebar order: Ctrl/Cmd+1, +2, … follow it (the More menu takes the /// next number), and so does the previous/next page cycle. /// - internal static PageType[] GetShortcutPages(bool catalogEnabled) => catalogEnabled - ? [PageType.Catalog, PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, - PageType.CatalogEditor, PageType.Settings, PageType.Managers] - : [PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, - PageType.Settings, PageType.Managers]; + internal static PageType[] GetShortcutPages(IEnumerable navigationPages) + => [.. navigationPages, PageType.Settings, PageType.Managers]; - public PageType[] ShortcutPages => GetShortcutPages(Sidebar.SoftwareCatalogEnabled); + public PageType[] ShortcutPages => GetShortcutPages(Sidebar.NavigationPages); - internal static PageType GetNextPage(PageType type, bool catalogEnabled) => Step(type, catalogEnabled, +1); + internal static PageType GetNextPage(PageType type, PageType[] pages) => Step(type, pages, +1); - internal static PageType GetPreviousPage(PageType type, bool catalogEnabled) => Step(type, catalogEnabled, -1); + internal static PageType GetPreviousPage(PageType type, PageType[] pages) => Step(type, pages, -1); // Pages outside the sidebar order (logs, help, …) step to Discover. - private static PageType Step(PageType type, bool catalogEnabled, int direction) + private static PageType Step(PageType type, PageType[] pages, int direction) { - var pages = GetShortcutPages(catalogEnabled); int index = Array.IndexOf(pages, type); return index < 0 ? PageType.Discover : pages[(index + direction + pages.Length) % pages.Length]; } diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index bada85990b..2c7bad65ab 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -18,6 +18,21 @@ public partial class SidebarViewModel : ViewModelBase public bool HasUsableCatalog { get; } = Settings.Get(Settings.K.EnableSoftwareCatalog) && SoftwareCatalog.HasUsableCatalog(); + [ObservableProperty] + private PageType[] _navigationPages; + + public void ReorderNavigationPages(IReadOnlyList pages) + { + NavigationPageOrder.Save(pages); + NavigationPages = NavigationPageOrder.Get(SoftwareCatalogEnabled); + } + + public void ResetNavigationPages() + { + NavigationPageOrder.Reset(); + NavigationPages = NavigationPageOrder.Get(SoftwareCatalogEnabled); + } + // ─── Badge properties ───────────────────────────────────────────────────── [ObservableProperty] [NotifyPropertyChangedFor(nameof(UpdatesBadgeText))] @@ -89,6 +104,7 @@ public enum NavMode { Automatic, Docked, Overlay } public SidebarViewModel() { + _navigationPages = NavigationPageOrder.Get(SoftwareCatalogEnabled); _wasDocked = Docked; _isPaneOpen = Docked && !Settings.Get(Settings.K.CollapseNavMenuOnWideScreen); } diff --git a/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs b/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs index 6a6cefb339..5fb873a691 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs +++ b/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs @@ -8,6 +8,7 @@ using Avalonia.Controls; using Avalonia.Data; using Avalonia.Media; +using Avalonia.Media.Immutable; using Avalonia.Platform; using Avalonia.Styling; @@ -179,8 +180,8 @@ private static ParsedSvg ParseSvg(string uri) return new ParsedSvg(geometries, viewBoxWidth, viewBoxHeight); } - private static readonly IBrush _darkFg = new SolidColorBrush(Color.Parse("#E8E8E8")); - private static readonly IBrush _lightFg = new SolidColorBrush(Color.Parse("#1E1E1E")); + private static readonly IBrush _darkFg = new ImmutableSolidColorBrush(Color.Parse("#E8E8E8")); + private static readonly IBrush _lightFg = new ImmutableSolidColorBrush(Color.Parse("#1E1E1E")); private static IBrush LookupThemeForeground() => Application.Current?.ActualThemeVariant == ThemeVariant.Dark diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs b/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs index 60830aadf7..605edc8ba8 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs @@ -4,6 +4,7 @@ using Avalonia.Input; using Avalonia.VisualTree; using AvaloniaEdit; +using UniGetUI.Avalonia.ViewModels; using UniGetUI.Core.Tools; namespace UniGetUI.Avalonia.Views; @@ -19,6 +20,9 @@ public partial class MainWindow private TextBox? _menuTextBox; private TextEditor? _menuEditor; + private NativeMenu? _macViewMenu; + private NativeMenuItem[] _macPageItems = []; + private void SetupMacMenuBar() { if (!OperatingSystem.IsMacOS()) @@ -60,14 +64,9 @@ private void SetupMacMenuBar() new NativeMenuItemSeparator(), Action(CoreTools.Translate("Find"), TriggerPageSearch, Key.F, enabled: () => PageShortcutListener is not null)); - // Settings is reached from the application menu (⌘,), so the View menu skips it. - NativeMenuItemBase[] pageItems = ViewModel.ShortcutPages - .Select((page, index) => (page, key: Key.D1 + index)) - .Where(p => p.page != PageType.Settings) - .Select(p => (NativeMenuItemBase)Item(PageMenuTitle(p.page), navigate, p.page.ToString(), p.key)) - .ToArray(); + _macPageItems = CreateMacPageItems(); var view = Submenu(CoreTools.Translate("View"), [ - .. pageItems, + .. _macPageItems, new NativeMenuItemSeparator(), Item(CoreTools.Translate("Toggle navigation panel"), ViewModel.ToggleSidebarCommand, null, Key.S, KeyModifiers.Control), new NativeMenuItemSeparator(), @@ -90,10 +89,41 @@ private void SetupMacMenuBar() Item(CoreTools.Translate("Package Manager logs"), navigate, "ManagerLog"), Item(CoreTools.Translate("Operation history"), navigate, "OperationHistory")); + _macViewMenu = view.Menu; + sidebar.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(SidebarViewModel.NavigationPages)) + RefreshMacViewMenu(); + }; NativeMenu.SetMenu(this, new NativeMenu { Items = { edit, view, window, help } }); UpdateMacMenuState(); } + // Settings is reached from the application menu (⌘,), so the View menu skips it. + private NativeMenuItem[] CreateMacPageItems() + => ViewModel.ShortcutPages + .Select((page, index) => (page, key: Key.D1 + index)) + .Where(p => p.page != PageType.Settings) + .Select(p => Item(PageMenuTitle(p.page), ViewModel.Sidebar.RequestNavigationCommand, p.page.ToString(), p.key)) + .ToArray(); + + private void RefreshMacViewMenu() + { + if (_macViewMenu is null) + return; + + foreach (var item in _macPageItems) + { + _macViewMenu.Items.Remove(item); + _macMenuStates.RemoveAll(state => state.Item == item); + } + + _macPageItems = CreateMacPageItems(); + for (int i = 0; i < _macPageItems.Length; i++) + _macViewMenu.Items.Insert(i, _macPageItems[i]); + UpdateMacMenuState(); + } + // States are computed as a menu shows. For ⌘ shortcuts a stale disabled item lets the key reach the // focused control, and a click re-checks the live state, so the outcome is right either way. private void RefreshOnDemand(NativeMenu menu) diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 829617d956..a60f3ec433 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -311,8 +311,8 @@ private void Window_KeyDownTunnel(object? sender, KeyEventArgs e) _focusSidebarSelectionOnNextPageChange = true; ViewModel.NavigateTo(e.KeyModifiers.HasFlag(KeyModifiers.Shift) - ? MainWindowViewModel.GetPreviousPage(ViewModel.CurrentPage_t, SoftwareCatalogEnabled) - : MainWindowViewModel.GetNextPage(ViewModel.CurrentPage_t, SoftwareCatalogEnabled)); + ? MainWindowViewModel.GetPreviousPage(ViewModel.CurrentPage_t, ViewModel.ShortcutPages) + : MainWindowViewModel.GetNextPage(ViewModel.CurrentPage_t, ViewModel.ShortcutPages)); e.Handled = true; } @@ -565,6 +565,8 @@ private void SetupResponsiveRail() public void RefreshNavigationMode() => ViewModel.Sidebar.Mode = SidebarViewModel.ParseMode(Settings.GetValue(Settings.K.NavMenuMode)); + public void ResetNavigationOrder() => ViewModel.Sidebar.ResetNavigationPages(); + // Let the splitter hover surface replace the separator visually without changing its layout thickness. private void OperationsSplitter_PointerEntered(object? sender, PointerEventArgs e) => OperationsPanelBorder.Classes.Set("splitter-active", true); diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml index eb9939fb97..6242a1a7b4 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml @@ -41,6 +41,13 @@ + + + + MainWindow.Instance?.RefreshNavigationMode(); + + NavigationOrderCard.Description = CoreTools.Translate( + "Drag the pages in the navigation menu, or press {0} on a focused page, to reorder them. Keyboard shortcuts follow the same order.", + OperatingSystem.IsMacOS() ? "Option+Up/Down" : "Alt+Up/Down"); + NavigationOrderCard.Click += (_, _) => MainWindow.Instance?.ResetNavigationOrder(); } } diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml b/src/UniGetUI.Avalonia/Views/SidebarView.axaml index 8776a45ec1..6a2afb1b96 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml @@ -27,6 +27,12 @@ automation:AutomationProperties.Name="{t:Translate Navigation}" Padding="8,10,8,0"> + + + + + + private ListBoxItem? _pendingPillItem; private bool _pendingPillAnimate; + private ListBoxItem? _dragCandidate; + private Point _dragStart; + private bool _isDragging; + private ListBoxItem[] _dragItems = []; + private double[] _dragTops = []; + private int _dragFromIndex; + private int _dragTargetIndex; + private bool _dragHadFocus; + private TopLevel? _dragTopLevel; + private IPointer? _dragPointer; + private SidebarViewModel? _subscribedViewModel; + + private const double DragThreshold = 6d; private const double PillHeight = 16d; private static readonly TimeSpan PillAnimationDuration = TimeSpan.FromMilliseconds(300); @@ -64,6 +80,12 @@ public SidebarView() MoreNavBtn.AddHandler(KeyDownEvent, MoreNavBtn_KeyDown, RoutingStrategies.Tunnel); + NavListBox.AddHandler(PointerPressedEvent, NavListBox_PointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true); + NavListBox.AddHandler(PointerMovedEvent, NavListBox_PointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true); + NavListBox.AddHandler(PointerReleasedEvent, NavListBox_PointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true); + NavListBox.AddHandler(KeyDownEvent, NavListBox_KeyDown, RoutingStrategies.Tunnel); + NavListBox.PointerCaptureLost += (_, _) => EndDrag(commit: false); + if (FlyoutBase.GetAttachedFlyout(MoreNavBtn) is { } moreFlyout) { moreFlyout.Opened += (_, _) => _isMoreFlyoutOpen = true; @@ -78,26 +100,282 @@ public SidebarView() protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); + UpdateViewModelSubscription(TopLevel.GetTopLevel(this) is not null); if (DataContext is SidebarViewModel vm) { - if (!vm.SoftwareCatalogEnabled) + ApplyNavigationOrder(vm.NavigationPages); + // The startup page may already be set before this view subscribes, so apply it now. + SyncListBoxSelection(vm.SelectedPageType); + } + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + UpdateViewModelSubscription(attached: true); + if (_subscribedViewModel is { } vm) + { + ApplyNavigationOrder(vm.NavigationPages); + SyncListBoxSelection(vm.SelectedPageType); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + EndDrag(commit: false); + UpdateViewModelSubscription(attached: false); + } + + private void UpdateViewModelSubscription(bool attached) + { + SidebarViewModel? vm = attached ? DataContext as SidebarViewModel : null; + if (ReferenceEquals(vm, _subscribedViewModel)) + return; + + if (_subscribedViewModel is { } previous) + previous.PropertyChanged -= ViewModel_PropertyChanged; + _subscribedViewModel = vm; + vm?.PropertyChanged += ViewModel_PropertyChanged; + } + + private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if (sender is not SidebarViewModel vm) + return; + + if (args.PropertyName == nameof(SidebarViewModel.SelectedPageType)) + SyncListBoxSelection(vm.SelectedPageType); + else if (args.PropertyName == nameof(SidebarViewModel.NavigationPages)) + { + ApplyNavigationOrder(vm.NavigationPages); + SyncListBoxSelection(vm.SelectedPageType); + } + } + + private void ApplyNavigationOrder(PageType[] pages) + { + ResetDragVisuals(); + _lastNavItemSelectionWasAuto = true; + NavListBox.Items.Clear(); + foreach (var page in pages) + { + if (NavButtonFor(page) is { } button) + NavListBox.Items.Add(button); + } + _lastNavItemSelectionWasAuto = false; + AssignShortcutKeys(MainWindowViewModel.GetShortcutPages(pages)); + } + + private void NavListBox_PointerPressed(object? sender, PointerPressedEventArgs e) + { + _dragCandidate = null; + if (!e.GetCurrentPoint(NavListBox).Properties.IsLeftButtonPressed + || (e.Source as Visual)?.FindAncestorOfType(includeSelf: true) is not { } item + || !NavListBox.Items.Contains(item)) + return; + + _dragCandidate = item; + _dragStart = e.GetPosition(DragSurface); + } + + private Visual DragSurface => NavListBox.ItemsPanelRoot ?? (Visual)NavListBox; + + private void NavListBox_PointerMoved(object? sender, PointerEventArgs e) + { + if (_dragCandidate is null) + return; + + double offset = e.GetPosition(DragSurface).Y - _dragStart.Y; + if (!_isDragging) + { + if (Math.Abs(offset) < DragThreshold || !BeginDrag(_dragCandidate)) + return; + _dragPointer = e.Pointer; + e.Pointer.Capture(NavListBox); + } + + UpdateDrag(offset); + e.Handled = true; + } + + private void NavListBox_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isDragging) + { + _dragCandidate = null; + return; + } + + EndDrag(commit: true); + e.Pointer.Capture(null); + e.Handled = true; + } + + private bool BeginDrag(ListBoxItem item) + { + ListBoxItem[] items = NavListBox.Items.OfType().ToArray(); + int from = Array.IndexOf(items, item); + double[] tops = new double[items.Length]; + for (int i = 0; i < items.Length; i++) + { + if (items[i].TranslatePoint(default, DragSurface) is not { } position) { - NavListBox.Items.Remove(CatalogNavBtn); - NavListBox.Items.Remove(CatalogEditorNavBtn); + _dragCandidate = null; + return false; } - AssignShortcutKeys(MainWindowViewModel.GetShortcutPages(vm.SoftwareCatalogEnabled)); + tops[i] = position.Y; + } + if (from < 0 || items.Length < 2) + { + _dragCandidate = null; + return false; + } - vm.PropertyChanged += (_, args) => + _dragItems = items; + _dragTops = tops; + _dragFromIndex = from; + for (int i = 0; i < _dragItems.Length; i++) + { + var transform = new TranslateTransform(); + if (i != _dragFromIndex && !MotionPreference.ReducedMotion) { - if (args.PropertyName == nameof(SidebarViewModel.SelectedPageType)) - SyncListBoxSelection(vm.SelectedPageType); - }; - // The startup page may already be set before this view subscribes, so apply it now. - SyncListBoxSelection(vm.SelectedPageType); + transform.Transitions = + [ + new DoubleTransition + { + Property = TranslateTransform.YProperty, + Duration = TimeSpan.FromMilliseconds(150), + Easing = new CubicEaseOut(), + }, + ]; + } + _dragItems[i].RenderTransform = transform; + } + + _isDragging = true; + _dragTargetIndex = _dragFromIndex; + _dragHadFocus = item.IsKeyboardFocusWithin; + _dragTopLevel = TopLevel.GetTopLevel(this); + _dragTopLevel?.AddHandler(KeyDownEvent, DragTopLevel_KeyDown, RoutingStrategies.Tunnel); + item.ZIndex = 1; + item.Opacity = 0.85; + _pillAnimationCancellation?.Cancel(); + NavigationSelectionPill.IsVisible = false; + return true; + } + + private void UpdateDrag(double offset) + { + ListBoxItem dragged = _dragItems[_dragFromIndex]; + double fromTop = _dragTops[_dragFromIndex]; + offset = Math.Clamp(offset, _dragTops[0] - fromTop, _dragTops[^1] - fromTop); + ((TranslateTransform)dragged.RenderTransform!).Y = offset; + + double draggedCenter = fromTop + offset + (dragged.Bounds.Height / 2d); + int target = 0; + for (int i = 0; i < _dragItems.Length; i++) + { + double center = _dragTops[i] + (_dragItems[i].Bounds.Height / 2d); + if ((i < _dragFromIndex && center < draggedCenter) || (i > _dragFromIndex && center <= draggedCenter)) + target++; + } + _dragTargetIndex = target; + + double pitch = dragged.Bounds.Height + dragged.Margin.Top + dragged.Margin.Bottom; + for (int i = 0; i < _dragItems.Length; i++) + { + if (i == _dragFromIndex) + continue; + double shift = 0d; + if (_dragFromIndex < target && i > _dragFromIndex && i <= target) + shift = -pitch; + else if (target < _dragFromIndex && i >= target && i < _dragFromIndex) + shift = pitch; + ((TranslateTransform)_dragItems[i].RenderTransform!).Y = shift; + } + } + + private void EndDrag(bool commit) + { + _dragCandidate = null; + if (!_isDragging) + return; + _isDragging = false; + _dragTopLevel?.RemoveHandler(KeyDownEvent, DragTopLevel_KeyDown); + _dragTopLevel = null; + _dragPointer = null; + + int from = _dragFromIndex; + int target = _dragTargetIndex; + ListBoxItem dragged = _dragItems[from]; + + ResetDragVisuals(); + if (!commit || target == from || !MoveNavItem(dragged, target - from, _dragHadFocus ? NavigationMethod.Pointer : null)) + QueueSelectionPillUpdate(animate: false); + } + + private void DragTopLevel_KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key is not Key.Escape) + return; + + IPointer? pointer = _dragPointer; + EndDrag(commit: false); + pointer?.Capture(null); + e.Handled = true; + } + + private void NavListBox_KeyDown(object? sender, KeyEventArgs e) + { + if (e.KeyModifiers != KeyModifiers.Alt || e.Key is not (Key.Up or Key.Down) || _isDragging + || (e.Source as Visual)?.FindAncestorOfType(includeSelf: true) is not { } item + || !NavListBox.Items.Contains(item)) + return; + + MoveNavItem(item, e.Key is Key.Up ? -1 : 1, NavigationMethod.Directional); + e.Handled = true; + } + + private bool MoveNavItem(ListBoxItem item, int offset, NavigationMethod? refocus) + { + if (ViewModel is not { } vm) + return false; + + PageType[] pages = vm.NavigationPages; + int from = Array.FindIndex(pages, page => NavButtonFor(page) == item); + int target = Math.Clamp(from + offset, 0, pages.Length - 1); + if (from < 0 || target == from) + return false; + + List reordered = [.. pages]; + reordered.RemoveAt(from); + reordered.Insert(target, pages[from]); + vm.ReorderNavigationPages(reordered); + + if (refocus is { } method) + item.Focus(method); + AccessibilityAnnouncementService.Announce(CoreTools.Translate( + "{0} moved to position {1} of {2}", + AutomationProperties.GetName(item) ?? pages[from].ToString(), + target + 1, + pages.Length)); + return true; + } + + private void ResetDragVisuals() + { + foreach (var item in _dragItems) + { + item.RenderTransform = null; + item.ZIndex = 0; + item.Opacity = 1d; } + _dragItems = []; + _dragTops = []; } - // Announces each item's Ctrl/Cmd+number shortcut, which shifts when the catalog is enabled. + // Announces each item's Ctrl/Cmd+number shortcut, which follows the sidebar order. private void AssignShortcutKeys(PageType[] pages) { string modifier = OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl"; @@ -219,6 +497,9 @@ private void QueueSelectionPillUpdate(bool animate, ListBoxItem? targetItem = nu Dispatcher.UIThread.Post( () => { + if (_isDragging) + return; + if ((targetItem ?? NavListBox.SelectedItem ?? FooterNavListBox.SelectedItem) is ListBoxItem item) { MoveSelectionPill(item, animate); diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 749ab97c92..4ee2bdd5a0 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -127,6 +127,7 @@ public enum K MaintenanceSchedules, MaintenanceTaskLastRun, MaintenanceTaskLastFailure, + NavigationPageOrder, // NOTE: Set this to true to delegate package operations to Devolutions Agent broker // instead of using local UAC elevation. Change default here when ready for production. UseAgentBroker, @@ -268,6 +269,7 @@ public static string ResolveKey(K key) K.MaintenanceSchedules => "MaintenanceSchedules", K.MaintenanceTaskLastRun => "MaintenanceTaskLastRun", K.MaintenanceTaskLastFailure => "MaintenanceTaskLastFailure", + K.NavigationPageOrder => "NavigationPageOrder", K.UseAgentBroker => "UseAgentBroker", K.Test1 => "TestSetting1", diff --git a/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs b/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs new file mode 100644 index 0000000000..7e57deede5 --- /dev/null +++ b/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs @@ -0,0 +1,262 @@ +using System.Reflection; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Themes.Fluent; +using Avalonia.Threading; +using Avalonia.VisualTree; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Data; +using CoreSettings = UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Tests; + +public sealed class SidebarNavigationOrderTests : IDisposable +{ + private readonly HeadlessUnitTestSession _session = HeadlessUnitTestSession.StartNew(typeof(Application)); + private readonly string _testRoot = Path.Combine(Path.GetTempPath(), nameof(SidebarNavigationOrderTests), Guid.NewGuid().ToString("N")); + + public SidebarNavigationOrderTests() + { + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + CoreSettings.ResetSettings(); + } + + public void Dispose() + { + _session.Dispose(); + CoreSettings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + Directory.Delete(_testRoot, recursive: true); + } + + [Theory] + [InlineData("Updates", "Bundles", 999d, new[] { PageType.Discover, PageType.Installed, PageType.Bundles, PageType.Updates })] + [InlineData("Bundles", "Discover", -999d, new[] { PageType.Bundles, PageType.Discover, PageType.Updates, PageType.Installed })] + public Task DraggingAPageReordersTheSidebarAndKeepsEveryPageVisible( + string from, string to, double overshoot, PageType[] expected) => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + Point start = Center(window, Item(list, from)); + Point end = Center(window, Item(list, to)) + new Point(0, overshoot); + + window.MouseDown(start, MouseButton.Left); + for (int step = 1; step <= 10; step++) + window.MouseMove(start + ((end - start) * step / 10d)); + window.MouseUp(end, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(expected, vm.NavigationPages); + Assert.Equal(expected.Select(page => page.ToString()), Items(list).Select(item => (string?)item.Tag)); + Assert.True(Item(list, from).IsFocused); + AssertAllPresented(list); + Assert.NotEqual("", CoreSettings.GetValue(CoreSettings.K.NavigationPageOrder)); + }, CancellationToken.None); + + [Fact] + public Task EscapeCancelsADragWithoutReordering() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + PageType[] before = vm.NavigationPages; + Point start = Center(window, Item(list, "Discover")); + Point end = Center(window, Item(list, "Bundles")); + + window.MouseDown(start, MouseButton.Left); + window.MouseMove(start + new Point(0, 20)); + window.MouseMove(end); + window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, null); + window.MouseUp(end, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + Assert.Equal(before, vm.NavigationPages); + Assert.All(Items(list), item => Assert.Null(item.RenderTransform)); + AssertAllPresented(list); + Assert.Equal("", CoreSettings.GetValue(CoreSettings.K.NavigationPageOrder)); + }, CancellationToken.None); + + [Fact] + public Task AltArrowsMoveTheFocusedPageAndKeepItFocused() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + ListBoxItem updates = Item(list, "Updates"); + updates.Focus(NavigationMethod.Directional); + + window.KeyPress(Key.Down, RawInputModifiers.Alt, PhysicalKey.ArrowDown, null); + window.KeyPress(Key.Down, RawInputModifiers.Alt, PhysicalKey.ArrowDown, null); + window.KeyPress(Key.Down, RawInputModifiers.Alt, PhysicalKey.ArrowDown, null); + Dispatcher.UIThread.RunJobs(); + Assert.Equal([PageType.Discover, PageType.Installed, PageType.Bundles, PageType.Updates], vm.NavigationPages); + Assert.True(updates.IsFocused); + + window.KeyPress(Key.Up, RawInputModifiers.Alt, PhysicalKey.ArrowUp, null); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Assert.Equal([PageType.Discover, PageType.Installed, PageType.Updates, PageType.Bundles], vm.NavigationPages); + Assert.True(updates.IsFocused); + AssertAllPresented(list); + }, CancellationToken.None); + + [Theory] + [InlineData(700)] + [InlineData(330)] + public Task TouchDraggingReordersInsteadOfScrollingTheSidebar(int windowHeight) => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(windowHeight); + var scrollViewer = list.GetVisualDescendants().OfType().First(); + Point start = Center(window, Item(list, "Discover")); + Point end = Center(window, Item(list, "Bundles")); + var touch = new TouchInjector(window); + + touch.Send(RawPointerEventType.TouchBegin, start); + for (int step = 1; step <= 20; step++) + touch.Send(RawPointerEventType.TouchUpdate, start + ((end - start) * step / 20d)); + touch.Send(RawPointerEventType.TouchEnd, end); + window.UpdateLayout(); + + Assert.Equal([PageType.Updates, PageType.Installed, PageType.Bundles, PageType.Discover], vm.NavigationPages); + Assert.Equal(0d, scrollViewer.Offset.Y); + AssertAllPresented(list); + }, CancellationToken.None); + + [Fact] + public Task MovingAPageWithTheKeyboardAnnouncesItsNewPosition() => _session.Dispatch(() => + { + var (window, _, list) = ShowSidebar(); + var announcements = new List(); + void Collect(object? _, AccessibilityAnnouncement announcement) => announcements.Add(announcement.Message); + AccessibilityAnnouncementService.AnnouncementRequested += Collect; + try + { + Item(list, "Updates").Focus(NavigationMethod.Directional); + window.KeyPress(Key.Down, RawInputModifiers.Alt, PhysicalKey.ArrowDown, null); + Dispatcher.UIThread.RunJobs(); + } + finally + { + AccessibilityAnnouncementService.AnnouncementRequested -= Collect; + } + + string name = AutomationProperties.GetName(Item(list, "Updates"))!; + Assert.Equal([$"{name} moved to position 3 of 4"], announcements); + }, CancellationToken.None); + + [Fact] + public Task ScrollingTheSidebarDuringADragStillDropsWhereThePointerIs() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(330); + var scrollViewer = list.GetVisualDescendants().OfType().First(); + Assert.True(scrollViewer.Extent.Height > scrollViewer.Viewport.Height); + Point start = Center(window, Item(list, "Discover")); + + window.MouseDown(start, MouseButton.Left); + window.MouseMove(start + new Point(0, 20)); + scrollViewer.Offset = new Vector(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Point end = Center(window, Item(list, "Bundles")) + new Point(0, 10); + window.MouseMove(end); + window.MouseUp(end, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal([PageType.Updates, PageType.Installed, PageType.Bundles, PageType.Discover], vm.NavigationPages); + }, CancellationToken.None); + + [Fact] + public Task ADetachedSidebarStopsFollowingTheOrderUntilItIsShownAgain() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + var view = (SidebarView)window.Content!; + string[] before = Items(list).Select(item => (string)item.Tag!).ToArray(); + + window.Content = null; + Dispatcher.UIThread.RunJobs(); + vm.ReorderNavigationPages([PageType.Bundles, PageType.Installed, PageType.Updates, PageType.Discover]); + Assert.Equal(before, Items(list).Select(item => (string)item.Tag!)); + + window.Content = view; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Assert.Equal(["Bundles", "Installed", "Updates", "Discover"], Items(list).Select(item => (string)item.Tag!)); + AssertAllPresented(list); + }, CancellationToken.None); + + private static (Window Window, SidebarViewModel ViewModel, ListBox List) ShowSidebar(int height = 700) + { + if (!Application.Current!.Styles.OfType().Any()) + Application.Current.Styles.Add(new FluentTheme()); + + var vm = new SidebarViewModel(); + var view = new SidebarView { DataContext = vm }; + var window = new Window { Width = 300, Height = height, Content = view }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + return (window, vm, view.FindControl("NavListBox")!); + } + + private static ListBoxItem[] Items(ListBox list) => list.Items.OfType().ToArray(); + + private static ListBoxItem Item(ListBox list, string tag) => Items(list).Single(item => (string?)item.Tag == tag); + + private static Point Center(Window window, ListBoxItem item) + => item.TranslatePoint(new Point(20, item.Bounds.Height / 2d), window)!.Value; + + private static void AssertAllPresented(ListBox list) + { + double previousTop = double.MinValue; + foreach (ListBoxItem item in Items(list)) + { + Assert.NotNull(item.GetVisualParent()); + Assert.True(item.IsEffectivelyVisible); + Assert.True(item.Bounds.Height > 0); + double top = item.TranslatePoint(default, list)!.Value.Y; + Assert.True(top > previousTop); + previousTop = top; + } + } + + private sealed class TouchInjector + { + private const BindingFlags AnyInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static readonly Type RawTouchEventArgsType = + typeof(RawPointerEventArgs).Assembly.GetType("Avalonia.Input.Raw.RawTouchEventArgs") + ?? throw Unsupported("the Avalonia.Input.Raw.RawTouchEventArgs type"); + + private readonly IInputDevice _device = Activator.CreateInstance(typeof(TouchDevice), nonPublic: true) as IInputDevice + ?? throw Unsupported("a constructible TouchDevice"); + private readonly Action _input; + private readonly IInputRoot _root; + private ulong _timestamp = 1; + + public TouchInjector(Window window) + { + _input = typeof(ITopLevelImpl).GetProperty("Input", AnyInstance)?.GetValue(window.PlatformImpl) as Action + ?? throw Unsupported("the ITopLevelImpl.Input callback"); + _root = typeof(TopLevel).GetProperties(AnyInstance).Where(property => property.GetIndexParameters().Length == 0) + .Select(property => property.GetValue(window)).OfType().FirstOrDefault() + ?? typeof(TopLevel).GetFields(AnyInstance).Select(field => field.GetValue(window)).OfType().FirstOrDefault() + ?? throw Unsupported("the window's IInputRoot"); + } + + private static InvalidOperationException Unsupported(string what) => new( + $"Touch injection relies on Avalonia internals and could not find {what}. " + + "Avalonia probably changed them; update TouchInjector for the new version."); + + public void Send(RawPointerEventType type, Point position) + { + _input((RawInputEventArgs)Activator.CreateInstance( + RawTouchEventArgsType, _device, _timestamp++, _root, type, position, RawInputModifiers.None, 1L)!); + Dispatcher.UIThread.RunJobs(); + } + } +} diff --git a/src/UniGetUI.Tests/SoftwareCatalogTests.cs b/src/UniGetUI.Tests/SoftwareCatalogTests.cs index f7e6535d61..b80bcb2991 100644 --- a/src/UniGetUI.Tests/SoftwareCatalogTests.cs +++ b/src/UniGetUI.Tests/SoftwareCatalogTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.Models; using UniGetUI.Avalonia.ViewModels; using UniGetUI.Avalonia.ViewModels.Pages; @@ -694,25 +695,62 @@ public void NumberShortcutsFollowTheSidebarOrder() Assert.Equal( [PageType.Catalog, PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, PageType.CatalogEditor, PageType.Settings, PageType.Managers], - MainWindowViewModel.GetShortcutPages(true)); + MainWindowViewModel.GetShortcutPages(NavigationPageOrder.DefaultPages(true))); Assert.Equal( [PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, PageType.Settings, PageType.Managers], - MainWindowViewModel.GetShortcutPages(false)); + MainWindowViewModel.GetShortcutPages(NavigationPageOrder.DefaultPages(false))); + Assert.Equal( + [PageType.Updates, PageType.Discover, PageType.Installed, PageType.Bundles, PageType.Settings, PageType.Managers], + MainWindowViewModel.GetShortcutPages(NavigationPageOrder.Resolve("Updates,Discover", false))); } [Theory] - [InlineData(true)] - [InlineData(false)] - public void KeyboardPageCycleFollowsTheSidebarOrderInBothDirections(bool catalogEnabled) + [InlineData(true, "")] + [InlineData(false, "")] + [InlineData(true, "Bundles,Updates,Catalog")] + [InlineData(false, "Bundles,Updates,Catalog")] + public void KeyboardPageCycleFollowsTheSidebarOrderInBothDirections(bool catalogEnabled, string stored) { - var pages = MainWindowViewModel.GetShortcutPages(catalogEnabled); + var pages = MainWindowViewModel.GetShortcutPages(NavigationPageOrder.Resolve(stored, catalogEnabled)); for (int i = 0; i < pages.Length; i++) { var next = pages[(i + 1) % pages.Length]; - Assert.Equal(next, MainWindowViewModel.GetNextPage(pages[i], catalogEnabled)); - Assert.Equal(pages[i], MainWindowViewModel.GetPreviousPage(next, catalogEnabled)); + Assert.Equal(next, MainWindowViewModel.GetNextPage(pages[i], pages)); + Assert.Equal(pages[i], MainWindowViewModel.GetPreviousPage(next, pages)); } - Assert.Equal(PageType.Discover, MainWindowViewModel.GetNextPage(PageType.OwnLog, catalogEnabled)); + Assert.Equal(PageType.Discover, MainWindowViewModel.GetNextPage(PageType.OwnLog, pages)); + } + + [Theory] + [InlineData("", true, new[] { PageType.Catalog, PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, PageType.CatalogEditor })] + [InlineData("", false, new[] { PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles })] + [InlineData("Updates,Installed", true, new[] { PageType.Updates, PageType.Installed, PageType.Catalog, PageType.Discover, PageType.Bundles, PageType.CatalogEditor })] + [InlineData("CatalogEditor,Bundles,Catalog,Discover", false, new[] { PageType.Bundles, PageType.Discover, PageType.Updates, PageType.Installed })] + [InlineData(" Bundles , Bogus,Settings,Bundles,,OwnLog,Null", false, new[] { PageType.Bundles, PageType.Discover, PageType.Updates, PageType.Installed })] + public void StoredNavigationOrderResolvesToEveryVisiblePageOnce(string stored, bool catalogEnabled, PageType[] expected) + => Assert.Equal(expected, NavigationPageOrder.Resolve(stored, catalogEnabled)); + + [Fact] + public void ReorderingWithTheCatalogHiddenKeepsTheCatalogPagesInPlace() + { + var full = NavigationPageOrder.Resolve("Discover,Catalog,Updates,CatalogEditor,Installed,Bundles", true); + var merged = NavigationPageOrder.Merge(full, [PageType.Bundles, PageType.Discover, PageType.Updates, PageType.Installed]); + + Assert.Equal( + [PageType.Bundles, PageType.Catalog, PageType.Discover, PageType.CatalogEditor, PageType.Updates, PageType.Installed], + merged); + Assert.Equal( + [PageType.Bundles, PageType.Discover, PageType.Updates, PageType.Installed], + NavigationPageOrder.Resolve(NavigationPageOrder.Serialize(merged), false)); + } + + [Fact] + public void TheDefaultNavigationOrderIsStoredAsUnset() + { + Assert.Equal("", NavigationPageOrder.Serialize(NavigationPageOrder.DefaultPages(true))); + Assert.Equal( + "Discover,Catalog,Updates,Installed,Bundles,CatalogEditor", + NavigationPageOrder.Serialize(NavigationPageOrder.Resolve("Discover", true))); } [Fact] From b394c66da4dfcd06cc1583caf5c14a2e4b494e7a Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 15 Oct 2026 10:46:00 -0400 Subject: [PATCH 2/2] Navigate on release in the sidebar and harden drag start and drop --- .../Views/SidebarView.axaml.cs | 41 +++++++- .../SidebarNavigationOrderTests.cs | 95 ++++++++++++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs index a8052fd490..30c3ea8a3b 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs @@ -46,6 +46,7 @@ public partial class SidebarView : BaseView private TopLevel? _dragTopLevel; private IPointer? _dragPointer; private SidebarViewModel? _subscribedViewModel; + private PageType? _pendingPointerNavigation; private const double DragThreshold = 6d; private const double PillHeight = 16d; @@ -170,6 +171,7 @@ private void ApplyNavigationOrder(PageType[] pages) private void NavListBox_PointerPressed(object? sender, PointerPressedEventArgs e) { _dragCandidate = null; + _pendingPointerNavigation = null; if (!e.GetCurrentPoint(NavListBox).Properties.IsLeftButtonPressed || (e.Source as Visual)?.FindAncestorOfType(includeSelf: true) is not { } item || !NavListBox.Items.Contains(item)) @@ -189,6 +191,11 @@ private void NavListBox_PointerMoved(object? sender, PointerEventArgs e) double offset = e.GetPosition(DragSurface).Y - _dragStart.Y; if (!_isDragging) { + if (!e.GetCurrentPoint(NavListBox).Properties.IsLeftButtonPressed) + { + AbandonPointerPress(); + return; + } if (Math.Abs(offset) < DragThreshold || !BeginDrag(_dragCandidate)) return; _dragPointer = e.Pointer; @@ -204,9 +211,15 @@ private void NavListBox_PointerReleased(object? sender, PointerReleasedEventArgs if (!_isDragging) { _dragCandidate = null; + if (_pendingPointerNavigation is { } page) + { + _pendingPointerNavigation = null; + ViewModel?.RequestNavigation(page.ToString()); + } return; } + UpdateDrag(e.GetPosition(DragSurface).Y - _dragStart.Y); EndDrag(commit: true); e.Pointer.Capture(null); e.Handled = true; @@ -255,6 +268,7 @@ private bool BeginDrag(ListBoxItem item) _isDragging = true; _dragTargetIndex = _dragFromIndex; + DiscardPendingPointerNavigation(); _dragHadFocus = item.IsKeyboardFocusWithin; _dragTopLevel = TopLevel.GetTopLevel(this); _dragTopLevel?.AddHandler(KeyDownEvent, DragTopLevel_KeyDown, RoutingStrategies.Tunnel); @@ -296,11 +310,29 @@ private void UpdateDrag(double offset) } } - private void EndDrag(bool commit) + private void AbandonPointerPress() { _dragCandidate = null; + DiscardPendingPointerNavigation(); + } + + private void DiscardPendingPointerNavigation() + { + if (_pendingPointerNavigation is null) + return; + + _pendingPointerNavigation = null; + SyncListBoxSelection(ViewModel?.SelectedPageType ?? PageType.Null); + } + + private void EndDrag(bool commit) + { if (!_isDragging) + { + AbandonPointerPress(); return; + } + _dragCandidate = null; _isDragging = false; _dragTopLevel?.RemoveHandler(KeyDownEvent, DragTopLevel_KeyDown); _dragTopLevel = null; @@ -444,7 +476,12 @@ private void HandleNavSelectionChanged(object? selectedItem) return; } - if (Enum.TryParse(tag, out var pageType)) + if (!Enum.TryParse(tag, out var pageType)) + return; + + if (_dragCandidate is not null && ReferenceEquals(selectedItem, _dragCandidate)) + _pendingPointerNavigation = pageType; + else ViewModel?.RequestNavigation(pageType.ToString()); } diff --git a/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs b/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs index 7e57deede5..d53f3d9ac2 100644 --- a/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs +++ b/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs @@ -50,7 +50,7 @@ public Task DraggingAPageReordersTheSidebarAndKeepsEveryPageVisible( window.MouseDown(start, MouseButton.Left); for (int step = 1; step <= 10; step++) - window.MouseMove(start + ((end - start) * step / 10d)); + window.MouseMove(start + ((end - start) * step / 10d), RawInputModifiers.LeftMouseButton); window.MouseUp(end, MouseButton.Left); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); @@ -71,8 +71,8 @@ public Task EscapeCancelsADragWithoutReordering() => _session.Dispatch(() => Point end = Center(window, Item(list, "Bundles")); window.MouseDown(start, MouseButton.Left); - window.MouseMove(start + new Point(0, 20)); - window.MouseMove(end); + window.MouseMove(start + new Point(0, 20), RawInputModifiers.LeftMouseButton); + window.MouseMove(end, RawInputModifiers.LeftMouseButton); window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, null); window.MouseUp(end, MouseButton.Left); Dispatcher.UIThread.RunJobs(); @@ -159,12 +159,12 @@ public Task ScrollingTheSidebarDuringADragStillDropsWhereThePointerIs() => _sess Point start = Center(window, Item(list, "Discover")); window.MouseDown(start, MouseButton.Left); - window.MouseMove(start + new Point(0, 20)); + window.MouseMove(start + new Point(0, 20), RawInputModifiers.LeftMouseButton); scrollViewer.Offset = new Vector(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Point end = Center(window, Item(list, "Bundles")) + new Point(0, 10); - window.MouseMove(end); + window.MouseMove(end, RawInputModifiers.LeftMouseButton); window.MouseUp(end, MouseButton.Left); Dispatcher.UIThread.RunJobs(); @@ -190,6 +190,91 @@ public Task ADetachedSidebarStopsFollowingTheOrderUntilItIsShownAgain() => _sess AssertAllPresented(list); }, CancellationToken.None); + [Fact] + public Task PressingAPageNavigatesOnReleaseAndDraggingItDoesNotNavigate() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + var view = (SidebarView)window.Content!; + var requests = new List(); + vm.NavigationRequested += (_, page) => + { + requests.Add(page); + view.IsHitTestVisible = false; + }; + Point start = Center(window, Item(list, "Updates")); + Point end = Center(window, Item(list, "Bundles")) + new Point(0, 10); + + window.MouseDown(start, MouseButton.Left); + Assert.Empty(requests); + for (int step = 1; step <= 10; step++) + window.MouseMove(start + ((end - start) * step / 10d), RawInputModifiers.LeftMouseButton); + window.MouseUp(end, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + Assert.Empty(requests); + Assert.Equal([PageType.Discover, PageType.Installed, PageType.Bundles, PageType.Updates], vm.NavigationPages); + + Point click = Center(window, Item(list, "Installed")); + window.MouseDown(click, MouseButton.Left); + Assert.Empty(requests); + window.MouseUp(click, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + Assert.Equal([PageType.Installed], requests); + }, CancellationToken.None); + + [Fact] + public Task KeyboardSelectionStillNavigatesImmediately() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + var requests = new List(); + vm.NavigationRequested += (_, page) => requests.Add(page); + Item(list, "Discover").Focus(NavigationMethod.Directional); + + window.KeyPress(Key.Down, RawInputModifiers.None, PhysicalKey.ArrowDown, null); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal([PageType.Updates], requests); + }, CancellationToken.None); + + [Fact] + public Task HoveringAfterAMissedReleaseDoesNotStartADrag() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(); + var requests = new List(); + vm.NavigationRequested += (_, page) => requests.Add(page); + Point start = Center(window, Item(list, "Updates")); + + window.MouseDown(start, MouseButton.Left); + window.MouseMove(start + new Point(0, 2), RawInputModifiers.LeftMouseButton); + window.MouseMove(Center(window, Item(list, "Bundles")), RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + + Assert.All(Items(list), item => Assert.Null(item.RenderTransform)); + Assert.Empty(requests); + Assert.Null(list.SelectedItem); + Assert.Equal([PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles], vm.NavigationPages); + }, CancellationToken.None); + + [Fact] + public Task ReleasingAfterTheSidebarScrollsDropsWhereThePointerIs() => _session.Dispatch(() => + { + var (window, vm, list) = ShowSidebar(330); + var scrollViewer = list.GetVisualDescendants().OfType().First(); + Point start = Center(window, Item(list, "Discover")); + Point held = start + new Point(0, 20); + + window.MouseDown(start, MouseButton.Left); + window.MouseMove(held, RawInputModifiers.LeftMouseButton); + scrollViewer.Offset = new Vector(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + window.MouseUp(held, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + Assert.True(scrollViewer.Offset.Y > 0); + Assert.Equal([PageType.Updates, PageType.Discover, PageType.Installed, PageType.Bundles], vm.NavigationPages); + }, CancellationToken.None); + private static (Window Window, SidebarViewModel ViewModel, ListBox List) ShowSidebar(int height = 700) { if (!Application.Current!.Styles.OfType().Any())