From 51a32c8427b172fe9cdbad080ce171587bc32660 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 18 Jun 2026 14:17:40 -0400 Subject: [PATCH 1/5] Rework Avalonia navigation into an adaptive rail + cross-fading flyout --- .../ViewModels/SidebarViewModel.cs | 16 ++- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 113 +++++++++++++++--- .../Views/MainWindow.axaml.cs | 37 ++++-- src/UniGetUI.Avalonia/Views/SidebarView.axaml | 63 ++++------ .../Views/SidebarView.axaml.cs | 15 +++ 5 files changed, 179 insertions(+), 65 deletions(-) diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index 0c20c6432e..a733c9d1ba 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 df986043b0..931f745b3f 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -16,17 +16,34 @@ Width="1450" MinWidth="700" MinHeight="500"> + + + + + - - + + ColumnDefinitions="Auto,*"> - - + - + + + + - + @@ -69,7 +89,7 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0ecc44b0a0..81c5535dd2 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 2963ab9ef0..286ef2f129 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(); From cd2d7083314036ee00e400ae359834e57b2d7d17 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 18 Jun 2026 15:30:42 -0400 Subject: [PATCH 2/5] Give both Avalonia search boxes a WinUI-style focus look --- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 107 ++++++++++++------ .../SoftwarePages/AbstractPackagesPage.axaml | 84 +++++++++----- 2 files changed, 130 insertions(+), 61 deletions(-) diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml index 931f745b3f..5409f97931 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -17,6 +17,20 @@ MinWidth="700" MinHeight="500"> + + + + - - - - + + + + + + 0 + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index f09cc7714a..c1c0440684 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -51,6 +51,19 @@ + + + @@ -742,7 +755,8 @@ RowDefinitions="*,64,*" ColumnDefinitions="*,8*,64,*"> - - - - - + + + + + + 0 + + + + + + + + + + From 58fde135562bab64a7f12995a6c251d50c0eb87f Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 18 Jun 2026 15:31:29 -0400 Subject: [PATCH 3/5] Drop the redundant in-app success banner for operations --- .../Infrastructure/AvaloniaOperationRegistry.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index 77c160a0e1..ae2452a0cc 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) From 43e3cfbe3d0e13403fcc3e2cec3859f684ddec87 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 18 Jun 2026 16:11:04 -0400 Subject: [PATCH 4/5] Animate the filter pane open/close --- .../SoftwarePages/AbstractPackagesPage.axaml | 9 +- .../AbstractPackagesPage.axaml.cs | 98 +++++++++++++++++-- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index c1c0440684..9cec908f03 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -116,6 +116,13 @@ with the package list when the pane is open, and collapse next to the Filters button when closed. --> + + + + + + =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) ───────────────────────────── From 7bb33e4163f7ebb13dd933ff087673d61f874380 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 18 Jun 2026 16:19:16 -0400 Subject: [PATCH 5/5] Add hover underline to both search boxes --- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 11 +++++++++-- .../Views/SoftwarePages/AbstractPackagesPage.axaml | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml index 5409f97931..49dc4a93cb 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -22,14 +22,22 @@ via styles (opacity) so the .focus-within setter wins over the base. --> + + + + + + @@ -811,8 +819,7 @@ VerticalAlignment="Bottom" Height="2" Margin="1,0,1,0" - IsHitTestVisible="False" - Background="{DynamicResource SystemAccentColor}"/> + IsHitTestVisible="False"/>