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 @@ - - + +