Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -195,7 +197,7 @@ partial void OnIsFilterPaneOpenChanged(bool value)
// ─── Collections ──────────────────────────────────────────────────────────
public ObservablePackageCollection FilteredPackages { get; } = new();
public AvaloniaList<SourceTreeNode> SourceNodes { get; } = new();
public AvaloniaList<object> ToolBarItems { get; } = new();
public List<ToolbarEntry> 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).
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand Down
227 changes: 227 additions & 0 deletions src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs
Original file line number Diff line number Diff line change
@@ -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<double> SpacingProperty =
AvaloniaProperty.Register<ToolbarOverflowPanel, double>(nameof(Spacing), 4.0);

static ToolbarOverflowPanel()
{
AffectsMeasure<ToolbarOverflowPanel>(SpacingProperty);
}

private readonly List<Control> _items = new();
private readonly List<Control> _overflowedItems = new();
private readonly Dictionary<Control, double> _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<bool>? LabelCollapseRequested { get; set; }

public IReadOnlyList<Control> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,20 @@
</StackPanel>

<!-- Toolbar (AppBarButtons added by subclass) -->
<ItemsControl x:Name="ToolBar"
Grid.Column="2"
HorizontalAlignment="Left"
ItemsSource="{Binding ToolBarItems}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<controls:ToolbarOverflowPanel x:Name="ToolBar"
Grid.Column="2"
Spacing="4"
ClipToBounds="True">
<Button x:Name="ToolbarOverflowButton"
Width="40" Height="40"
Padding="0"
CornerRadius="4"
automation:AutomationProperties.Name="{t:Translate More options}">
<TextBlock Text="···" FontSize="20"
HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="0,-4,0,0"/>
</Button>
</controls:ToolbarOverflowPanel>

</Grid>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
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)
{
Expand Down Expand Up @@ -96,6 +97,7 @@

// 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) =>
Expand Down Expand Up @@ -162,14 +164,6 @@
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)
Expand Down Expand Up @@ -238,10 +232,43 @@
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<object>();
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 ─────────────────────────────────────
Expand Down Expand Up @@ -795,7 +822,7 @@

// Guard against spreadsheet formula injection (CWE-1236): package metadata is
// external, and a value starting with one of these executes as a formula in Excel/Sheets.
if (field.Length > 0 && "=+-@\t\r".IndexOf(field[0]) >= 0)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / Linux (Avalonia)

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / Linux (NativeAOT)

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / Windows (Avalonia)

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / Windows (Avalonia)

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / Windows (NativeAOT)

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)

Check warning on line 825 in src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Use 'string.Contains' instead of 'string.IndexOf' to improve readability (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2249)
field = "'" + field;

if (field.IndexOfAny(['"', ',', '\n', '\r']) >= 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Comment thread
GabrielDuf marked this conversation as resolved.
}
Expand Down
Loading