diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs index afd06fddda..4be7e753e3 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -27,6 +27,8 @@ namespace UniGetUI.Avalonia.ViewModels.Pages; +public sealed record ToolbarEntry(Control Control, string IconName, string Label, Action? Invoke); + public enum SearchMode { Both, Name, Id, Exact, Similar } public enum PackageViewMode { List = 0, Grid = 1, Icons = 2 } @@ -195,7 +197,7 @@ partial void OnIsFilterPaneOpenChanged(bool value) // ─── Collections ────────────────────────────────────────────────────────── public ObservablePackageCollection FilteredPackages { get; } = new(); public AvaloniaList SourceNodes { get; } = new(); - public AvaloniaList ToolBarItems { get; } = new(); + public List ToolbarEntries { get; } = new(); // Labels of toolbar buttons that can be hidden to collapse the menu bar to icon-only // on narrow windows (buttons created with showLabel: false are never tracked here). @@ -345,7 +347,7 @@ public Button AddToolbarButton(string svgName, string label, Action onClick, boo ToolTip.SetTip(btn, label); AutomationProperties.SetName(btn, label); btn.Click += (_, _) => onClick(); - ToolBarItems.Add(btn); + ToolbarEntries.Add(new ToolbarEntry(btn, svgName, label, onClick)); return btn; } @@ -379,7 +381,7 @@ public void AddToolbarSeparator() ?? new SolidColorBrush(Color.FromArgb(80, 128, 128, 128)), }; AutomationProperties.SetAccessibilityView(sep, AccessibilityView.Raw); - ToolBarItems.Add(sep); + ToolbarEntries.Add(new ToolbarEntry(sep, "", "", null)); } public async Task ShowInfoDialog(Window owner, string title, string message) diff --git a/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs b/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs new file mode 100644 index 0000000000..6ac6df4ec5 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs @@ -0,0 +1,227 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.VisualTree; + +namespace UniGetUI.Avalonia.Views.Controls; + +public sealed class ToolbarOverflowPanel : Panel +{ + public static readonly StyledProperty SpacingProperty = + AvaloniaProperty.Register(nameof(Spacing), 4.0); + + static ToolbarOverflowPanel() + { + AffectsMeasure(SpacingProperty); + } + + private readonly List _items = new(); + private readonly List _overflowedItems = new(); + private readonly Dictionary _naturalWidths = new(); + private double _expandedRowWidth = double.NaN; + private bool _labelsCollapsed; + + public double Spacing + { + get => GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + public Control? OverflowControl { get; set; } + + public Action? LabelCollapseRequested { get; set; } + + public IReadOnlyList OverflowedItems => _overflowedItems; + + protected override Size MeasureOverride(Size availableSize) + { + CollectItems(); + + double height = MeasureChildren(availableSize.Height); + + if (UpdateLabelState(availableSize.Width)) + height = Math.Max(height, MeasureChildren(availableSize.Height)); + + ApplyOverflow(availableSize.Width); + height = Math.Max(height, MeasureChildren(availableSize.Height)); + + double used = UsedWidth(); + return new Size(double.IsInfinity(availableSize.Width) ? used : Math.Min(used, availableSize.Width), height); + } + + protected override Size ArrangeOverride(Size finalSize) + { + CollectItems(); + + double x = 0; + foreach (var item in _items) + { + if (!item.IsVisible) + { + item.Arrange(new Rect(x, 0, 0, 0)); + continue; + } + + double itemWidth = NaturalWidthOf(item); + item.Arrange(new Rect(x, 0, itemWidth, finalSize.Height)); + x += itemWidth + Spacing; + } + + if (OverflowControl is { } overflow) + { + double overflowWidth = overflow.IsVisible ? NaturalWidthOf(overflow) : 0; + overflow.Arrange(new Rect(x, 0, overflowWidth, overflowWidth > 0 ? finalSize.Height : 0)); + } + + return finalSize; + } + + private void CollectItems() + { + _items.Clear(); + foreach (var child in Children) + { + if (ReferenceEquals(child, OverflowControl)) continue; + _items.Add(child); + } + } + + private double MeasureChildren(double availableHeight) + { + double height = 0; + + foreach (var item in _items) + { + MeasureNaturalWidth(item, availableHeight); + height = Math.Max(height, item.DesiredSize.Height); + } + + if (OverflowControl is { } overflow) + { + MeasureNaturalWidth(overflow, availableHeight); + height = Math.Max(height, overflow.DesiredSize.Height); + } + + return height; + } + + private void MeasureNaturalWidth(Control control, double availableHeight) + { + if (!control.IsVisible) return; + + control.Measure(new Size(double.PositiveInfinity, availableHeight)); + _naturalWidths[control] = control.DesiredSize.Width; + } + + private double NaturalWidthOf(Control control) + => _naturalWidths.GetValueOrDefault(control); + + private double RowWidth() + { + double total = 0; + for (int i = 0; i < _items.Count; i++) + { + if (i > 0) total += Spacing; + total += NaturalWidthOf(_items[i]); + } + return total; + } + + private double UsedWidth() + { + double total = 0; + foreach (var item in _items) + { + if (!item.IsVisible) continue; + if (total > 0) total += Spacing; + total += NaturalWidthOf(item); + } + + if (OverflowControl is { IsVisible: true } overflow) + { + if (total > 0) total += Spacing; + total += NaturalWidthOf(overflow); + } + + return total; + } + + private bool UpdateLabelState(double availableWidth) + { + if (double.IsInfinity(availableWidth) || availableWidth <= 1 || LabelCollapseRequested is null) return false; + + double rowWidth = RowWidth(); + + if (!_labelsCollapsed) + { + _expandedRowWidth = rowWidth; + if (rowWidth <= availableWidth + 0.5) return false; + ApplyLabelState(true); + return true; + } + + if (double.IsNaN(_expandedRowWidth) || _expandedRowWidth > availableWidth - 0.5) return false; + ApplyLabelState(false); + return true; + } + + private void ApplyLabelState(bool collapsed) + { + _labelsCollapsed = collapsed; + LabelCollapseRequested?.Invoke(collapsed); + + _overflowedItems.Clear(); + foreach (var item in _items) + { + item.IsVisible = true; + _naturalWidths.Remove(item); + InvalidateMeasureTree(item); + } + } + + private static void InvalidateMeasureTree(Control control) + { + control.InvalidateMeasure(); + foreach (var descendant in control.GetVisualDescendants()) + { + if (descendant is Layoutable layoutable) layoutable.InvalidateMeasure(); + } + } + + private void ApplyOverflow(double availableWidth) + { + _overflowedItems.Clear(); + int shownCount = CountFittingItems(availableWidth); + + for (int i = 0; i < _items.Count; i++) + { + bool shown = i < shownCount; + _items[i].IsVisible = shown; + if (!shown) _overflowedItems.Add(_items[i]); + } + + if (OverflowControl is { } overflow) + overflow.IsVisible = _overflowedItems.Exists(item => item is not Separator); + } + + private int CountFittingItems(double availableWidth) + { + if (double.IsInfinity(availableWidth) || RowWidth() <= availableWidth + 0.5) return _items.Count; + + double budget = availableWidth + - (OverflowControl is { } overflow ? NaturalWidthOf(overflow) + Spacing : 0); + double used = 0; + int count = 0; + + foreach (var item in _items) + { + double itemWidth = NaturalWidthOf(item) + (count > 0 ? Spacing : 0); + if (used + itemWidth > budget) break; + used += itemWidth; + count++; + } + + while (count > 0 && _items[count - 1] is Separator) count--; + return count; + } +} diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 779fe3969f..eea42c3205 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -209,16 +209,20 @@ - - - - - - - + + + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs index 2f5c1dfaba..2838e07a58 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs @@ -41,6 +41,7 @@ public abstract partial class AbstractPackagesPage : UserControl, private double? _overlayRestingOffsetX; private static readonly SplineEasing FluentEntranceEasing = new(0.1, 0.9, 0.2, 1.0); private static readonly TimeSpan FilterAnimationDuration = TimeSpan.FromMilliseconds(300); + private readonly MenuFlyout _toolbarOverflowFlyout = new(); protected AbstractPackagesPage(PackagesPageData data) { @@ -96,6 +97,7 @@ or nameof(PackagesPageViewModel.SortAscending)) // Build the toolbar now that both AXAML controls and the ViewModel are ready GenerateToolBar(ViewModel); + InitializeToolbarOverflow(); // Double-click a list row → show details PackageList.DoubleTapped += (_, e) => @@ -162,14 +164,6 @@ or nameof(PackagesPageViewModel.SortAscending)) FilteringPanel.GetObservable(BoundsProperty) .SubscribeValue(bounds => OnFilteringPanelWidthChanged(bounds.Width)); - // Responsive: collapse the menu bar to icon-only on narrow windows so the - // toolbar buttons stay reachable instead of overflowing (mirrors WinUI). - this.GetObservable(BoundsProperty) - .SubscribeValue(bounds => UpdateToolbarLayout(bounds.Width)); - Loaded += (_, _) => Dispatcher.UIThread.Post( - () => UpdateToolbarLayout(Bounds.Width), - DispatcherPriority.Loaded); - // Grid/icons views: stretch cards to fill each row then reflow (mirrors WinUI's // UniformGridLayout) instead of leaving wasted space to the right. GridViewItems.GetObservable(BoundsProperty) @@ -238,10 +232,43 @@ private void UpdateIconCardWidth(double availableWidth) ViewModel.IconCardWidth = Math.Floor(availableWidth / columns); } - private void UpdateToolbarLayout(double availableWidth) + private void InitializeToolbarOverflow() { - if (availableWidth <= 0) return; - ViewModel.SetToolbarLabelsCollapsed(availableWidth < 900); + ToolBar.OverflowControl = ToolbarOverflowButton; + ToolBar.LabelCollapseRequested = ViewModel.SetToolbarLabelsCollapsed; + + ToolBar.Children.Remove(ToolbarOverflowButton); + foreach (var entry in ViewModel.ToolbarEntries) + ToolBar.Children.Add(entry.Control); + ToolBar.Children.Add(ToolbarOverflowButton); + + _toolbarOverflowFlyout.Opening += (_, _) => PopulateToolbarOverflowFlyout(); + ToolbarOverflowButton.Flyout = _toolbarOverflowFlyout; + } + + private void PopulateToolbarOverflowFlyout() + { + var items = new List(); + foreach (var control in ToolBar.OverflowedItems) + { + var entry = ViewModel.ToolbarEntries.FirstOrDefault(e => ReferenceEquals(e.Control, control)); + if (entry is null) continue; + + if (entry.Invoke is not { } invoke) + { + if (items.Count > 0 && items[^1] is not Separator) items.Add(new Separator()); + continue; + } + + var item = new MenuItem { Header = entry.Label, Icon = LoadMenuIcon(entry.IconName) }; + item.Click += (_, _) => invoke(); + items.Add(item); + } + + while (items.Count > 0 && items[^1] is Separator) items.RemoveAt(items.Count - 1); + + _toolbarOverflowFlyout.Items.Clear(); + foreach (var item in items) _toolbarOverflowFlyout.Items.Add(item); } // ─── UI-only: focus the package list ───────────────────────────────────── diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs index ebdd9b3331..0f827be4b9 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs @@ -109,11 +109,6 @@ protected override void GenerateToolBar(PackagesPageViewModel vm) ViewModel.AddToolbarButton("clipboard_list", CoreTools.Translate("Manage ignored updates"), () => vm.RequestManageIgnoredCommand.Execute(null)); ViewModel.AddToolbarSeparator(); - ViewModel.AddToolbarButton("sandclock", CoreTools.Translate("Automatically update selected packages"), - () => MarkForAutoUpdates(vm.FilteredPackages.GetCheckedPackages())); - ViewModel.AddToolbarButton("clipboard_list", CoreTools.Translate("Manage automatic updates"), - () => vm.RequestManageAutoUpdatesCommand.Execute(null)); - ViewModel.AddToolbarSeparator(); ViewModel.AddToolbarButton("save_as", CoreTools.Translate("Export to CSV"), () => _ = ExportPackagesToCsvAsync()); }