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 PageType? _pendingPointerNavigation; + + private const double DragThreshold = 6d; private const double PillHeight = 16d; private static readonly TimeSpan PillAnimationDuration = TimeSpan.FromMilliseconds(300); @@ -64,6 +81,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 +101,313 @@ 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; + _pendingPointerNavigation = 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 (!e.GetCurrentPoint(NavListBox).Properties.IsLeftButtonPressed) { - NavListBox.Items.Remove(CatalogNavBtn); - NavListBox.Items.Remove(CatalogEditorNavBtn); + AbandonPointerPress(); + return; } - AssignShortcutKeys(MainWindowViewModel.GetShortcutPages(vm.SoftwareCatalogEnabled)); + if (Math.Abs(offset) < DragThreshold || !BeginDrag(_dragCandidate)) + return; + _dragPointer = e.Pointer; + e.Pointer.Capture(NavListBox); + } + + UpdateDrag(offset); + e.Handled = true; + } - vm.PropertyChanged += (_, args) => + private void NavListBox_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isDragging) + { + _dragCandidate = null; + if (_pendingPointerNavigation is { } page) { - 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); + _pendingPointerNavigation = null; + ViewModel?.RequestNavigation(page.ToString()); + } + return; } + + UpdateDrag(e.GetPosition(DragSurface).Y - _dragStart.Y); + EndDrag(commit: true); + e.Pointer.Capture(null); + e.Handled = true; } - // Announces each item's Ctrl/Cmd+number shortcut, which shifts when the catalog is enabled. + 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) + { + _dragCandidate = null; + return false; + } + tops[i] = position.Y; + } + if (from < 0 || items.Length < 2) + { + _dragCandidate = null; + return false; + } + + _dragItems = items; + _dragTops = tops; + _dragFromIndex = from; + for (int i = 0; i < _dragItems.Length; i++) + { + var transform = new TranslateTransform(); + if (i != _dragFromIndex && !MotionPreference.ReducedMotion) + { + transform.Transitions = + [ + new DoubleTransition + { + Property = TranslateTransform.YProperty, + Duration = TimeSpan.FromMilliseconds(150), + Easing = new CubicEaseOut(), + }, + ]; + } + _dragItems[i].RenderTransform = transform; + } + + _isDragging = true; + _dragTargetIndex = _dragFromIndex; + DiscardPendingPointerNavigation(); + _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 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; + _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 follows the sidebar order. private void AssignShortcutKeys(PageType[] pages) { string modifier = OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl"; @@ -166,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()); } @@ -219,6 +534,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..d53f3d9ac2 --- /dev/null +++ b/src/UniGetUI.Tests/SidebarNavigationOrderTests.cs @@ -0,0 +1,347 @@ +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), RawInputModifiers.LeftMouseButton); + 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), RawInputModifiers.LeftMouseButton); + window.MouseMove(end, RawInputModifiers.LeftMouseButton); + 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), 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, RawInputModifiers.LeftMouseButton); + 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); + + [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()) + 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]