diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index 77c160a0e..ae2452a0c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -235,14 +235,6 @@ private static void ShowOperationSuccessNotification(AbstractOperation op) if (OperatingSystem.IsMacOS() && MacOsNotificationBridge.ShowSuccess(op)) return; - - if (TryGetMainWindow() is not { } mainWindow) - return; - - mainWindow.ShowRuntimeNotification( - title, - message, - UniGetUI.Avalonia.Views.MainWindow.RuntimeNotificationLevel.Success); } private static void ShowOperationFailureNotification(AbstractOperation op) diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index 0c20c6432..a733c9d1b 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -47,21 +47,27 @@ partial void OnBundlesBadgeVisibleChanged(bool value) private bool _installedIsLoading; // ─── Pane open/closed ───────────────────────────────────────────────────── + // Starts collapsed: the pane is a floating overlay in every size class (see MainWindow's + // adaptive logic), so there is no persistent open-on-launch state. [ObservableProperty] - private bool isPaneOpen = !Settings.Get(Settings.K.CollapseNavMenuOnWideScreen); + private bool isPaneOpen; + + // Only persist the open/closed choice as the "collapse on wide screen" preference + // while the pane is inline (Expanded mode). In the overlay modes used on smaller + // windows, opening/closing is transient and must not overwrite the saved preference. + // The MainWindow's adaptive logic keeps this in sync with the SplitView display mode. + public bool PersistPaneCollapsePreference { get; set; } = true; partial void OnIsPaneOpenChanged(bool value) { - Settings.Set(Settings.K.CollapseNavMenuOnWideScreen, !value); - OnPropertyChanged(nameof(PaneWidth)); + if (PersistPaneCollapsePreference) + Settings.Set(Settings.K.CollapseNavMenuOnWideScreen, !value); OnPropertyChanged(nameof(UpdatesBadgeExpandedVisible)); OnPropertyChanged(nameof(UpdatesBadgeCompactVisible)); OnPropertyChanged(nameof(BundlesBadgeExpandedVisible)); OnPropertyChanged(nameof(BundlesBadgeCompactVisible)); } - public double PaneWidth => IsPaneOpen ? 250 : 64; - public bool UpdatesBadgeExpandedVisible => UpdatesBadgeVisible && IsPaneOpen; public bool UpdatesBadgeCompactVisible => UpdatesBadgeVisible && !IsPaneOpen; public bool BundlesBadgeExpandedVisible => BundlesBadgeVisible && IsPaneOpen; diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml index df986043b..49dc4a93c 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -16,17 +16,56 @@ Width="1450" MinWidth="700" MinHeight="500"> + + + + + + + + + + + - - + + ColumnDefinitions="Auto,*"> - - + - + + + + - + @@ -69,7 +111,7 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - + + + + + + 0 + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index c04c16c2c..d8857bb09 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -96,11 +96,14 @@ public MainWindow() DataContext = new MainWindowViewModel(); InitializeComponent(); SetupTitleBar(); + SetupResponsiveRail(); RestoreGeometry(); KeyDown += Window_KeyDown; ViewModel.CurrentPageChanged += OnCurrentPageChanged; + // Title-bar back button: visible whenever there's somewhere to go back to (mirrors WinUI's TitleBar.IsBackButtonVisible). + ViewModel.CanGoBackChanged += (_, canGoBack) => BackButton.IsVisible = canGoBack; ViewModel.PropertyChanged += OnViewModelPropertyChanged; UpdateOperationsPanelRow(); @@ -216,6 +219,9 @@ private void Window_KeyDown(object? sender, KeyEventArgs e) private void OnCurrentPageChanged(object? sender, PageType pageType) { + // Like WinUI's NavigationView: picking a page collapses the sliding flyout back to the rail. + ViewModel.Sidebar.IsPaneOpen = false; + if (!_focusSidebarSelectionOnNextPageChange) return; @@ -227,6 +233,8 @@ private void OnCurrentPageChanged(object? sender, PageType pageType) }, DispatcherPriority.Background); } + private void BackButton_Click(object? sender, RoutedEventArgs e) => ViewModel.NavigateBack(); + private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName is nameof(MainWindowViewModel.OperationsPanelExpanded) @@ -240,10 +248,10 @@ or nameof(MainWindowViewModel.OperationsPanelVisible)) // user's chosen size across collapse/expand. private void UpdateOperationsPanelRow() { - if (MainContentGrid.RowDefinitions.Count < 3) + if (ContentRoot.RowDefinitions.Count < 3) return; - RowDefinition row = MainContentGrid.RowDefinitions[2]; + RowDefinition row = ContentRoot.RowDefinitions[2]; if (ViewModel.OperationsPanelVisible && ViewModel.OperationsPanelExpanded) { row.MinHeight = 80; @@ -258,6 +266,21 @@ private void UpdateOperationsPanelRow() } } + // ─── Navigation rail (responsive) ───────────────────────────────────────── + // The always-visible icon rail shows on roomy windows and collapses below 800px so narrow + // windows give the content full width (the hamburger + sliding flyout still provide nav). + private void SetupResponsiveRail() + => MainContentRoot.GetObservable(BoundsProperty) + .Subscribe(b => + { + if (b.Width <= 0) return; + NavRail.IsVisible = b.Width >= 800; + }); + + // Light-dismiss: clicking outside the open flyout closes it (no darkening — the layer is transparent). + private void FlyoutDismiss_PointerPressed(object? sender, PointerPressedEventArgs e) + => ViewModel.Sidebar.IsPaneOpen = false; + private void SetupTitleBar() { if (OperatingSystem.IsMacOS()) @@ -278,8 +301,8 @@ private void SetupTitleBar() { TitleBarGrid.ClearValue(HeightProperty); TitleBarGrid.Height = 44; - MainContentGrid.ClearValue(MarginProperty); - MainContentGrid.Margin = new Thickness(0, 44, 0, 0); + MainContentRoot.ClearValue(MarginProperty); + MainContentRoot.Margin = new Thickness(0, 44, 0, 0); HamburgerPanel.Margin = new Thickness(10, 0, 8, 0); } else @@ -288,7 +311,7 @@ private void SetupTitleBar() { RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor) { AncestorType = typeof(Window) }, }); - MainContentGrid.Bind(MarginProperty, new Binding("WindowDecorationMargin") + MainContentRoot.Bind(MarginProperty, new Binding("WindowDecorationMargin") { RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor) { AncestorType = typeof(Window) }, }); @@ -311,7 +334,7 @@ private void SetupTitleBar() TitleBarGrid.Height = 44; HamburgerPanel.Margin = new Thickness(10, 0, 8, 0); WindowButtons.IsVisible = true; - MainContentGrid.Margin = new Thickness(0, 44, 0, 0); + MainContentRoot.Margin = new Thickness(0, 44, 0, 0); this.GetObservable(WindowStateProperty).Subscribe(state => { UpdateMaximizeButtonState(state == WindowState.Maximized); @@ -328,7 +351,7 @@ private void SetupTitleBar() TitleBarGrid.Height = 44; HamburgerPanel.Margin = new Thickness(10, 0, 8, 0); WindowButtons.IsVisible = !useNativeDecorations; - MainContentGrid.Margin = new Thickness(0, 44, 0, 0); + MainContentRoot.Margin = new Thickness(0, 44, 0, 0); // Keep maximize icon in sync with window state this.GetObservable(WindowStateProperty).Subscribe(state => { diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml b/src/UniGetUI.Avalonia/Views/SidebarView.axaml index 0ecc44b0a..81c5535dd 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml @@ -4,10 +4,12 @@ xmlns:automation="clr-namespace:Avalonia.Automation;assembly=Avalonia.Controls" xmlns:vm="using:UniGetUI.Avalonia.ViewModels" xmlns:controls="using:UniGetUI.Avalonia.Views.Controls" + xmlns:local="using:UniGetUI.Avalonia.Views" xmlns:t="using:UniGetUI.Avalonia.MarkupExtensions" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" + x:Name="SidebarRoot" x:DataType="vm:SidebarViewModel" MinWidth="64"> @@ -36,13 +38,16 @@ FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" - IsVisible="{Binding IsPaneOpen}"/> + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> + Margin="4,0,0,2"/> @@ -58,26 +63,15 @@ FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" - IsVisible="{Binding IsPaneOpen}"/> - - - - + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> - + @@ -91,8 +85,11 @@ + Margin="4,0,0,2"/> @@ -108,13 +105,16 @@ FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" - IsVisible="{Binding IsPaneOpen}"/> + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> + Margin="4,0,0,2"/> @@ -130,27 +130,14 @@ FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" - IsVisible="{Binding IsPaneOpen}"/> - - - - + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> - + @@ -177,7 +164,7 @@ + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> @@ -195,7 +182,7 @@ + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> @@ -211,7 +198,7 @@ + IsVisible="{Binding $parent[local:SidebarView].ShowLabels}"/> diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs index 2963ab9ef..286ef2f12 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs @@ -1,3 +1,4 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using UniGetUI.Avalonia.ViewModels; @@ -8,6 +9,20 @@ public partial class SidebarView : BaseView { private bool _lastNavItemSelectionWasAuto; + /// + /// Whether the nav item text labels are shown. False renders an icon-only rail; true renders the + /// full labeled pane. Decoupled from the view-model's pane state so the same view can be used both + /// as the always-visible rail and as the sliding flyout simultaneously. + /// + public static readonly StyledProperty ShowLabelsProperty = + AvaloniaProperty.Register(nameof(ShowLabels), defaultValue: true); + + public bool ShowLabels + { + get => GetValue(ShowLabelsProperty); + set => SetValue(ShowLabelsProperty, value); + } + public SidebarView() { InitializeComponent(); diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index f09cc7714..a3b591a88 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -51,6 +51,27 @@ + + + + + + @@ -103,6 +124,13 @@ with the package list when the pane is open, and collapse next to the Filters button when closed. --> + + + + + + - - - - - + + + + + + 0 + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs index 93101467c..d47a83296 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs @@ -1,4 +1,6 @@ using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -7,6 +9,7 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Media.Transformation; using Avalonia.Threading; using UniGetUI.Avalonia.ViewModels.Pages; using UniGetUI.Avalonia.Views.Controls; @@ -25,6 +28,7 @@ public abstract partial class AbstractPackagesPage : UserControl, private double _savedFilterPaneWidth = 220; private bool _isOverlayMode; private IDisposable? _sidePanelBgBinding; + private CancellationTokenSource? _filterPaneAnimCts; protected AbstractPackagesPage(PackagesPageData data) { @@ -59,7 +63,7 @@ or nameof(PackagesPageViewModel.SortAscending)) if (args.PropertyName is nameof(PackagesPageViewModel.IsFilterPaneOpen)) { SyncFiltersButtonName(); - UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen); + UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen, animate: true); } }; SyncFiltersButtonName(); @@ -146,6 +150,19 @@ or nameof(PackagesPageViewModel.SortAscending)) // Apply the initial filter-pane state (AXAML defaults to 220px open). UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen); + + // Attach the pane slide AFTER the initial state so launch doesn't animate. The pane slides + // via a compositor RenderTransform (clipped by FilteringPanel) instead of width-tweening the + // column, which reflowed the package list every frame and felt laggy. + SidePanel.Transitions = new Transitions + { + new TransformOperationsTransition + { + Property = Visual.RenderTransformProperty, + Duration = TimeSpan.FromMilliseconds(200), + Easing = new SplineEasing(0.1, 0.9, 0.2, 1.0), + } + }; } // Recompute the grid-view card slot width: fit as many >=275px columns as possible, @@ -394,12 +411,14 @@ private void OnFilteringPanelWidthChanged(double width) UpdateFilterPaneColumn(ViewModel.IsFilterPaneOpen); } - private void UpdateFilterPaneColumn(bool open) + private void UpdateFilterPaneColumn(bool open, bool animate = false) { if (FilteringPanel.ColumnDefinitions.Count < 2) return; if (_isOverlayMode) { + _filterPaneAnimCts?.Cancel(); + // Package list fills full width; filter pane and splitter take no space. FilteringPanel.ColumnDefinitions[0].Width = new GridLength(0); FilteringPanel.ColumnDefinitions[1].Width = new GridLength(0); @@ -409,6 +428,8 @@ private void UpdateFilterPaneColumn(bool open) SidePanel.ZIndex = 10; SidePanel.Width = _savedFilterPaneWidth; SidePanel.HorizontalAlignment = HorizontalAlignment.Left; + SidePanel.IsVisible = open; + SetSidePanelTransformInstant(0); // Floating over content needs an opaque surface (the page surface is transparent under Mica). _sidePanelBgBinding ??= SidePanel.Bind(Border.BackgroundProperty, this.GetResourceObservable("AppWindowBackground")); @@ -428,13 +449,74 @@ private void UpdateFilterPaneColumn(bool open) SidePanel.Background = null; FilterOverlayBackdrop.IsVisible = false; - FilteringPanel.ColumnDefinitions[0].Width = open - ? new GridLength(_savedFilterPaneWidth) - : new GridLength(0); - FilteringPanel.ColumnDefinitions[1].Width = open - ? new GridLength(4) - : new GridLength(0); + if (animate) + { + AnimateInlineFilterPane(open); + } + else + { + _filterPaneAnimCts?.Cancel(); + SidePanel.IsVisible = open; + SetSidePanelTransformInstant(open ? 0 : -_savedFilterPaneWidth); + FilteringPanel.ColumnDefinitions[0].Width = open ? new GridLength(_savedFilterPaneWidth) : new GridLength(0); + FilteringPanel.ColumnDefinitions[1].Width = open ? new GridLength(4) : new GridLength(0); + } + } + } + + // Reserve/release the inline pane's column in one step (a single reflow, not a per-frame tween), + // and slide the pane itself in/out via a compositor RenderTransform (clipped by FilteringPanel) — + // tweening the column width reflowed the package list every frame and felt laggy. The toolbar + // buttons still slide via their MinWidth transition. + private async void AnimateInlineFilterPane(bool open) + { + _filterPaneAnimCts?.Cancel(); + var cts = new CancellationTokenSource(); + _filterPaneAnimCts = cts; + + var col0 = FilteringPanel.ColumnDefinitions[0]; + var col1 = FilteringPanel.ColumnDefinitions[1]; + + if (open) + { + // Reserve the column (one reflow), then slide the pane in from -width to 0. + col0.Width = new GridLength(_savedFilterPaneWidth); + col1.Width = new GridLength(4); + SidePanel.IsVisible = true; + SidePanel.RenderTransform = TranslateX(0); } + else + { + // Reclaim the list width in one reflow NOW and float the pane over the list, then slide + // the floating pane out — otherwise the list sits beside an empty gap and only snaps to + // full width when the slide ends. Inline positioning is restored on the next open by + // UpdateFilterPaneColumn. + double w = _savedFilterPaneWidth; + Grid.SetColumnSpan(SidePanel, 3); + SidePanel.ZIndex = 10; + SidePanel.Width = w; + SidePanel.HorizontalAlignment = HorizontalAlignment.Left; + _sidePanelBgBinding ??= SidePanel.Bind(Border.BackgroundProperty, this.GetResourceObservable("AppWindowBackground")); + col0.Width = new GridLength(0); + col1.Width = new GridLength(0); + + SidePanel.RenderTransform = TranslateX(-w); + try { await Task.Delay(210, cts.Token); } + catch (TaskCanceledException) { return; } + SidePanel.IsVisible = false; + } + } + + private static ITransform TranslateX(double x) + => TransformOperations.Parse(string.Create(System.Globalization.CultureInfo.InvariantCulture, $"translateX({x}px)")); + + // Set the pane transform without triggering the slide transition (for initial/mode-change states). + private void SetSidePanelTransformInstant(double translateX) + { + var transitions = SidePanel.Transitions; + SidePanel.Transitions = null; + SidePanel.RenderTransform = TranslateX(translateX); + SidePanel.Transitions = transitions; } // ─── Card overflow button (Grid / Icons view) ─────────────────────────────