diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml index 1582038c82..a05097453c 100644 --- a/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml +++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.Common.axaml @@ -31,17 +31,33 @@ gets a lighter light-theme value to read as the same weight as the compact title-bar field. --> + + + + + + + - + + + + + + + + + + @@ -64,6 +80,8 @@ + + @@ -76,7 +94,7 @@ - + @@ -113,17 +131,33 @@ share it since thick-stroke heaviness only reads as darker on light backgrounds. --> + + + + + + + - + + + + + + + + + + @@ -142,10 +176,12 @@ - + + + @@ -187,6 +223,44 @@ + + + + + + + + + @@ -198,34 +272,23 @@ + The accent pill is a single moving overlay owned by SidebarView. The content presenter is + named ContentHost (not PART_ContentPresenter) so the base theme's accent-fill setters don't + apply. --> @@ -241,10 +304,6 @@ - - - - - - - - - - + @@ -59,4 +42,4 @@ - + diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs index 082979e708..fc5fa38945 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs @@ -1,5 +1,4 @@ using Avalonia.Controls; -using Avalonia.Input; using Avalonia.Threading; using UniGetUI.Avalonia.ViewModels; using UniGetUI.PackageEngine.Enums; @@ -8,7 +7,7 @@ namespace UniGetUI.Avalonia.Views; -public partial class InstallOptionsWindow : Window +public partial class InstallOptionsWindow : UniGetUI.Avalonia.Views.DialogPages.ImmersiveDialog { public bool ShouldProceedWithOperation => ((InstallOptionsViewModel)DataContext!).ShouldProceedWithOperation; @@ -19,11 +18,6 @@ public InstallOptionsWindow(IPackage package, OperationType operation, InstallOp DataContext = vm; InitializeComponent(); - // Drop the OS title-bar strip but keep the system min/close buttons, extending the - // client area into the title-bar region (matches the Manage-ignored-updates window). - ExtendClientAreaToDecorationsHint = true; - ExtendClientAreaTitleBarHeightHint = -1; - vm.CloseRequested += (_, _) => Close(); } @@ -33,11 +27,4 @@ protected override void OnOpened(EventArgs e) Dispatcher.UIThread.Post(OptionsControl.FocusProfileSelector, DispatcherPriority.Background); } - // The client area is extended over the title bar, so provide window dragging from the - // transparent strip at the top. - private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e) - { - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) - BeginMoveDrag(e); - } } diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml index 4bb3f5e327..f6868596e5 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml +++ b/src/UniGetUI.Avalonia/Views/DialogPages/IntegrityViolationDialog.axaml @@ -1,28 +1,20 @@ - + + Background="{DynamicResource AppDialogBackground}"> - - - + - + + + + + + + + + + + + + _badgeSubscriptions = new(); + private readonly SemaphoreSlim _modalTransitionSemaphore = new(1, 1); + private readonly List _modalStack = new(); + private readonly Dictionary _modalFocusHistory = new(); + private readonly List _webViewsHiddenForModal = new(); + private IDisposable? _modalTitleSubscription; + private Control? _focusBeforeModal; public enum RuntimeNotificationLevel { @@ -149,6 +158,7 @@ public MainWindow() UpdateOperationsPanelRow(); Resized += (_, _) => _ = SaveGeometryAsync(); + ModalLayer.SizeChanged += (_, _) => UpdateModalSurfaceSize(); PositionChanged += (_, _) => _ = SaveGeometryAsync(); this.GetObservable(WindowStateProperty).SubscribeValue(state => { UpdateOnScreenState(); _ = SaveGeometryAsync(); }); this.GetObservable(IsVisibleProperty).SubscribeValue(_ => UpdateOnScreenState()); @@ -237,6 +247,16 @@ private void ReleaseWindowResources() private void Window_KeyDown(object? sender, KeyEventArgs e) { + // A WinUI ContentDialog is a true modal scope: background navigation and package-page + // shortcuts must not run while keyboard focus is inside the dialog. + if (ModalLayer.IsVisible) + { + if (e.Key == Key.Escape) + (ModalContent.Content as ImmersiveDialog)?.Close(); + e.Handled = true; + return; + } + bool isCtrl = e.KeyModifiers.HasFlag(KeyModifiers.Control); bool isShift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); @@ -450,26 +470,13 @@ private void SetupTitleBarFocusOpacity() HamburgerPanel.Opacity = active ? 1.0 : 0.6; WindowButtons.Opacity = active ? 1.0 : 0.55; - // Match WinUI's inactive Mica appearance without invoking the permanent, global - // NotifyMicaUnavailable() fallback used when composition actually fails (#5111). - // A partial (not full) overlay so the inactive window still keeps some of the Mica - // tint instead of flattening to solid grey. Skip it while a modal dialog is open: the - // window deactivates but shouldn't grey out behind its own dialog. Modeless owned - // windows (operation output/failure) don't count — the main window should still grey - // when the user switches to another application. + // WinUI replaces inactive Mica with SolidBackgroundFillColorBase rather than leaving + // a partially wallpaper-tinted backdrop. AppWindowBackground is that token + // (#F3F3F3 light / #202020 dark), so make the fallback fully opaque while inactive. InactiveMicaFallback.Opacity = - MicaWindowHelper.IsMicaEnabled() && !active && !HasOpenModalDialog() ? 0.4 : 0.0; + MicaWindowHelper.IsMicaEnabled() && !active ? 1.0 : 0.0; }); - // True when a window opened modally (ShowDialog) over this one is currently open. - private bool HasOpenModalDialog() - { - foreach (var owned in OwnedWindows) - if (owned.IsDialog) - return true; - return false; - } - private void SetupTitleBar() { if (OperatingSystem.IsMacOS()) @@ -892,7 +899,7 @@ private void UpdateMaximizeButtonState(bool isMaximized) { MaximizeIcon.Data = Geometry.Parse( isMaximized - ? "M2,0 H10 V8 H2 Z M0,2 H8 V10 H0 Z" + ? "M3,0 H10 V7 H8 V2 H3 Z M0,3 H7 V10 H0 Z" : "M0,0 H10 V10 H0 Z"); ToolTip.SetTip( MaximizeButton, @@ -903,7 +910,7 @@ private void UpdateMaximizeButtonState(bool isMaximized) // button's bounds — the area for which we claim HTMAXBUTTON so Snap Layouts can attach. private bool HitTestMaximizeButton(nint lParam) { - if (!MaximizeButton.IsVisible) + if (!MaximizeButton.IsVisible || !MaximizeButton.IsEnabled) return false; try { @@ -931,7 +938,7 @@ private void SetMaximizeButtonVisual(bool hover, bool pressed) if (key is not null && this.TryFindResource(key, ActualThemeVariant, out var res) && res is IBrush brush) MaximizeButton.Background = brush; else - MaximizeButton.Background = Brushes.Transparent; + MaximizeButton.ClearValue(BackgroundProperty); } private static void TrackNonClientMouseLeave(nint hWnd) @@ -1446,6 +1453,259 @@ public void OpenManagerSettings(IPackageManager? manager = null) => ViewModel.OpenManagerSettings(manager); public void ShowHelp(string uriAttachment = "") => ViewModel.ShowHelp(uriAttachment); + /// + /// Shows the ignored-updates editor as a modal surface inside this window. Keeping the + /// surface in the owner's visual tree makes it resize with the app and avoids a second + /// task-switcher/window-management target. + /// + public async Task ShowManageIgnoredUpdatesAsync() + { + var dialog = new ManageIgnoredUpdatesWindow(); + await ShowImmersiveDialogAsync(dialog); + } + + public async Task ShowImmersiveDialogAsync(ImmersiveDialog dialog) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void CloseDialog(object? sender, EventArgs args) => completion.TrySetResult(); + bool opened = false; + bool added = false; + dialog.CloseRequested += CloseDialog; + + try + { + await _modalTransitionSemaphore.WaitAsync(); + try + { + if (_modalStack.Contains(dialog)) + throw new InvalidOperationException("The immersive dialog is already open."); + + if (_modalStack.Count == 0) + { + _focusBeforeModal = FocusManager?.GetFocusedElement() as Control; + HideNativeWebViewsForModal(); + // Block pointer input without putting every underlying control into Avalonia's + // disabled visual state. The modal layer owns focus while it is visible. + MainContentRoot.IsHitTestVisible = false; + TitleBarGrid.IsHitTestVisible = false; + } + else + { + _modalFocusHistory[_modalStack[^1]] = + FocusManager?.GetFocusedElement() as Control; + } + + ModalContent.Content = null; + _modalStack.Add(dialog); + added = true; + PresentModal(dialog); + dialog.NotifyOpened(); + opened = true; + await AnimateModalAsync(opening: true); + FocusModalIfNeeded(dialog); + } + finally + { + _modalTransitionSemaphore.Release(); + } + + await completion.Task; + } + finally + { + dialog.CloseRequested -= CloseDialog; + await _modalTransitionSemaphore.WaitAsync(); + try + { + int index = added ? _modalStack.IndexOf(dialog) : -1; + bool isTopmost = index >= 0 && index == _modalStack.Count - 1; + if (isTopmost) + { + try + { + await AnimateModalAsync(opening: false); + } + catch (Exception ex) + { + Logger.Error(ex); + } + ModalContent.Content = null; + } + + if (index >= 0) + _modalStack.RemoveAt(index); + _modalFocusHistory.Remove(dialog); + + if (opened) + { + try + { + dialog.NotifyClosed(); + } + catch (Exception ex) + { + Logger.Error(ex); + } + } + + if (_modalStack.Count > 0) + { + PresentModal(_modalStack[^1]); + ModalLayer.Opacity = 1; + ModalSurface.Opacity = 1; + ModalSurface.RenderTransform = null; + RestoreModalFocus(_modalStack[^1]); + } + else + { + _modalTitleSubscription?.Dispose(); + _modalTitleSubscription = null; + ModalLayer.IsVisible = false; + ModalContent.Content = null; + ModalTitle.Text = ""; + MainContentRoot.IsHitTestVisible = true; + TitleBarGrid.IsHitTestVisible = true; + RestoreNativeWebViewsAfterModal(); + Dispatcher.UIThread.Post( + () => _focusBeforeModal?.Focus(), + DispatcherPriority.Background); + _focusBeforeModal = null; + } + } + finally + { + _modalTransitionSemaphore.Release(); + } + } + } + + private void PresentModal(ImmersiveDialog dialog) + { + _modalTitleSubscription?.Dispose(); + _modalTitleSubscription = dialog.GetObservable(ImmersiveDialog.TitleProperty) + .SubscribeValue(title => ModalTitle.Text = title ?? ""); + ModalCloseButton.IsVisible = dialog.IsCloseButtonVisible; + ModalContent.Content = dialog; + ModalLayer.IsVisible = true; + UpdateModalSurfaceSize(); + } + + private void HideNativeWebViewsForModal() + { + _webViewsHiddenForModal.Clear(); + foreach (UniGetUiWebView webView in MainContentRoot.GetVisualDescendants().OfType()) + { + if (!webView.IsVisible) + continue; + + _webViewsHiddenForModal.Add(webView); + webView.IsVisible = false; + } + } + + private void RestoreNativeWebViewsAfterModal() + { + foreach (UniGetUiWebView webView in _webViewsHiddenForModal) + webView.IsVisible = true; + _webViewsHiddenForModal.Clear(); + } + + private void FocusModalIfNeeded(ImmersiveDialog dialog) + { + Dispatcher.UIThread.Post( + () => + { + if (ReferenceEquals(ModalContent.Content, dialog) && !dialog.IsKeyboardFocusWithin) + ModalCloseButton.Focus(); + }, + DispatcherPriority.Background); + } + + private void RestoreModalFocus(ImmersiveDialog dialog) + { + _modalFocusHistory.Remove(dialog, out Control? previousFocus); + Dispatcher.UIThread.Post( + () => + { + if (!ReferenceEquals(ModalContent.Content, dialog)) + return; + if (previousFocus?.Focus() != true && !dialog.IsKeyboardFocusWithin) + ModalCloseButton.Focus(); + }, + DispatcherPriority.Background); + } + + private void ModalCloseButton_Click(object? sender, RoutedEventArgs e) => + (ModalContent.Content as ImmersiveDialog)?.Close(); + + private void UpdateModalSurfaceSize() + { + if (ModalContent.Content is not ImmersiveDialog dialog) + return; + + const double maximumSurfaceWidth = 1100; + const double maximumSurfaceHeight = 900; + double layerWidth = ModalLayer.Bounds.Width > 0 ? ModalLayer.Bounds.Width : Bounds.Width; + double layerHeight = ModalLayer.Bounds.Height > 0 ? ModalLayer.Bounds.Height : Bounds.Height; + double availableWidth = Math.Max(0, layerWidth - 96); + double availableHeight = Math.Max(0, layerHeight - 96); + + ModalSurface.MaxWidth = Math.Min(maximumSurfaceWidth, availableWidth); + ModalSurface.MaxHeight = Math.Min(maximumSurfaceHeight, availableHeight); + ModalSurface.Width = double.IsFinite(dialog.MaxWidth) + ? Math.Min(dialog.MaxWidth, ModalSurface.MaxWidth) + : double.NaN; + ModalSurface.Height = double.IsFinite(dialog.MaxHeight) + ? Math.Min(dialog.MaxHeight, ModalSurface.MaxHeight) + : double.NaN; + } + + private async Task AnimateModalAsync(bool opening) + { + if (MotionPreference.ReducedMotion) + { + ModalLayer.Opacity = opening ? 1 : 0; + ModalSurface.Opacity = opening ? 1 : 0; + ModalSurface.RenderTransform = null; + return; + } + + var scale = new ScaleTransform(); + ModalSurface.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); + ModalSurface.RenderTransform = scale; + + double fromOpacity = opening ? 0 : 1; + double toOpacity = opening ? 1 : 0; + double fromScale = opening ? 1.06 : 1; + double toScale = opening ? 1 : 1.06; + var duration = TimeSpan.FromMilliseconds(opening ? 180 : 120); + long started = Stopwatch.GetTimestamp(); + while (true) + { + double progress = Math.Clamp( + Stopwatch.GetElapsedTime(started).TotalMilliseconds / duration.TotalMilliseconds, + 0, + 1); + double eased = 1 - Math.Pow(1 - progress, 3); + double opacity = fromOpacity + ((toOpacity - fromOpacity) * eased); + double currentScale = fromScale + ((toScale - fromScale) * eased); + + ModalLayer.Opacity = opacity; + ModalSurface.Opacity = opacity; + scale.ScaleX = currentScale; + scale.ScaleY = currentScale; + + if (progress >= 1) + break; + await Task.Delay(16); + } + + ModalLayer.Opacity = toOpacity; + ModalSurface.Opacity = toOpacity; + scale.ScaleX = toScale; + scale.ScaleY = toScale; + } + /// /// Focuses the global search box and optionally pre-fills a character typed /// while the package list had focus (type-to-search). diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ManagersHomepage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ManagersHomepage.axaml.cs index 5e903b8d23..4719bfe73c 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ManagersHomepage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ManagersHomepage.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Shapes; using Avalonia.Layout; using Avalonia.Media; using UniGetUI.Avalonia.Infrastructure; @@ -9,6 +10,7 @@ using UniGetUI.Core.Tools; using UniGetUI.PackageEngine; using UniGetUI.PackageEngine.Interfaces; +using AvaloniaPath = Avalonia.Controls.Shapes.Path; using CoreSettings = UniGetUI.Core.SettingsEngine.Settings; namespace UniGetUI.Avalonia.Views.Pages.SettingsPages; @@ -22,7 +24,8 @@ public event EventHandler? RestartRequired { add { } remove { } } public event EventHandler? NavigationRequested { add { } remove { } } public event EventHandler? ManagerNavigationRequested; - private readonly List<(ToggleSwitch Toggle, IPackageManager Manager, Border Badge, TextBlock BadgeText)> _rows = []; + private readonly List<(ToggleSwitch Toggle, IPackageManager Manager, Border Badge, + Ellipse BadgeIcon, AvaloniaPath BadgeGlyph, TextBlock BadgeText)> _rows = []; private bool _isLoadingToggles; public ManagersHomepage() @@ -51,11 +54,45 @@ public ManagersHomepage() VerticalAlignment = VerticalAlignment.Center, }; AutomationProperties.SetAccessibilityView(badgeText, AccessibilityView.Raw); + + var badgeIcon = new Ellipse + { + Width = 12, + Height = 12, + }; + var badgeGlyph = new AvaloniaPath + { + Width = 5, + Height = 5, + StrokeThickness = 1.5, + StrokeLineCap = PenLineCap.Round, + StrokeJoin = PenLineJoin.Round, + Stretch = Stretch.None, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + var iconHost = new Grid + { + Width = 12, + Height = 12, + VerticalAlignment = VerticalAlignment.Center, + Children = { badgeIcon, badgeGlyph }, + }; + AutomationProperties.SetAccessibilityView(iconHost, AccessibilityView.Raw); + + var badgeContent = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + VerticalAlignment = VerticalAlignment.Center, + Children = { iconHost, badgeText }, + }; var badge = new Border { CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 3, 6, 3), - Child = badgeText, + HorizontalAlignment = HorizontalAlignment.Center, + Child = badgeContent, }; AutomationProperties.SetAccessibilityView(badge, AccessibilityView.Raw); @@ -64,6 +101,7 @@ public ManagersHomepage() { OnContent = "", OffContent = "", + HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; AutomationProperties.SetName(toggle, manager.DisplayName); @@ -72,14 +110,14 @@ public ManagersHomepage() _isLoadingToggles = true; toggle.IsChecked = manager.IsEnabled(); _isLoadingToggles = false; - ApplyStatusBadge(manager, toggle, badge, badgeText); + ApplyStatusBadge(manager, toggle, badge, badgeIcon, badgeGlyph, badgeText); }; toggle.IsCheckedChanged += async (_, _) => { if (_isLoadingToggles) return; CoreSettings.SetDictionaryItem(CoreSettings.K.DisabledManagers, manager.Name, toggle.IsChecked != true); await Task.Run(manager.Initialize); - ApplyStatusBadge(manager, toggle, badge, badgeText); + ApplyStatusBadge(manager, toggle, badge, badgeIcon, badgeGlyph, badgeText); AccessibilityAnnouncementService.AnnounceToggle(manager.DisplayName, toggle.IsChecked == true); }; @@ -108,7 +146,7 @@ public ManagersHomepage() btn.Click += (_, _) => ManagerNavigationRequested?.Invoke(this, capturedManager); ManagersPanel.Children.Add(btn); - _rows.Add((toggle, manager, badge, badgeText)); + _rows.Add((toggle, manager, badge, badgeIcon, badgeGlyph, badgeText)); } } @@ -116,37 +154,51 @@ public ManagersHomepage() public void RefreshToggles() { _isLoadingToggles = true; - foreach (var (toggle, manager, badge, badgeText) in _rows) + foreach (var (toggle, manager, badge, badgeIcon, badgeGlyph, badgeText) in _rows) { toggle.IsChecked = manager.IsEnabled(); - ApplyStatusBadge(manager, toggle, badge, badgeText); + ApplyStatusBadge(manager, toggle, badge, badgeIcon, badgeGlyph, badgeText); } _isLoadingToggles = false; } - private void ApplyStatusBadge(IPackageManager manager, ToggleSwitch toggle, Border badge, TextBlock text) + private void ApplyStatusBadge( + IPackageManager manager, + ToggleSwitch toggle, + Border badge, + Ellipse icon, + AvaloniaPath glyph, + TextBlock text) { string bgKey, fgKey, label; + glyph.RenderTransform = null; if (!manager.IsEnabled()) { bgKey = "WarningBannerBackground"; fgKey = "StatusWarningForeground"; label = CoreTools.Translate("Disabled"); + glyph.Data = Geometry.Parse("M2.5,0.4 L2.5,2.1 M2.1,4.5 L2.9,4.5"); } else if (manager.Status.Found) { bgKey = "StatusSuccessBackground"; fgKey = "StatusSuccessForeground"; label = CoreTools.Translate("Ready"); + glyph.Data = Geometry.Parse("M0.5,2.6 L2,4.1 L4.5,0.9"); + glyph.RenderTransform = new TranslateTransform(0, 0.5); } else { bgKey = "StatusErrorBackground"; fgKey = "StatusErrorForeground"; label = CoreTools.Translate("Not found"); + glyph.Data = Geometry.Parse("M0.75,0.75 L4.25,4.25 M4.25,0.75 L0.75,4.25"); } - badge.Background = LookupBrush(bgKey); - text.Foreground = LookupBrush(fgKey); + IBrush background = LookupBrush(bgKey); + badge.Background = background; + icon.Fill = LookupBrush(fgKey); + glyph.Stroke = background; + text.Foreground = LookupBrush("TextFillColorPrimaryBrush"); text.Text = label; // Bake state into Name so VoiceOver always announces it on macOS AutomationProperties.SetName(toggle, $"{manager.DisplayName}, {label}"); diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml b/src/UniGetUI.Avalonia/Views/SidebarView.axaml index 61e47a35be..7dc13927d9 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml @@ -13,7 +13,7 @@ x:DataType="vm:SidebarViewModel" MinWidth="64"> - + - + @@ -234,5 +233,16 @@ + + + + + diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs index 5bdf240b8e..1f1dcaf566 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs @@ -4,8 +4,11 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Styling; +using Avalonia.Threading; +using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.ViewModels; namespace UniGetUI.Avalonia.Views; @@ -13,6 +16,12 @@ namespace UniGetUI.Avalonia.Views; public partial class SidebarView : BaseView { private bool _lastNavItemSelectionWasAuto; + private bool _isMoreFlyoutOpen; + private CancellationTokenSource? _pillAnimationCancellation; + private int _pillAnimationVersion; + + private const double PillHeight = 16d; + private static readonly TimeSpan PillAnimationDuration = TimeSpan.FromMilliseconds(400); /// /// Whether the nav item text labels are shown. False renders an icon-only rail; true renders the @@ -31,6 +40,15 @@ public bool ShowLabels public SidebarView() { InitializeComponent(); + if (FlyoutBase.GetAttachedFlyout(MoreNavBtn) is { } moreFlyout) + { + moreFlyout.Opened += (_, _) => _isMoreFlyoutOpen = true; + moreFlyout.Closed += (_, _) => + { + _isMoreFlyoutOpen = false; + SyncListBoxSelection(ViewModel?.SelectedPageType ?? PageType.Null); + }; + } } protected override void OnDataContextChanged(EventArgs e) @@ -50,6 +68,9 @@ protected override void OnDataContextChanged(EventArgs e) private void SyncListBoxSelection(PageType page) { + if (_isMoreFlyoutOpen) + return; + // Selection lives in two ListBoxes (main + footer); only one may hold a selection at a time. _lastNavItemSelectionWasAuto = true; NavListBox.SelectedItem = page switch @@ -67,6 +88,7 @@ private void SyncListBoxSelection(PageType page) _ => null, }; _lastNavItemSelectionWasAuto = false; + QueueSelectionPillUpdate(animate: NavigationSelectionPill.IsVisible); } private void NavListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -82,8 +104,9 @@ private void HandleNavSelectionChanged(object? selectedItem) if (tag == "More") { - // Not a page: re-sync selection so "More" doesn't stay highlighted, then open its menu. - SyncListBoxSelection(ViewModel?.SelectedPageType ?? PageType.Null); + // Keep the item selected until the menu closes so its accent pill remains anchored. + _isMoreFlyoutOpen = true; + QueueSelectionPillUpdate(animate: true, item); FlyoutBase.ShowAttachedFlyout(item); return; } @@ -115,4 +138,152 @@ public void FocusSelectedItem() else NavListBox.Focus(); } + + private void QueueSelectionPillUpdate(bool animate, ListBoxItem? targetItem = null) + { + Dispatcher.UIThread.Post( + () => + { + if ((targetItem ?? NavListBox.SelectedItem ?? FooterNavListBox.SelectedItem) is ListBoxItem item) + { + MoveSelectionPill(item, animate); + } + else + { + _pillAnimationCancellation?.Cancel(); + NavigationSelectionPill.IsVisible = false; + } + }, + DispatcherPriority.Render); + } + + private void MoveSelectionPill(ListBoxItem item, bool animate) + { + Point? itemPosition = item.TranslatePoint(default, SidebarLayout); + if (itemPosition is null || item.Bounds.Height <= 0) + return; + + double targetTop = itemPosition.Value.Y + ((item.Bounds.Height - PillHeight) / 2d); + double targetLeft = itemPosition.Value.X; + + _pillAnimationCancellation?.Cancel(); + _pillAnimationCancellation?.Dispose(); + _pillAnimationCancellation = null; + + Canvas.SetLeft(NavigationSelectionPill, targetLeft); + + if (!NavigationSelectionPill.IsVisible || !animate || MotionPreference.ReducedMotion) + { + Canvas.SetTop(NavigationSelectionPill, targetTop); + NavigationSelectionPill.Height = PillHeight; + NavigationSelectionPill.IsVisible = true; + return; + } + + double currentTop = Canvas.GetTop(NavigationSelectionPill); + if (double.IsNaN(currentTop)) + currentTop = targetTop; + + double currentBottom = currentTop + NavigationSelectionPill.Height; + double targetBottom = targetTop + PillHeight; + if (Math.Abs(currentTop - targetTop) < 0.5) + return; + + bool movingDown = targetTop > currentTop; + _pillAnimationCancellation = new CancellationTokenSource(); + int version = ++_pillAnimationVersion; + _ = AnimatePillEdgesAsync( + currentTop, + currentBottom, + targetTop, + targetBottom, + movingDown, + version, + _pillAnimationCancellation.Token); + } + + private async Task AnimatePillEdgesAsync( + double startTop, + double startBottom, + double targetTop, + double targetBottom, + bool movingDown, + int version, + CancellationToken cancellationToken) + { + var animation = new Animation + { + Duration = PillAnimationDuration, + FillMode = FillMode.Forward, + }; + + const int sampleCount = 20; + for (int sample = 0; sample <= sampleCount; sample++) + { + double progress = (double)sample / sampleCount; + double lead = EvaluateBezier(progress, 0d, 0d, 0d, 1d); + double trail = EvaluateBezier(progress, 0.5d, 0d, 0.2d, 1d); + double topProgress = movingDown ? trail : lead; + double bottomProgress = movingDown ? lead : trail; + double top = Lerp(startTop, targetTop, topProgress); + double bottom = Lerp(startBottom, targetBottom, bottomProgress); + + animation.Children.Add(new KeyFrame + { + Cue = new Cue(progress), + Setters = + { + new Setter(Canvas.TopProperty, top), + new Setter(Layoutable.HeightProperty, Math.Max(1d, bottom - top)), + }, + }); + } + + try + { + await animation.RunAsync(NavigationSelectionPill, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + + if (version != _pillAnimationVersion || cancellationToken.IsCancellationRequested) + return; + + Canvas.SetTop(NavigationSelectionPill, targetTop); + NavigationSelectionPill.Height = PillHeight; + } + + private static double Lerp(double start, double end, double progress) + => start + ((end - start) * progress); + + private static double EvaluateBezier( + double progress, + double control1X, + double control1Y, + double control2X, + double control2Y) + { + double low = 0d; + double high = 1d; + for (int i = 0; i < 12; i++) + { + double parameter = (low + high) / 2d; + if (BezierCoordinate(parameter, control1X, control2X) < progress) + low = parameter; + else + high = parameter; + } + + return BezierCoordinate((low + high) / 2d, control1Y, control2Y); + } + + private static double BezierCoordinate(double parameter, double control1, double control2) + { + double inverse = 1d - parameter; + return (3d * inverse * inverse * parameter * control1) + + (3d * inverse * parameter * parameter * control2) + + (parameter * parameter * parameter); + } } diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 849b4a0b68..2c5f5fe86f 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -15,6 +15,9 @@ Name="ABSTRACT_PAGE"> + + @@ -847,9 +855,7 @@ Grid.Column="1" Grid.ColumnSpan="2" CornerRadius="8" - BorderBrush="{DynamicResource AppBorderBrush}" BorderThickness="1" - Background="{DynamicResource MicaPageBackground}" ClipToBounds="True"> @@ -918,9 +924,9 @@