diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 157d732d15..1d73e4505f 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -1,4 +1,13 @@
{
+ "View": "View",
+ "Window": "Window",
+ "Zoom": "Zoom",
+ "Undo": "Undo",
+ "Redo": "Redo",
+ "Cut": "Cut",
+ "Copy": "Copy",
+ "Paste": "Paste",
+ "Find": "Find",
"Catalog Editor": "Catalog Editor",
"Tiles": "Tiles",
"Rename catalog": "Rename catalog",
diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs
index 26d398000c..25451e23b7 100644
--- a/src/UniGetUI.Avalonia/App.axaml.cs
+++ b/src/UniGetUI.Avalonia/App.axaml.cs
@@ -35,9 +35,12 @@ public override void Initialize()
// Windows 11 Mica look is opt-in per environment: only merge the translucent
// surface overrides when Mica is actually usable (Win11 + transparency on).
- // macOS, Linux, Windows 10, and transparency-off all keep the solid Styles.Common look.
+ // macOS gets its own look (Styles.MacGlass*); Linux, Windows 10, and Windows with
+ // transparency off keep the solid Styles.Common look.
if (MicaWindowHelper.IsMicaEnabled())
ApplyWindowsMicaStyling();
+ else
+ MacGlassWindowHelper.Initialize(this);
#if AVALONIA_DIAGNOSTICS_ENABLED
this.AttachDeveloperTools();
#endif
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlass.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlass.axaml
new file mode 100644
index 0000000000..99ddc24835
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlass.axaml
@@ -0,0 +1,179 @@
+
+
+ 18
+ 10
+
+ White
+
+
+
+
+
+
+
+
+ 7
+ 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 2 12 0 #14000000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 6 20 0 #4D000000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlass.axaml.cs b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlass.axaml.cs
new file mode 100644
index 0000000000..aa1a904046
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlass.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace UniGetUI.Avalonia.Assets.Styles;
+
+public partial class MacGlassStyles : ResourceDictionary
+{
+ public MacGlassStyles()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlassControls.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlassControls.axaml
new file mode 100644
index 0000000000..5471f59d31
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlassControls.axaml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlassControls.axaml.cs b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlassControls.axaml.cs
new file mode 100644
index 0000000000..4743add760
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacGlassControls.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Markup.Xaml;
+using AvaloniaStyles = Avalonia.Styling.Styles;
+
+namespace UniGetUI.Avalonia.Assets.Styles;
+
+public partial class MacGlassControlStyles : AvaloniaStyles
+{
+ public MacGlassControlStyles()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacSolid.axaml b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacSolid.axaml
new file mode 100644
index 0000000000..03f9505fa5
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacSolid.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacSolid.axaml.cs b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacSolid.axaml.cs
new file mode 100644
index 0000000000..2ecaa8b7ed
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Assets/Styles/Styles.MacSolid.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace UniGetUI.Avalonia.Assets.Styles;
+
+public partial class MacSolidStyles : ResourceDictionary
+{
+ public MacSolidStyles()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/MacGlassWindowHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/MacGlassWindowHelper.cs
new file mode 100644
index 0000000000..d3f2c547f2
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/MacGlassWindowHelper.cs
@@ -0,0 +1,341 @@
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using UniGetUI.Avalonia.Assets.Styles;
+using UniGetUI.Core.Logging;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+/// macOS look: Styles.MacGlass* plus native vibrancy (AcrylicBlur) behind the sidebar and menus.
+/// Under Reduce transparency or Increase contrast the look stays, with solid surfaces instead.
+internal static partial class MacGlassWindowHelper
+{
+ /// Class set on a window while the macOS look is active; layout and styles key off it.
+ public const string GLASS_CLASS = "mac-glass";
+
+ private const string ObjCRuntime = "/usr/lib/libobjc.A.dylib";
+
+ // AppKit NSVisualEffectMaterial / NSVisualEffectBlendingMode / NSVisualEffectState values.
+ private const long NSVisualEffectMaterialSidebar = 7;
+ private const long NSVisualEffectMaterialMenu = 5;
+ private const long NSVisualEffectStateActive = 1;
+ private const long NSVisualEffectBlendingModeBehindWindow = 0;
+
+ private static bool? _solidPreferred;
+ private static bool _increaseContrast;
+ private static bool _vibrancyRefused;
+ private static Window? _window;
+
+ /// True on macOS: the macOS layout and control styles apply.
+ public static bool IsGlassEnabled() => OperatingSystem.IsMacOS();
+
+ /// True when the see-through vibrancy is used; false means solid surfaces.
+ public static bool IsVibrancyEnabled()
+ => IsGlassEnabled() && !_vibrancyRefused && !IsSolidPreferred();
+
+ /// Merges the macOS styles and hooks popups. Call once from App.Initialize.
+ public static void Initialize(Application app)
+ {
+ if (!IsGlassEnabled())
+ return;
+
+ app.Resources.MergedDictionaries.Add(new MacGlassStyles());
+ app.Styles.Add(new MacGlassControlStyles());
+ UpdateSolidOverrides(app);
+ UpdateAccentForeground(app);
+ app.ActualThemeVariantChanged += (_, _) => UpdateAccentForeground(app);
+ if (app.PlatformSettings is { } platform)
+ platform.ColorValuesChanged += (_, _) => UpdateAccentForeground(app);
+ Control.LoadedEvent.AddClassHandler((root, _) => ApplyToPopup(root));
+ }
+
+ /// Applies the macOS look to the main window. Call before the window is shown.
+ public static void Apply(Window window)
+ {
+ if (!IsGlassEnabled())
+ return;
+
+ _window = window;
+ window.Classes.Add(GLASS_CLASS);
+ ApplyVibrancyState(window);
+ window.Opened += (_, _) => VerifyVibrancy(window);
+ // Accessibility display settings can change while the app runs; pick them up on activation.
+ window.Activated += (_, _) => RefreshAccessibilityPreferences();
+ }
+
+ private static void ApplyVibrancyState(Window window)
+ {
+ // Drop the solid-mode resource binding (or a previous value) before switching.
+ window.ClearValue(Window.BackgroundProperty);
+ if (IsVibrancyEnabled())
+ {
+ window.TransparencyLevelHint = new[] { WindowTransparencyLevel.AcrylicBlur };
+ window.Background = Brushes.Transparent;
+ Dispatcher.UIThread.Post(() => UseSidebarMaterial(window));
+ }
+ else
+ {
+ window.TransparencyLevelHint = new[] { WindowTransparencyLevel.None };
+ window.Bind(Window.BackgroundProperty, window.GetResourceObservable("MacGlassWindowBackground"));
+ }
+ }
+
+ // A transparent window without a granted backdrop shows whatever is behind it unblurred,
+ // so switch to solid surfaces for the rest of the session if the request was refused.
+ private static void VerifyVibrancy(Window window)
+ {
+ if (!IsVibrancyEnabled() || window.ActualTransparencyLevel == WindowTransparencyLevel.AcrylicBlur)
+ return;
+
+ Logger.Warn($"macOS vibrancy was not granted (got {window.ActualTransparencyLevel}); using solid surfaces");
+ _vibrancyRefused = true;
+ ApplyVibrancyState(window);
+ if (Application.Current is { } app)
+ UpdateSolidOverrides(app);
+ }
+
+ private static void RefreshAccessibilityPreferences()
+ {
+ bool before = IsVibrancyEnabled();
+ bool contrastBefore = _increaseContrast;
+ _solidPreferred = null;
+ if (IsVibrancyEnabled() == before)
+ {
+ if (_increaseContrast != contrastBefore && Application.Current is { } current)
+ UpdateAccentForeground(current);
+ return;
+ }
+
+ if (Application.Current is { } app)
+ {
+ UpdateSolidOverrides(app);
+ UpdateAccentForeground(app);
+ }
+ if (_window is { } window)
+ {
+ ApplyVibrancyState(window);
+ // The backdrop request is re-made, so confirm it was granted once it settles.
+ Dispatcher.UIThread.Post(() => VerifyVibrancy(window), DispatcherPriority.Background);
+ }
+ }
+
+ private static void UpdateSolidOverrides(Application app)
+ {
+ var dictionaries = app.Resources.MergedDictionaries;
+ int existing = -1;
+ for (int i = 0; i < dictionaries.Count; i++)
+ {
+ if (dictionaries[i] is MacSolidStyles)
+ existing = i;
+ }
+
+ if (IsVibrancyEnabled() && existing >= 0)
+ dictionaries.RemoveAt(existing);
+ else if (!IsVibrancyEnabled() && existing < 0)
+ dictionaries.Add(new MacSolidStyles());
+ }
+
+ // Text drawn on the accent is white unless that falls below 3:1 against it (the WCAG minimum for
+ // UI components and bold text; 4.5:1 under Increase Contrast), as with orange, yellow or green.
+ private static void UpdateAccentForeground(Application app)
+ {
+ if (!app.TryFindResource("SystemAccentColor", app.ActualThemeVariant, out object? value)
+ || value is not Color accent)
+ return;
+
+ double minimum = _increaseContrast ? 4.5 : 3.0;
+ app.Resources["MacAccentForegroundColor"] = ContrastRatio(Colors.White, accent) >= minimum
+ ? Colors.White
+ : Color.FromRgb(0x1D, 0x1D, 0x1F);
+ }
+
+ private static double ContrastRatio(Color a, Color b)
+ {
+ double la = RelativeLuminance(a), lb = RelativeLuminance(b);
+ return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05);
+ }
+
+ // WCAG 2 relative luminance of an sRGB colour.
+ private static double RelativeLuminance(Color color)
+ {
+ static double Channel(byte value)
+ {
+ double c = value / 255.0;
+ return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
+ }
+
+ return 0.2126 * Channel(color.R) + 0.7152 * Channel(color.G) + 0.0722 * Channel(color.B);
+ }
+
+ private static void ApplyToPopup(PopupRoot root)
+ {
+ // Tooltips keep their plain opaque look, as on macOS; without vibrancy menus stay solid.
+ if (!IsVibrancyEnabled() || root.GetVisualDescendants().Any(c => c is ToolTip))
+ return;
+
+ root.TransparencyLevelHint = new[] { WindowTransparencyLevel.AcrylicBlur };
+ root.Background = Brushes.Transparent;
+
+ // Avalonia (re)creates its blur view while applying the hint, so style it once that settled.
+ Dispatcher.UIThread.Post(() =>
+ {
+ try
+ {
+ double radius = root.TryFindResource("MacGlassMenuCornerRadius", out object? r) && r is CornerRadius cr
+ ? cr.TopLeft
+ : 10;
+ StyleBehindWindowEffectViews(GetNSWindow(root), NSVisualEffectMaterialMenu, radius);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn("Could not apply the macOS menu material to a popup");
+ Logger.Warn(ex);
+ }
+ });
+ }
+
+ // Avalonia creates its behind-window NSVisualEffectView with the deprecated, always-light
+ // NSVisualEffectMaterialLight. The sidebar material follows the window's light/dark appearance.
+ private static void UseSidebarMaterial(Window window)
+ {
+ if (!IsVibrancyEnabled())
+ return;
+
+ try
+ {
+ StyleBehindWindowEffectViews(GetNSWindow(window), NSVisualEffectMaterialSidebar, 0);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn("Could not switch the macOS vibrancy material; keeping Avalonia's default");
+ Logger.Warn(ex);
+ }
+ }
+
+ // Sets the material of the window's behind-window NSVisualEffectViews; a non-zero radius also
+ // rounds them (always-active, like native menus) and re-derives the window shadow from that shape.
+ private static void StyleBehindWindowEffectViews(nint nsWindow, long material, double cornerRadius)
+ {
+ if (nsWindow == 0)
+ return;
+
+ nint effectViewClass = objc_getClass("NSVisualEffectView");
+ nint contentView = objc_msgSend_IntPtr(nsWindow, Sel.ContentView);
+ nint subviews = objc_msgSend_IntPtr(contentView, Sel.Subviews);
+ long count = objc_msgSend_IntPtr(subviews, Sel.Count);
+
+ for (long i = 0; i < count; i++)
+ {
+ nint view = objc_msgSend_IntPtr_Long(subviews, Sel.ObjectAtIndex, i);
+ if (!objc_msgSend_Bool_IntPtr(view, Sel.IsKindOfClass, effectViewClass)
+ || objc_msgSend_IntPtr(view, Sel.BlendingMode) != NSVisualEffectBlendingModeBehindWindow)
+ continue;
+
+ objc_msgSend_IntPtr_Long(view, Sel.SetMaterial, material);
+ if (cornerRadius <= 0)
+ continue;
+
+ objc_msgSend_IntPtr_Long(view, Sel.SetState, NSVisualEffectStateActive);
+ objc_msgSend_IntPtr_Long(view, Sel.SetWantsLayer, 1);
+ nint layer = objc_msgSend_IntPtr(view, Sel.Layer);
+ if (layer != 0)
+ {
+ objc_msgSend_IntPtr_Double(layer, Sel.SetCornerRadius, cornerRadius);
+ objc_msgSend_IntPtr_Long(layer, Sel.SetMasksToBounds, 1);
+ }
+ }
+
+ if (cornerRadius > 0)
+ {
+ objc_msgSend_IntPtr_Long(nsWindow, Sel.SetHasShadow, 1);
+ objc_msgSend_IntPtr(nsWindow, Sel.InvalidateShadow);
+ }
+ }
+
+ private static nint GetNSWindow(TopLevel window)
+ {
+ if (window.TryGetPlatformHandle() is not { Handle: not 0 } handle)
+ return 0;
+
+ return handle.HandleDescriptor == "NSWindow"
+ ? handle.Handle
+ : objc_msgSend_IntPtr(handle.Handle, Sel.Window);
+ }
+
+ // NSWorkspace's Reduce transparency and Increase contrast settings; cleared on window activation.
+ private static bool IsSolidPreferred()
+ {
+ if (_solidPreferred is { } cached)
+ return cached;
+
+ bool solid = false;
+ try
+ {
+ nint workspace = objc_msgSend_IntPtr(objc_getClass("NSWorkspace"), Sel.SharedWorkspace);
+ if (workspace != 0)
+ {
+ _increaseContrast = objc_msgSend_Bool(workspace, Sel.AccessibilityDisplayShouldIncreaseContrast);
+ solid = _increaseContrast
+ || objc_msgSend_Bool(workspace, Sel.AccessibilityDisplayShouldReduceTransparency);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn("Could not read the macOS transparency and contrast settings; assuming they are off");
+ Logger.Warn(ex);
+ }
+
+ _solidPreferred = solid;
+ return solid;
+ }
+
+ // Selectors used on every popup and window, registered once on first use (macOS only).
+ private static class Sel
+ {
+ public static readonly nint AccessibilityDisplayShouldIncreaseContrast = sel_registerName("accessibilityDisplayShouldIncreaseContrast");
+ public static readonly nint AccessibilityDisplayShouldReduceTransparency = sel_registerName("accessibilityDisplayShouldReduceTransparency");
+ public static readonly nint BlendingMode = sel_registerName("blendingMode");
+ public static readonly nint ContentView = sel_registerName("contentView");
+ public static readonly nint Count = sel_registerName("count");
+ public static readonly nint InvalidateShadow = sel_registerName("invalidateShadow");
+ public static readonly nint IsKindOfClass = sel_registerName("isKindOfClass:");
+ public static readonly nint Layer = sel_registerName("layer");
+ public static readonly nint ObjectAtIndex = sel_registerName("objectAtIndex:");
+ public static readonly nint SetCornerRadius = sel_registerName("setCornerRadius:");
+ public static readonly nint SetHasShadow = sel_registerName("setHasShadow:");
+ public static readonly nint SetMasksToBounds = sel_registerName("setMasksToBounds:");
+ public static readonly nint SetMaterial = sel_registerName("setMaterial:");
+ public static readonly nint SetState = sel_registerName("setState:");
+ public static readonly nint SetWantsLayer = sel_registerName("setWantsLayer:");
+ public static readonly nint SharedWorkspace = sel_registerName("sharedWorkspace");
+ public static readonly nint Subviews = sel_registerName("subviews");
+ public static readonly nint Window = sel_registerName("window");
+ }
+
+ [LibraryImport(ObjCRuntime, StringMarshalling = StringMarshalling.Utf8)]
+ private static partial nint objc_getClass(string name);
+
+ [LibraryImport(ObjCRuntime, StringMarshalling = StringMarshalling.Utf8)]
+ private static partial nint sel_registerName(string name);
+
+ [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")]
+ private static partial nint objc_msgSend_IntPtr(nint receiver, nint selector);
+
+ [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")]
+ private static partial nint objc_msgSend_IntPtr_Long(nint receiver, nint selector, long arg);
+
+ [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")]
+ private static partial nint objc_msgSend_IntPtr_Double(nint receiver, nint selector, double arg);
+
+ [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")]
+ [return: MarshalAs(UnmanagedType.U1)]
+ private static partial bool objc_msgSend_Bool(nint receiver, nint selector);
+
+ [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")]
+ [return: MarshalAs(UnmanagedType.U1)]
+ private static partial bool objc_msgSend_Bool_IntPtr(nint receiver, nint selector, nint arg);
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/PlatformShortcuts.cs b/src/UniGetUI.Avalonia/Infrastructure/PlatformShortcuts.cs
new file mode 100644
index 0000000000..656cd986d7
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/PlatformShortcuts.cs
@@ -0,0 +1,27 @@
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.LogicalTree;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+/// Keyboard-shortcut conventions per platform: ⌘ (Meta) on macOS, Ctrl elsewhere.
+internal static class PlatformShortcuts
+{
+ public static KeyModifiers Primary => OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control;
+
+ public static bool HasPrimary(KeyModifiers modifiers) => modifiers.HasFlag(Primary);
+
+ /// On macOS, rewrites "Ctrl+…" accessibility accelerator keys under to "Cmd+…".
+ public static void AdaptAcceleratorKeys(Control root)
+ {
+ if (!OperatingSystem.IsMacOS())
+ return;
+
+ foreach (var control in root.GetLogicalDescendants().OfType().Prepend(root))
+ {
+ if (AutomationProperties.GetAcceleratorKey(control) is { } key && key.StartsWith("Ctrl+", StringComparison.Ordinal))
+ AutomationProperties.SetAcceleratorKey(control, "Cmd+" + key["Ctrl+".Length..]);
+ }
+ }
+}
diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs
index a834ea28a6..cef3dd5810 100644
--- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs
@@ -388,8 +388,13 @@ public void SetToolbarLabelsCollapsed(bool collapsed)
}
/// Adds a thin vertical separator to the toolbar.
- public void AddToolbarSeparator(int index = -1)
+ /// Separates closely related actions; the macOS glass toolbar omits it so
+ /// they share one capsule.
+ public void AddToolbarSeparator(int index = -1, bool subgroup = false)
{
+ if (subgroup && MacGlassWindowHelper.IsGlassEnabled())
+ return;
+
object? borderResource = null;
Application.Current?.Resources.TryGetResource(
"AppBorderBrush",
diff --git a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml
index 813d09c88a..e1434f3334 100644
--- a/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml
+++ b/src/UniGetUI.Avalonia/Views/Controls/InfoBar.axaml
@@ -7,6 +7,9 @@
x:DataType="vm:InfoBarViewModel">
+
+
+
+
+
_border.CornerRadius;
- set => _border.CornerRadius = value;
+ set => _border.CornerRadius = AdaptCorners(value);
}
+ // macOS grouped forms use rounder sections, so the standard 8px card corners widen to 12px there.
+ private static CornerRadius AdaptCorners(CornerRadius radius)
+ => MacGlassWindowHelper.IsGlassEnabled()
+ ? new CornerRadius(Widen(radius.TopLeft), Widen(radius.TopRight), Widen(radius.BottomRight), Widen(radius.BottomLeft))
+ : radius;
+
+ private static double Widen(double corner) => corner == 8 ? 12 : corner;
+
public new Thickness BorderThickness
{
get => _border.BorderThickness;
@@ -231,7 +240,7 @@ public SettingsCard()
_border = new Border
{
- CornerRadius = new CornerRadius(8),
+ CornerRadius = AdaptCorners(new CornerRadius(8)),
BorderThickness = new Thickness(1),
Child = _layoutGrid,
};
diff --git a/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs b/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs
index 6ac6df4ec5..11f0620d80 100644
--- a/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs
+++ b/src/UniGetUI.Avalonia/Views/Controls/ToolbarOverflowPanel.cs
@@ -1,6 +1,8 @@
+using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
+using Avalonia.Media;
using Avalonia.VisualTree;
namespace UniGetUI.Avalonia.Views.Controls;
@@ -10,9 +12,57 @@ public sealed class ToolbarOverflowPanel : Panel
public static readonly StyledProperty SpacingProperty =
AvaloniaProperty.Register(nameof(Spacing), 4.0);
+ /// Keeps labels collapsed at every width (icon-only toolbar).
+ public static readonly StyledProperty AlwaysCollapseLabelsProperty =
+ AvaloniaProperty.Register(nameof(AlwaysCollapseLabels));
+
+ /// When set, each run of items between separators is drawn on a capsule of this brush.
+ public static readonly StyledProperty GroupBackgroundProperty =
+ AvaloniaProperty.Register(nameof(GroupBackground));
+
+ public static readonly StyledProperty GroupBorderBrushProperty =
+ AvaloniaProperty.Register(nameof(GroupBorderBrush));
+
+ public static readonly StyledProperty GroupCornerRadiusProperty =
+ AvaloniaProperty.Register(nameof(GroupCornerRadius));
+
+ public static readonly StyledProperty GroupVerticalInsetProperty =
+ AvaloniaProperty.Register(nameof(GroupVerticalInset));
+
static ToolbarOverflowPanel()
{
- AffectsMeasure(SpacingProperty);
+ AffectsMeasure(SpacingProperty, AlwaysCollapseLabelsProperty);
+ }
+
+ // Panel.Render is sealed, so the group capsules come from a visual child kept behind the items.
+ private readonly GroupBackdrop _backdrop;
+
+ public ToolbarOverflowPanel()
+ {
+ _backdrop = new GroupBackdrop(this) { ZIndex = -1, IsHitTestVisible = false };
+ VisualChildren.Add(_backdrop);
+ }
+
+ // Panel mirrors Children indices onto VisualChildren, so the backdrop (not one of the Children)
+ // must stay last for those indices to keep pointing at the matching visual.
+ protected override void ChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ base.ChildrenChanged(sender, e);
+ if (VisualChildren.Count > 0 && VisualChildren[^1] != _backdrop)
+ {
+ VisualChildren.Remove(_backdrop);
+ VisualChildren.Add(_backdrop);
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == GroupBackgroundProperty
+ || change.Property == GroupBorderBrushProperty
+ || change.Property == GroupCornerRadiusProperty
+ || change.Property == GroupVerticalInsetProperty)
+ _backdrop.InvalidateVisual();
}
private readonly List _items = new();
@@ -27,6 +77,36 @@ public double Spacing
set => SetValue(SpacingProperty, value);
}
+ public bool AlwaysCollapseLabels
+ {
+ get => GetValue(AlwaysCollapseLabelsProperty);
+ set => SetValue(AlwaysCollapseLabelsProperty, value);
+ }
+
+ public IBrush? GroupBackground
+ {
+ get => GetValue(GroupBackgroundProperty);
+ set => SetValue(GroupBackgroundProperty, value);
+ }
+
+ public IBrush? GroupBorderBrush
+ {
+ get => GetValue(GroupBorderBrushProperty);
+ set => SetValue(GroupBorderBrushProperty, value);
+ }
+
+ public double GroupCornerRadius
+ {
+ get => GetValue(GroupCornerRadiusProperty);
+ set => SetValue(GroupCornerRadiusProperty, value);
+ }
+
+ public double GroupVerticalInset
+ {
+ get => GetValue(GroupVerticalInsetProperty);
+ set => SetValue(GroupVerticalInsetProperty, value);
+ }
+
public Control? OverflowControl { get; set; }
public Action? LabelCollapseRequested { get; set; }
@@ -73,6 +153,8 @@ protected override Size ArrangeOverride(Size finalSize)
overflow.Arrange(new Rect(x, 0, overflowWidth, overflowWidth > 0 ? finalSize.Height : 0));
}
+ _backdrop.Arrange(new Rect(finalSize));
+ _backdrop.InvalidateVisual();
return finalSize;
}
@@ -148,7 +230,14 @@ private double UsedWidth()
private bool UpdateLabelState(double availableWidth)
{
- if (double.IsInfinity(availableWidth) || availableWidth <= 1 || LabelCollapseRequested is null) return false;
+ if (LabelCollapseRequested is null) return false;
+ if (AlwaysCollapseLabels)
+ {
+ if (_labelsCollapsed) return false;
+ ApplyLabelState(true);
+ return true;
+ }
+ if (double.IsInfinity(availableWidth) || availableWidth <= 1) return false;
double rowWidth = RowWidth();
@@ -224,4 +313,53 @@ private int CountFittingItems(double availableWidth)
while (count > 0 && _items[count - 1] is Separator) count--;
return count;
}
+
+ private sealed class GroupBackdrop(ToolbarOverflowPanel owner) : Control
+ {
+ private Pen? _rimPen;
+
+ public override void Render(DrawingContext context)
+ {
+ if (owner.GroupBackground is not { } brush)
+ return;
+
+ // One capsule per run of visible items between separators; the overflow button gets its own.
+ Rect? group = null;
+ foreach (var child in owner.Children)
+ {
+ if (!child.IsVisible || child.Bounds.Width <= 0 || child == owner.OverflowControl)
+ continue;
+ if (child is Separator)
+ {
+ DrawGroup(context, brush, group);
+ group = null;
+ continue;
+ }
+ group = group is { } current ? current.Union(child.Bounds) : child.Bounds;
+ }
+ DrawGroup(context, brush, group);
+ if (owner.OverflowControl is { IsVisible: true } overflow && overflow.Bounds.Width > 0)
+ DrawGroup(context, brush, overflow.Bounds);
+ }
+
+ private void DrawGroup(DrawingContext context, IBrush brush, Rect? group)
+ {
+ if (group is not { } rect)
+ return;
+ double radius = owner.GroupCornerRadius;
+ IPen? rim = RimPen();
+ // Inset half the pen width so the 1px rim stays inside the capsule.
+ Rect capsule = rect.Deflate(new Thickness(0, owner.GroupVerticalInset)).Deflate(rim is null ? 0 : 0.5);
+ context.DrawRectangle(brush, rim, capsule, radius, radius);
+ }
+
+ private Pen? RimPen()
+ {
+ if (owner.GroupBorderBrush is not { } border)
+ return null;
+ if (_rimPen?.Brush != border)
+ _rimPen = new Pen(border, 1);
+ return _rimPen;
+ }
+ }
}
diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs
index fc5fa38945..9a595e47ae 100644
--- a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs
@@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Threading;
+using UniGetUI.Avalonia.Infrastructure;
using UniGetUI.Avalonia.ViewModels;
using UniGetUI.PackageEngine.Enums;
using UniGetUI.PackageEngine.Interfaces;
@@ -17,6 +18,7 @@ public InstallOptionsWindow(IPackage package, OperationType operation, InstallOp
var vm = new InstallOptionsViewModel(package, operation, options);
DataContext = vm;
InitializeComponent();
+ PlatformShortcuts.AdaptAcceleratorKeys(this);
vm.CloseRequested += (_, _) => Close();
}
diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs b/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs
new file mode 100644
index 0000000000..39ead962ff
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Views/MainWindow.MacMenu.cs
@@ -0,0 +1,181 @@
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.VisualTree;
+using AvaloniaEdit;
+using UniGetUI.Core.Tools;
+
+namespace UniGetUI.Avalonia.Views;
+
+// macOS menu bar. Apple expects every toolbar action to also be a menu command with its ⌘ shortcut;
+// items reuse the sidebar commands and the same page actions as the keyboard shortcuts.
+public partial class MainWindow
+{
+ // Each item paired with when it applies; inapplicable items are greyed out, as in native menus.
+ private readonly List<(NativeMenuItem Item, Func IsEnabled)> _macMenuStates = [];
+
+ // Text target resolved once per refresh, so the Edit predicates don't each walk the tree.
+ private TextBox? _menuTextBox;
+ private TextEditor? _menuEditor;
+
+ private void SetupMacMenuBar()
+ {
+ if (!OperatingSystem.IsMacOS())
+ return;
+
+ var sidebar = ViewModel.Sidebar;
+ ICommand navigate = sidebar.RequestNavigationCommand;
+
+ // Application menu: insert ahead of the Services, Hide and Quit items (and their leading separator).
+ if (Application.Current is { } app && NativeMenu.GetMenu(app) is { } appMenu)
+ {
+ NativeMenuItemBase[] appItems =
+ [
+ Item(CoreTools.Translate("About UniGetUI"), navigate, "About"),
+ new NativeMenuItemSeparator(),
+ Item(CoreTools.Translate("Settings") + "…", navigate, "Settings", Key.OemComma),
+ Item(CoreTools.Translate("Check for UniGetUI updates") + "…", sidebar.CheckForUpdatesCommand),
+ ];
+ for (int i = 0; i < appItems.Length; i++)
+ appMenu.Items.Insert(i, appItems[i]);
+ RefreshOnDemand(appMenu);
+ }
+
+ // Text commands apply to a focused text field or log editor, including inside dialogs.
+ var edit = Submenu(CoreTools.Translate("Edit"),
+ TextAction(CoreTools.Translate("Undo"), t => t.Undo(), e => e.Undo(),
+ t => t.CanUndo, e => e.CanUndo, Key.Z),
+ TextAction(CoreTools.Translate("Redo"), t => t.Redo(), e => e.Redo(),
+ t => t.CanRedo, e => e.CanRedo, Key.Z, KeyModifiers.Shift),
+ new NativeMenuItemSeparator(),
+ TextAction(CoreTools.Translate("Cut"), t => t.Cut(), e => e.Cut(),
+ t => t.CanCut, e => !e.IsReadOnly && e.SelectionLength > 0, Key.X),
+ TextAction(CoreTools.Translate("Copy"), t => t.Copy(), e => e.Copy(),
+ t => t.CanCopy, e => e.SelectionLength > 0, Key.C),
+ TextAction(CoreTools.Translate("Paste"), t => t.Paste(), e => e.Paste(),
+ t => t.CanPaste, e => !e.IsReadOnly, Key.V),
+ Action(CoreTools.Translate("Select all"), SelectAllForFocus, Key.A,
+ enabled: () => _menuTextBox is not null || _menuEditor is not null || PageShortcutListener is not null),
+ new NativeMenuItemSeparator(),
+ Action(CoreTools.Translate("Find"), TriggerPageSearch, Key.F, enabled: () => PageShortcutListener is not null));
+
+ var view = Submenu(CoreTools.Translate("View"),
+ Item(CoreTools.Translate("Discover Packages"), navigate, "Discover", Key.D1),
+ Item(CoreTools.Translate("Software Updates"), navigate, "Updates", Key.D2),
+ Item(CoreTools.Translate("Installed Packages"), navigate, "Installed", Key.D3),
+ Item(CoreTools.Translate("Package Bundles"), navigate, "Bundles", Key.D4),
+ Item(CoreTools.Translate("Package Managers"), navigate, "Managers", Key.D6),
+ new NativeMenuItemSeparator(),
+ Item(CoreTools.Translate("Toggle navigation panel"), ViewModel.ToggleSidebarCommand, null, Key.S, KeyModifiers.Control),
+ new NativeMenuItemSeparator(),
+ Action(CoreTools.Translate("Reload"), TriggerPageReload, Key.R, enabled: () => PageShortcutListener is not null),
+ Action(CoreTools.Translate("Package details"), TriggerPageDetails, Key.I, enabled: () => PageShortcutListener is not null));
+
+ // macOS greys Minimize and Zoom out for a window in full screen.
+ var window = Submenu(CoreTools.Translate("Window"),
+ Action(CoreTools.Translate("Minimize"), () => WindowState = WindowState.Minimized, Key.M,
+ enabled: () => WindowState != WindowState.FullScreen),
+ Action(CoreTools.Translate("Zoom"), () => WindowState =
+ WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized,
+ enabled: () => WindowState != WindowState.FullScreen));
+
+ var help = Submenu(CoreTools.Translate("Help"),
+ Item(CoreTools.Translate("Help"), navigate, "Help"),
+ Item(CoreTools.Translate("Release notes"), navigate, "ReleaseNotes"),
+ new NativeMenuItemSeparator(),
+ Item(CoreTools.Translate("UniGetUI Log"), navigate, "OwnLog"),
+ Item(CoreTools.Translate("Package Manager logs"), navigate, "ManagerLog"),
+ Item(CoreTools.Translate("Operation history"), navigate, "OperationHistory"));
+
+ NativeMenu.SetMenu(this, new NativeMenu { Items = { edit, view, window, help } });
+ UpdateMacMenuState();
+ }
+
+ // States are computed as a menu shows. For ⌘ shortcuts a stale disabled item lets the key reach the
+ // focused control, and a click re-checks the live state, so the outcome is right either way.
+ private void RefreshOnDemand(NativeMenu menu)
+ {
+ menu.NeedsUpdate += (_, _) => UpdateMacMenuState();
+ menu.Opening += (_, _) => UpdateMacMenuState();
+ }
+
+ private void UpdateMacMenuState()
+ {
+ (_menuTextBox, _menuEditor) = ResolveTextTarget();
+ foreach (var (item, isEnabled) in _macMenuStates)
+ item.IsEnabled = isEnabled();
+ }
+
+ // The focused text box or AvaloniaEdit editor; while a dialog is open, only one inside it.
+ private (TextBox? TextBox, TextEditor? Editor) ResolveTextTarget()
+ {
+ if (FocusManager?.GetFocusedElement() is not Visual focused
+ || (ModalLayer.IsVisible && !ModalLayer.IsVisualAncestorOf(focused)))
+ return (null, null);
+
+ return focused.FindAncestorOfType(includeSelf: true) is { } textBox
+ ? (textBox, null)
+ : (null, focused.FindAncestorOfType(includeSelf: true));
+ }
+
+ // ⌘A selects text in a focused field or editor, and every package on a package page otherwise.
+ private void SelectAllForFocus()
+ {
+ var (textBox, editor) = ResolveTextTarget();
+ if (textBox is not null)
+ textBox.SelectAll();
+ else if (editor is not null)
+ editor.SelectAll();
+ else
+ TriggerPageSelectAll();
+ }
+
+ // Routes a text command to the focused text box, or to the AvaloniaEdit log/JSON editor.
+ private NativeMenuItem TextAction(string header, Action onTextBox, Action onEditor,
+ Func canTextBox, Func canEditor,
+ Key key, KeyModifiers extraModifiers = KeyModifiers.None)
+ => Action(header, () =>
+ {
+ var (textBox, editor) = ResolveTextTarget();
+ if (textBox is not null)
+ onTextBox(textBox);
+ else if (editor is not null)
+ onEditor(editor);
+ }, key, extraModifiers, enabled: () => _menuTextBox is { } textBox ? canTextBox(textBox)
+ : _menuEditor is { } editor && canEditor(editor));
+
+ private NativeMenuItem Submenu(string header, params NativeMenuItemBase[] items)
+ {
+ var menu = new NativeMenu();
+ foreach (var item in items)
+ menu.Items.Add(item);
+ RefreshOnDemand(menu);
+ return new NativeMenuItem(header) { Menu = menu };
+ }
+
+ private NativeMenuItem Item(string header, ICommand command, string? parameter = null,
+ Key? key = null, KeyModifiers extraModifiers = KeyModifiers.None)
+ => Action(header, () => command.Execute(parameter), key, extraModifiers,
+ enabled: () => !ModalLayer.IsVisible && command.CanExecute(parameter));
+
+ // By default, like Window_KeyDown, a command doesn't apply while an in-window dialog is open.
+ // Clicks re-check the rule, since the state may have changed since the last refresh.
+ private NativeMenuItem Action(string header, Action action, Key? key = null,
+ KeyModifiers extraModifiers = KeyModifiers.None, Func? enabled = null)
+ {
+ Func isEnabled = enabled ?? (() => !ModalLayer.IsVisible);
+ var item = new NativeMenuItem(header)
+ {
+ Gesture = key is { } k ? new KeyGesture(k, KeyModifiers.Meta | extraModifiers) : null,
+ };
+ item.Click += (_, _) =>
+ {
+ (_menuTextBox, _menuEditor) = ResolveTextTarget();
+ if (isEnabled())
+ action();
+ };
+ _macMenuStates.Add((item, isEnabled));
+ return item;
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml
index 675c0d1392..0062e7d6d7 100644
--- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml
+++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml
@@ -22,6 +22,7 @@
@@ -58,6 +59,7 @@
value, which would beat the .open setter). -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -649,7 +712,8 @@
automation:AutomationProperties.AccessibilityView="Raw"
Data="M0,1 H16 M0,7 H16 M0,13 H16"/>
-
@@ -663,12 +727,11 @@
VerticalAlignment="Center"
Margin="0,0,40,0"
Height="32"
- CornerRadius="5"
BorderThickness="1"
ClipToBounds="True"
automation:AutomationProperties.AccessibilityView="Control">
-
+
- ModalLayer.IsVisible ? null : ViewModel.CurrentPageContent as IKeyboardShortcutListener;
+
+ private void TriggerPageReload() => PageShortcutListener?.ReloadTriggered();
+
+ private void TriggerPageSearch() => PageShortcutListener?.SearchTriggered();
+
+ private void TriggerPageSelectAll() => PageShortcutListener?.SelectAllTriggered();
+
+ private void TriggerPageDetails() => PageShortcutListener?.DetailsTriggered();
+
private void OnCurrentPageChanged(object? sender, PageType pageType)
{
// Like WinUI's NavigationView: picking a page collapses the sliding flyout back to the rail.
@@ -574,7 +609,9 @@ private void SetupTitleBar()
// traffic lights' centre line (see ApplyMacTitleBarLayout / MAC_TRAFFIC_LIGHT_*).
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = 44;
+ MacGlassWindowHelper.Apply(this);
ApplyMacTitleBarControlSizes();
+ ApplyMacTitleBarChrome();
// Drive height/inset from code rather than the AXAML bindings: in fullscreen the native
// title bar is hidden and WindowDecorationMargin collapses to 0, which would clip the
@@ -595,7 +632,7 @@ void ApplyMacTitleBarLayout()
}
TitleBarGrid.Height = _macTitleBarHeight;
- MainContentRoot.Margin = new Thickness(0, _macTitleBarHeight, 0, 0);
+ ApplyMacSidebarLayout();
if (fullScreen)
{
@@ -619,6 +656,17 @@ void ApplyMacTitleBarLayout()
this.GetObservable(WindowStateProperty).SubscribeValue(_ => ApplyMacTitleBarLayout());
this.GetObservable(WindowDecorationMarginProperty).SubscribeValue(_ => ApplyMacTitleBarLayout());
+ NavRail.GetObservable(IsVisibleProperty).SubscribeValue(_ => ApplyMacSidebarLayout());
+ NavDock.GetObservable(IsVisibleProperty).SubscribeValue(_ => ApplyMacSidebarLayout());
+ SidebarGlass.GetObservable(BoundsProperty).SubscribeValue(_ => UpdateMacWindowSurfaceClip());
+ NavFlyout.GetObservable(BoundsProperty).SubscribeValue(_ => UpdateMacWindowSurfaceClip());
+ NavFlyout.GetObservable(OpacityProperty).SubscribeValue(_ => UpdateMacWindowSurfaceClip());
+ ViewModel.Sidebar.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(SidebarViewModel.OverlayActive))
+ UpdateMacWindowSurfaceClip();
+ };
+ MacWindowSurface.GetObservable(BoundsProperty).SubscribeValue(_ => UpdateMacWindowSurfaceClip());
}
else if (OperatingSystem.IsWindows())
{
@@ -674,6 +722,118 @@ void ApplyMacTitleBarLayout()
/// a button centred on the traffic lights' centre line would overflow past the top of the window.
/// The centred search pill is unaffected — it stays centred in the full 44 px bar.
///
+ // With the glass look the sidebar floats as an inset panel: the labelled dock runs full height
+ // around the traffic lights, while the narrow rail (and the overlay aligned to it) starts below them.
+ private void ApplyMacSidebarLayout()
+ {
+ double bar = _macTitleBarHeight;
+ var railMargin = new Thickness(MAC_GLASS_INSET, bar, MAC_GLASS_INSET, MAC_GLASS_INSET);
+ var dockMargin = new Thickness(MAC_GLASS_INSET);
+
+ MainContentRoot.Margin = default;
+ ContentRoot.Margin = new Thickness(0, bar, 0, 0);
+ NavRail.Margin = railMargin;
+ NavDock.Margin = dockMargin;
+ NavDock.Padding = new Thickness(0, bar - MAC_GLASS_INSET, 0, 0);
+ NavFlyout.Margin = new Thickness(MAC_GLASS_INSET, bar, 0, MAC_GLASS_INSET);
+
+ SidebarGlass.IsVisible = NavRail.IsVisible || NavDock.IsVisible;
+ SidebarGlass.Margin = NavDock.IsVisible ? dockMargin : railMargin;
+ MacWindowSurface.IsVisible = true;
+ UpdateMacWindowSurfaceClip();
+ }
+
+ // Punches the sidebar panel's rounded rect out of the opaque window surface, so the native
+ // vibrancy only shows through the panel (System Settings look) and the content stays solid.
+ // While the expanded overlay shows (including its fade-out) it is the hole, and the content
+ // beneath it is clipped away too, so the overlay shows the same glass instead of the page.
+ private void UpdateMacWindowSurfaceClip()
+ {
+ if (!MacWindowSurface.IsVisible)
+ return;
+
+ bool overlay = ViewModel.Sidebar.OverlayActive || NavFlyout.Opacity > 0;
+ Border? panel = overlay ? NavFlyout : SidebarGlass.IsVisible ? SidebarGlass : null;
+
+ foreach (var child in MainContentRoot.Children)
+ {
+ if (child != NavFlyout && child != FlyoutDismiss)
+ child.Clip = overlay ? ExcludePanel(child, NavFlyout) : null;
+ }
+
+ MacWindowSurface.Clip = panel is null ? null : ExcludePanel(MacWindowSurface, panel);
+ }
+
+ // The control's own bounds minus the panel's rounded rect, in the control's coordinates.
+ private static Geometry? ExcludePanel(Visual target, Border panel)
+ {
+ if (panel.TranslatePoint(default, target) is not { } origin)
+ return null;
+
+ double radius = panel.CornerRadius.TopLeft;
+ return new CombinedGeometry(
+ GeometryCombineMode.Exclude,
+ new RectangleGeometry(new Rect(target.Bounds.Size)),
+ new RectangleGeometry(new Rect(origin, panel.Bounds.Size), radius, radius));
+ }
+
+ // macOS title-bar idioms: the sidebar.left toggle and a back chevron instead of the hamburger and
+ // arrow, and search at the trailing end of the bar, as in Finder.
+ private void ApplyMacTitleBarChrome()
+ {
+ if (SidebarToggleButton.Content is global::Avalonia.Controls.Shapes.Path toggle)
+ toggle.Data = Geometry.Parse("M2.5,0.75 H13.5 A1.75,1.75 0 0 1 15.25,2.5 V11.5 A1.75,1.75 0 0 1 13.5,13.25 "
+ + "H2.5 A1.75,1.75 0 0 1 0.75,11.5 V2.5 A1.75,1.75 0 0 1 2.5,0.75 Z M5.75,0.75 V13.25");
+ if (BackButton.Content is global::Avalonia.Controls.Shapes.Path back)
+ back.Data = Geometry.Parse("M10,1 L4,7 L10,13");
+
+ // Back is a round glass button, like the back/forward capsule in System Settings.
+ BackButton.Bind(BackgroundProperty, this.GetResourceObservable("MacGlassControlBackground"));
+ BackButton.Bind(BorderBrushProperty, this.GetResourceObservable("MacGlassPanelBorderBrush"));
+ BackButton.BorderThickness = new Thickness(1);
+ BackButton.CornerRadius = new CornerRadius(MAC_TITLE_BAR_CONTROL_SIZE / 2);
+
+ // Apple: don't title windows with the app name; the page header already names the view.
+ TitleBarTextBlock.IsVisible = Settings.Get(Settings.K.ShowVersionNumberOnTitlebar);
+
+ Grid.SetColumn(GlobalSearchContainer, 2);
+ GlobalSearchContainer.HorizontalAlignment = HorizontalAlignment.Right;
+ GlobalSearchContainer.Margin = new Thickness(0, 0, MAC_SEARCH_TRAILING_INSET, 0);
+
+ // At the trailing end the field has only its column's width, so it shrinks on narrow windows.
+ GlobalSearchGrid.ColumnDefinitions[0].Width = GridLength.Star;
+ TitleBarGrid.GetObservable(BoundsProperty).SubscribeValue(_ =>
+ {
+ double available = TitleBarGrid.ColumnDefinitions[2].ActualWidth - MAC_SEARCH_TRAILING_INSET;
+ GlobalSearchContainer.Width = Math.Clamp(available, MAC_SEARCH_MIN_WIDTH, MAC_SEARCH_MAX_WIDTH);
+ });
+
+ // Dialogs already render as in-window sheets; give them the rounder macOS 26 sheet shape.
+ ModalSurface.CornerRadius = new CornerRadius(20);
+ ModalOutline.CornerRadius = new CornerRadius(20);
+ ModalOutline.Bind(Border.BorderBrushProperty, this.GetResourceObservable("MacGlassPanelBorderBrush"));
+
+ // Sheet close: a round glass ✕ on the leading edge, not the red Windows caption button.
+ ModalCloseButton.Classes.Remove("caption");
+ ModalCloseButton.Classes.Remove("caption-close");
+ Grid.SetColumn(ModalCloseButton, 0);
+ ModalCloseButton.HorizontalAlignment = HorizontalAlignment.Left;
+ ModalCloseButton.VerticalAlignment = VerticalAlignment.Center;
+ ModalCloseButton.Margin = new Thickness(MAC_SHEET_CLOSE_INSET, 0, 0, 0);
+ ModalCloseButton.Width = MAC_SHEET_CLOSE_SIZE;
+ ModalCloseButton.Height = MAC_SHEET_CLOSE_SIZE;
+ ModalCloseButton.Padding = new Thickness(0);
+ ModalCloseButton.CornerRadius = new CornerRadius(MAC_SHEET_CLOSE_SIZE / 2);
+ ModalCloseButton.BorderThickness = new Thickness(1);
+ ModalCloseButton.Bind(BackgroundProperty, this.GetResourceObservable("MacGlassControlBackground"));
+ ModalCloseButton.Bind(BorderBrushProperty, this.GetResourceObservable("MacGlassPanelBorderBrush"));
+ if (ModalCloseButton.Content is SvgIcon glyph)
+ {
+ glyph.Width = 10;
+ glyph.Height = 10;
+ }
+ }
+
private void ApplyMacTitleBarControlSizes()
{
foreach (Button button in new[] { BackButton, SidebarToggleButton })
@@ -1891,7 +2051,11 @@ private void PresentModal(ImmersiveDialog dialog)
_modalTitleSubscription = dialog.GetObservable(ImmersiveDialog.TitleProperty)
.SubscribeValue(title => ModalTitle.Text = title ?? "");
ModalCloseButton.IsVisible = dialog.IsCloseButtonVisible;
- ModalTitle.Margin = dialog.TitleMargin;
+ // On macOS the close button sits on the leading edge, so the title starts after it.
+ ModalTitle.Margin = OperatingSystem.IsMacOS() && dialog.IsCloseButtonVisible
+ ? new Thickness(MAC_SHEET_CLOSE_INSET + MAC_SHEET_CLOSE_SIZE + 12, dialog.TitleMargin.Top,
+ dialog.TitleMargin.Right, dialog.TitleMargin.Bottom)
+ : dialog.TitleMargin;
ModalContent.Content = dialog;
ModalLayer.IsVisible = true;
UpdateModalSurfaceSize();
diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs
index 0e4e918e10..6423a8296d 100644
--- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs
@@ -51,6 +51,7 @@ public bool ShowLabels
public SidebarView()
{
InitializeComponent();
+ PlatformShortcuts.AdaptAcceleratorKeys(this);
NavigationPillTopCap.RenderTransform = _pillTopCapTranslate;
_pillStemTransform.Children.Add(_pillStemScale);
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml
index 7ab7fe8607..d6ec5203c0 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml
@@ -118,6 +118,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -243,7 +285,7 @@
@@ -272,8 +314,8 @@
-
-
+
+