From 47470f93041a3184b338e1ed5b9c8b5c441bbfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Ku=C4=8Dera?= <10546952+miloush@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:33:31 +0100 Subject: [PATCH 1/6] Internal PointerCaptureChanging event (#19833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PointerCaptureChanging event * PR feedback * Announce implicit capture to platform --------- Co-authored-by: Jan Kučera Co-authored-by: Max Katz --- src/Avalonia.Base/Input/InputElement.cs | 32 ++++++++++++- src/Avalonia.Base/Input/MouseDevice.cs | 4 +- src/Avalonia.Base/Input/Pointer.cs | 53 ++++++++++++++------- src/Avalonia.Base/Input/PointerEventArgs.cs | 17 ++++++- 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index 565e6afc1a..d15abfbd3f 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -178,6 +178,14 @@ namespace Avalonia.Input nameof(PointerReleased), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + /// + /// Defines the routed event. + /// + internal static readonly RoutedEvent PointerCaptureChangingEvent = + RoutedEvent.Register( + nameof(PointerCaptureChanging), + RoutingStrategies.Direct); + /// /// Defines the routed event. /// @@ -240,6 +248,7 @@ namespace Avalonia.Input PointerMovedEvent.AddClassHandler((x, e) => x.OnPointerMoved(e)); PointerPressedEvent.AddClassHandler((x, e) => x.OnPointerPressed(e)); PointerReleasedEvent.AddClassHandler((x, e) => x.OnPointerReleased(e)); + PointerCaptureChangingEvent.AddClassHandler((x, e) => x.OnPointerCaptureChanging(e)); PointerCaptureLostEvent.AddClassHandler((x, e) => x.OnPointerCaptureLost(e)); PointerWheelChangedEvent.AddClassHandler((x, e) => x.OnPointerWheelChanged(e)); @@ -381,9 +390,19 @@ namespace Avalonia.Input remove { RemoveHandler(PointerReleasedEvent, value); } } + /// + /// Occurs when the control or its child control is about to lose capture, + /// event will not be triggered for a parent control if capture was transferred to another child of that parent control. + /// + internal event EventHandler? PointerCaptureChanging + { + add => AddHandler(PointerCaptureChangingEvent, value); + remove => RemoveHandler(PointerCaptureChangingEvent, value); + } + /// /// Occurs when the control or its child control loses the pointer capture for any reason, - /// event will not be triggered for a parent control if capture was transferred to another child of that parent control + /// event will not be triggered for a parent control if capture was transferred to another child of that parent control. /// public event EventHandler? PointerCaptureLost { @@ -770,6 +789,17 @@ namespace Avalonia.Input /// Last focusable element if available/>. protected internal virtual InputElement? GetLastFocusableElementOverride() => null; + /// + /// Invoked when an unhandled reaches an element in its + /// route that is derived from this class. Implement this method to add class handling + /// for this event. + /// + /// Data about the event. + internal virtual void OnPointerCaptureChanging(PointerCaptureChangingEventArgs e) + { + + } + /// /// Invoked when an unhandled reaches an element in its /// route that is derived from this class. Implement this method to add class handling diff --git a/src/Avalonia.Base/Input/MouseDevice.cs b/src/Avalonia.Base/Input/MouseDevice.cs index b09f195656..49945f1e8a 100644 --- a/src/Avalonia.Base/Input/MouseDevice.cs +++ b/src/Avalonia.Base/Input/MouseDevice.cs @@ -131,7 +131,7 @@ namespace Avalonia.Input if (source != null) { - _pointer.Capture(source); + _pointer.Capture(source, CaptureSource.Implicit); var settings = ((IInputRoot?)(source as Interactive)?.GetVisualRoot())?.PlatformSettings; if (settings is not null) @@ -206,7 +206,7 @@ namespace Avalonia.Input } finally { - _pointer.Capture(null); + _pointer.Capture(null, CaptureSource.Implicit); _pointer.CaptureGestureRecognizer(null); _pointer.IsGestureRecognitionSkipped = false; _lastMouseDownButton = default; diff --git a/src/Avalonia.Base/Input/Pointer.cs b/src/Avalonia.Base/Input/Pointer.cs index 1f6741a09e..f243a2e382 100644 --- a/src/Avalonia.Base/Input/Pointer.cs +++ b/src/Avalonia.Base/Input/Pointer.cs @@ -6,6 +6,13 @@ using Avalonia.VisualTree; namespace Avalonia.Input { + internal enum CaptureSource + { + Explicit, + Implicit, + Platform + } + public class Pointer : IPointer, IDisposable { private static int s_NextFreePointerId = 1000; @@ -30,46 +37,60 @@ namespace Avalonia.Input protected virtual void PlatformCapture(IInputElement? element) { - + } internal void PlatformCaptureLost() { if (Captured != null) - Capture(null, platformInitiated: true); + Capture(null, CaptureSource.Platform); } public void Capture(IInputElement? control) { - Capture(control, platformInitiated: false); + Capture(control, CaptureSource.Explicit); } - private void Capture(IInputElement? control, bool platformInitiated) + internal void Capture(IInputElement? control, CaptureSource source) { var oldCapture = Captured; if (oldCapture == control) return; - if (oldCapture is Visual v1) - v1.DetachedFromVisualTree -= OnCaptureDetached; + var oldVisual = oldCapture as Visual; + + IInputElement? commonParent = null; + if (oldVisual != null) + { + commonParent = FindCommonParent(control, oldCapture); + foreach (var notifyTarget in oldVisual.GetSelfAndVisualAncestors().OfType()) + { + if (notifyTarget == commonParent) + break; + var args = new PointerCaptureChangingEventArgs(notifyTarget, this, control, source); + notifyTarget.RaiseEvent(args); + if (args.Handled) + return; + } + } + + if (oldVisual != null) + oldVisual.DetachedFromVisualTree -= OnCaptureDetached; Captured = control; - - if (!platformInitiated) + + if (source != CaptureSource.Platform) PlatformCapture(control); - if (oldCapture is Visual v2) - { - var commonParent = FindCommonParent(control, oldCapture); - foreach (var notifyTarget in v2.GetSelfAndVisualAncestors().OfType()) + if (oldVisual != null) + foreach (var notifyTarget in oldVisual.GetSelfAndVisualAncestors().OfType()) { if (notifyTarget == commonParent) break; notifyTarget.RaiseEvent(new PointerCaptureLostEventArgs(notifyTarget, this)); } - } - if (Captured is Visual v3) - v3.DetachedFromVisualTree += OnCaptureDetached; + if (Captured is Visual newVisual) + newVisual.DetachedFromVisualTree += OnCaptureDetached; if (Captured != null) CaptureGestureRecognizer(null); @@ -92,7 +113,7 @@ namespace Avalonia.Input public IInputElement? Captured { get; private set; } - + public PointerType Type { get; } public bool IsPrimary { get; } diff --git a/src/Avalonia.Base/Input/PointerEventArgs.cs b/src/Avalonia.Base/Input/PointerEventArgs.cs index bbcc5cccd8..7682b0fb22 100644 --- a/src/Avalonia.Base/Input/PointerEventArgs.cs +++ b/src/Avalonia.Base/Input/PointerEventArgs.cs @@ -202,11 +202,26 @@ namespace Avalonia.Input { public IPointer Pointer { get; } - [Unstable("This constructor might be removed in 12.0. If you need to remove capture, use stable methods on the IPointer instance.,")] + [Unstable("This constructor might be removed in 12.0. If you need to remove capture, use stable methods on the IPointer instance.")] public PointerCaptureLostEventArgs(object source, IPointer pointer) : base(InputElement.PointerCaptureLostEvent) { Pointer = pointer; Source = source; } } + + internal class PointerCaptureChangingEventArgs : RoutedEventArgs + { + public IPointer Pointer { get; } + public CaptureSource CaptureSource { get; } + public IInputElement? NewValue { get; } + + internal PointerCaptureChangingEventArgs(object source, IPointer pointer, IInputElement? newValue, CaptureSource captureSource) : base(InputElement.PointerCaptureChangingEvent) + { + Pointer = pointer; + Source = source; + NewValue = newValue; + CaptureSource = captureSource; + } + } } From 6cf9b87a951503d40bada47681e158486b399b48 Mon Sep 17 00:00:00 2001 From: memorydream Date: Sat, 18 Oct 2025 05:20:51 +0000 Subject: [PATCH 2/6] fix: DBus tray icon NRE when change visible (#19742) * fix: TrayIcon NRE when change visible * fix: DBus tray icon duplicated click event --------- Co-authored-by: Max Katz --- src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs b/src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs index b19da5ff36..b8ed5d30b4 100644 --- a/src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs +++ b/src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs @@ -58,6 +58,7 @@ namespace Avalonia.FreeDesktop _statusNotifierItemDbusObj = new StatusNotifierItemDbusObj(_connection, dbusMenuPath); _pathHandler.Add(_statusNotifierItemDbusObj); _connection.AddMethodHandler(_pathHandler); + _statusNotifierItemDbusObj.ActivationDelegate += () => OnClicked?.Invoke(); WatchAsync(); } @@ -112,13 +113,19 @@ namespace Avalonia.FreeDesktop #endif var tid = s_trayIconInstanceId++; + // make sure not to add the path handle and connection method handler twice + if (_statusNotifierItemDbusObj!.PathHandler is null) + _pathHandler.Add(_statusNotifierItemDbusObj!); + + _connection.RemoveMethodHandler(_pathHandler.Path); + _connection.AddMethodHandler(_pathHandler); + _sysTrayServiceName = FormattableString.Invariant($"org.kde.StatusNotifierItem-{pid}-{tid}"); await _dBus!.RequestNameAsync(_sysTrayServiceName, 0); await _statusNotifierWatcher.RegisterStatusNotifierItemAsync(_sysTrayServiceName); _statusNotifierItemDbusObj!.SetTitleAndTooltip(_tooltipText); _statusNotifierItemDbusObj.SetIcon(_icon); - _statusNotifierItemDbusObj.ActivationDelegate += OnClicked; } private void DestroyTrayIcon() From 1d8e38417b284795da8e62400b6e3aeb3d39fd49 Mon Sep 17 00:00:00 2001 From: pavelovcharov <1357165+pavelovcharov@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:45:34 +0300 Subject: [PATCH 3/6] LayoutTransformControl - fix memory leak due to Transform.Changed event subscription (#19718) * fix Transform.Changed memory leak in LayoutTransformControl * refix * apply transform on attach to visual tree --- .../LayoutTransformControl.cs | 47 ++++++++++++++---- .../LayoutTransformControlTests.cs | 29 +++++++++++ tests/Avalonia.LeakTests/ControlTests.cs | 49 +++++++++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/Avalonia.Controls/LayoutTransformControl.cs b/src/Avalonia.Controls/LayoutTransformControl.cs index 6cd2ee14bb..c6eef00a76 100644 --- a/src/Avalonia.Controls/LayoutTransformControl.cs +++ b/src/Avalonia.Controls/LayoutTransformControl.cs @@ -145,6 +145,19 @@ namespace Avalonia.Controls // Return result to allocate enough space for the transformation return transformedDesiredSize; } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + SubscribeLayoutTransform(LayoutTransform as Transform); + ApplyLayoutTransform(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + UnsubscribeLayoutTransform(LayoutTransform as Transform); + } private IDisposable? _renderTransformChangedEvent; @@ -224,7 +237,6 @@ namespace Avalonia.Controls /// Transformation matrix corresponding to _matrixTransform. /// private Matrix _transformation = Matrix.Identity; - private IDisposable? _transformChangedEvent; /// /// Returns true if Size a is smaller than Size b in either dimension. @@ -424,19 +436,34 @@ namespace Avalonia.Controls private void OnLayoutTransformChanged(AvaloniaPropertyChangedEventArgs e) { - var newTransform = e.NewValue as Transform; - - _transformChangedEvent?.Dispose(); - _transformChangedEvent = null; - - if (newTransform != null) + if (this.IsAttachedToVisualTree) { - _transformChangedEvent = Observable.FromEventPattern( - v => newTransform.Changed += v, v => newTransform.Changed -= v) - .Subscribe(_ => ApplyLayoutTransform()); + UnsubscribeLayoutTransform(e.OldValue as Transform); + SubscribeLayoutTransform(e.NewValue as Transform); } + + ApplyLayoutTransform(); + } + private void OnTransformChanged(object? sender, EventArgs e) + { ApplyLayoutTransform(); } + + private void SubscribeLayoutTransform(Transform? transform) + { + if (transform != null) + { + transform.Changed += OnTransformChanged; + } + } + + private void UnsubscribeLayoutTransform(Transform? transform) + { + if (transform != null) + { + transform.Changed -= OnTransformChanged; + } + } } } diff --git a/tests/Avalonia.Controls.UnitTests/LayoutTransformControlTests.cs b/tests/Avalonia.Controls.UnitTests/LayoutTransformControlTests.cs index 2fcc15f434..3574a239e2 100644 --- a/tests/Avalonia.Controls.UnitTests/LayoutTransformControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/LayoutTransformControlTests.cs @@ -306,6 +306,35 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(m.M31, res.M31, 3); Assert.Equal(m.M32, res.M32, 3); } + + [Fact] + public void Should_Apply_Transform_On_Attach_To_VisualTree() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var transform = new SkewTransform() { AngleX = -45, AngleY = -45 }; + + LayoutTransformControl lt = CreateWithChildAndMeasureAndTransform( + 100, + 100, + transform); + + transform.AngleX = 45; + transform.AngleY = 45; + + var window = new Window { Content = lt }; + window.Show(); + + Matrix actual = lt.TransformRoot.RenderTransform.Value; + Matrix expected = Matrix.CreateSkew(Matrix.ToRadians(45), Matrix.ToRadians(45)); + Assert.Equal(expected.M11, actual.M11, 3); + Assert.Equal(expected.M12, actual.M12, 3); + Assert.Equal(expected.M21, actual.M21, 3); + Assert.Equal(expected.M22, actual.M22, 3); + Assert.Equal(expected.M31, actual.M31, 3); + Assert.Equal(expected.M32, actual.M32, 3); + } + } private static void TransformMeasureSizeTest(Size size, Transform transform, Size expectedSize) { diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index c720e4d2f8..5da96511ce 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -13,6 +13,7 @@ using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Diagnostics; using Avalonia.Input; +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering; @@ -1000,6 +1001,54 @@ namespace Avalonia.LeakTests } } + [Fact] + public void LayoutTransformControl_Is_Freed() + { + using (Start()) + { + var transform = new RotateTransform { Angle = 90 }; + + Func run = () => + { + var window = new Window + { + Content = new LayoutTransformControl + { + LayoutTransform = transform, + Child = new Canvas() + } + }; + + window.Show(); + + // Do a layout and make sure that LayoutTransformControl gets added to visual tree + window.LayoutManager.ExecuteInitialLayoutPass(); + Assert.IsType(window.Presenter.Child); + Assert.NotEmpty(window.Presenter.Child.GetVisualChildren()); + + // Clear the content and ensure the LayoutTransformControl is removed. + window.Content = null; + window.LayoutManager.ExecuteLayoutPass(); + Assert.Null(window.Presenter.Child); + + return window; + }; + + var result = run(); + + // Process all Loaded events to free control reference(s) + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); + + // We are keeping transform alive to simulate a resource that outlives the control. + GC.KeepAlive(transform); + } + } + private FuncControlTemplate CreateWindowTemplate() { return new FuncControlTemplate((parent, scope) => From 6e35604b9d2061dc97c7a6ea285c5ad0734400d9 Mon Sep 17 00:00:00 2001 From: Mahmoud Darwish Date: Wed, 22 Oct 2025 02:46:32 -0400 Subject: [PATCH 4/6] Fix GTK file chooser to show network locations on Linux (#19855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTK file choosers default to local_only=TRUE, which hides network locations (SMB, SFTP, NFS) mounted via GVFS. This change calls gtk_file_chooser_set_local_only(FALSE) to enable network browsing. Fixes browsing to network shares in OpenFileDialog, OpenFolderDialog, and SaveFileDialog on Linux. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: meywd Co-authored-by: Claude --- src/Avalonia.X11/NativeDialogs/Gtk.cs | 5 ++++- src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.X11/NativeDialogs/Gtk.cs b/src/Avalonia.X11/NativeDialogs/Gtk.cs index 70010863ba..3138bdb22f 100644 --- a/src/Avalonia.X11/NativeDialogs/Gtk.cs +++ b/src/Avalonia.X11/NativeDialogs/Gtk.cs @@ -61,7 +61,10 @@ namespace Avalonia.X11.NativeDialogs [DllImport(GtkName)] public static extern void gtk_file_chooser_set_select_multiple(IntPtr chooser, bool allow); - + + [DllImport(GtkName)] + public static extern void gtk_file_chooser_set_local_only(IntPtr chooser, bool local_only); + [DllImport(GtkName)] public static extern void gtk_file_chooser_set_do_overwrite_confirmation(IntPtr chooser, bool do_overwrite_confirmation); diff --git a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs index 2ae1dcfd89..99b4803445 100644 --- a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs +++ b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs @@ -87,6 +87,7 @@ namespace Avalonia.X11.NativeDialogs } gtk_window_set_modal(dlg, true); + gtk_file_chooser_set_local_only(dlg, false); var tcs = new TaskCompletionSource(); List? disposables = null; From 037d7621d192d1de63efb1ed3876ecb7e2a870c2 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 22 Oct 2025 08:08:29 +0100 Subject: [PATCH 5/6] Prevent duplicated files during drag-and-drop (#19864) --- .../Input/Platform/DataObjectToDataTransferWrapper.cs | 11 +++++++++++ src/Avalonia.X11/Clipboard/X11Clipboard.cs | 6 ++++++ src/Windows/Avalonia.Win32/OleDataObjectHelper.cs | 6 +++--- .../OleDataObjectToDataTransferWrapper.cs | 6 ++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Base/Input/Platform/DataObjectToDataTransferWrapper.cs b/src/Avalonia.Base/Input/Platform/DataObjectToDataTransferWrapper.cs index 8460268ec6..6fe64134d8 100644 --- a/src/Avalonia.Base/Input/Platform/DataObjectToDataTransferWrapper.cs +++ b/src/Avalonia.Base/Input/Platform/DataObjectToDataTransferWrapper.cs @@ -28,6 +28,7 @@ internal sealed class DataObjectToDataTransferWrapper(IDataObject dataObject) var items = new List(); var nonFileFormats = new List(); var nonFileFormatStrings = new List(); + var hasFiles = false; foreach (var formatString in DataObject.GetDataFormats()) { @@ -35,18 +36,28 @@ internal sealed class DataObjectToDataTransferWrapper(IDataObject dataObject) if (formatString == DataFormats.Files) { + if (hasFiles) + continue; + // This is not ideal as we're reading the filenames ahead of time to generate the appropriate items. // We don't really care about that for this legacy wrapper. if (DataObject.Get(formatString) is IEnumerable storageItems) { + hasFiles = true; + foreach (var storageItem in storageItems) items.Add(PlatformDataTransferItem.Create(DataFormat.File, storageItem)); } } else if (formatString == DataFormats.FileNames) { + if (hasFiles) + continue; + if (DataObject.Get(formatString) is IEnumerable fileNames) { + hasFiles = true; + foreach (var fileName in fileNames) { if (StorageProviderHelpers.TryCreateBclStorageItem(fileName) is { } storageItem) diff --git a/src/Avalonia.X11/Clipboard/X11Clipboard.cs b/src/Avalonia.X11/Clipboard/X11Clipboard.cs index 21ba3d54f7..a5a56a51d7 100644 --- a/src/Avalonia.X11/Clipboard/X11Clipboard.cs +++ b/src/Avalonia.X11/Clipboard/X11Clipboard.cs @@ -327,15 +327,21 @@ namespace Avalonia.X11.Clipboard { List? nonFileFormats = null; var items = new List(); + var hasFiles = false; foreach (var format in formats) { if (DataFormat.File.Equals(format)) { + if (hasFiles) + continue; + // We're reading the filenames ahead of time to generate the appropriate items. // This is async, so it should be fine. if (await reader.TryGetAsync(format) is IEnumerable storageItems) { + hasFiles = true; + foreach (var storageItem in storageItems) items.Add(PlatformDataTransferItem.Create(DataFormat.File, storageItem)); } diff --git a/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs b/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs index 820231d16d..1e521e685c 100644 --- a/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs +++ b/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs @@ -29,15 +29,15 @@ internal static class OleDataObjectHelper tymed = TYMED.TYMED_HGLOBAL }; - public static unsafe object? TryGet(this Win32Com.IDataObject _oleDataObject, DataFormat format) + public static unsafe object? TryGet(this Win32Com.IDataObject oleDataObject, DataFormat format) { var formatEtc = format.ToFormatEtc(); - if (_oleDataObject.QueryGetData(&formatEtc) != (uint)HRESULT.S_OK) + if (oleDataObject.QueryGetData(&formatEtc) != (uint)HRESULT.S_OK) return null; var medium = new STGMEDIUM(); - if (_oleDataObject.GetData(&formatEtc, &medium) != (uint)HRESULT.S_OK) + if (oleDataObject.GetData(&formatEtc, &medium) != (uint)HRESULT.S_OK) return null; try diff --git a/src/Windows/Avalonia.Win32/OleDataObjectToDataTransferWrapper.cs b/src/Windows/Avalonia.Win32/OleDataObjectToDataTransferWrapper.cs index 8afc079d20..f5e101e0d0 100644 --- a/src/Windows/Avalonia.Win32/OleDataObjectToDataTransferWrapper.cs +++ b/src/Windows/Avalonia.Win32/OleDataObjectToDataTransferWrapper.cs @@ -54,15 +54,21 @@ internal sealed class OleDataObjectToDataTransferWrapper(Win32Com.IDataObject ol { List? nonFileFormats = null; var items = new List(); + var hasFiles = false; foreach (var format in Formats) { if (DataFormat.File.Equals(format)) { + if (hasFiles) + continue; + // This is not ideal as we're reading the filenames ahead of time to generate the appropriate items. // However, it's unlikely to be a heavy operation. if (_oleDataObject.TryGet(format) is IEnumerable storageItems) { + hasFiles = true; + foreach (var storageItem in storageItems) items.Add(PlatformDataTransferItem.Create(DataFormat.File, storageItem)); } From c676419cfa96669d9458ea413fb71520eb52be8d Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 22 Oct 2025 09:17:13 +0200 Subject: [PATCH 6/6] Deal with non standard oblique typefaces on Windows (#19876) --- src/Skia/Avalonia.Skia/FontManagerImpl.cs | 14 +++-- src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs | 26 ++++---- src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs | 60 ++++++++++++------- .../Media/FontManagerTests.cs | 47 +++++++++++---- 4 files changed, 101 insertions(+), 46 deletions(-) diff --git a/src/Skia/Avalonia.Skia/FontManagerImpl.cs b/src/Skia/Avalonia.Skia/FontManagerImpl.cs index 477348b59a..eb1833193c 100644 --- a/src/Skia/Avalonia.Skia/FontManagerImpl.cs +++ b/src/Skia/Avalonia.Skia/FontManagerImpl.cs @@ -50,7 +50,7 @@ namespace Avalonia.Skia skFontStyle = SKFontStyle.BoldItalic; break; default: - skFontStyle = new SKFontStyle((SKFontStyleWeight)fontWeight, (SKFontStyleWidth)fontStretch, (SKFontStyleSlant)fontStyle); + skFontStyle = new SKFontStyle((SKFontStyleWeight)fontWeight, (SKFontStyleWidth)fontStretch, fontStyle.ToSkia()); break; } @@ -63,7 +63,12 @@ namespace Avalonia.Skia if (skTypeface != null) { - fontKey = new Typeface(skTypeface.FamilyName, (FontStyle)skTypeface.FontStyle.Slant, (FontWeight)skTypeface.FontStyle.Weight, (FontStretch)skTypeface.FontStyle.Width); + // ToDo: create glyph typeface here to get the correct style/weight/stretch + fontKey = new Typeface( + skTypeface.FamilyName, + skTypeface.FontStyle.Slant.ToAvalonia(), + (FontWeight)skTypeface.FontStyle.Weight, + (FontStretch)skTypeface.FontStyle.Width); return true; } @@ -78,8 +83,7 @@ namespace Avalonia.Skia { glyphTypeface = null; - var fontStyle = new SKFontStyle((SKFontStyleWeight)weight, (SKFontStyleWidth)stretch, - (SKFontStyleSlant)style); + var fontStyle = new SKFontStyle((SKFontStyleWeight)weight, (SKFontStyleWidth)stretch, style.ToSkia()); var skTypeface = _skFontManager.MatchFamily(familyName, fontStyle); @@ -127,7 +131,7 @@ namespace Avalonia.Skia var set = _skFontManager.GetFontStyles(familyName); - if(set.Count == 0) + if (set.Count == 0) { return false; } diff --git a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs index 2def64c18d..703496a834 100644 --- a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs @@ -16,7 +16,6 @@ namespace Avalonia.Skia internal class GlyphTypefaceImpl : IGlyphTypeface2 { private bool _isDisposed; - private readonly SKTypeface _typeface; private readonly NameTable? _nameTable; private readonly OS2Table? _os2Table; private readonly HorizontalHeadTable? _hhTable; @@ -24,7 +23,7 @@ namespace Avalonia.Skia public GlyphTypefaceImpl(SKTypeface typeface, FontSimulations fontSimulations) { - _typeface = typeface ?? throw new ArgumentNullException(nameof(typeface)); + SKTypeface = typeface ?? throw new ArgumentNullException(nameof(typeface)); Face = new Face(GetTable) { UnitsPerEm = typeface.UnitsPerEm }; @@ -96,6 +95,11 @@ namespace Avalonia.Skia var style = _os2Table != null ? GetFontStyle(_os2Table.FontStyle) : FontStyle.Normal; + if (typeface.FontStyle.Slant == SKFontStyleSlant.Oblique) + { + style = FontStyle.Oblique; + } + Style = (fontSimulations & FontSimulations.Oblique) != 0 ? FontStyle.Italic : style; var stretch = _os2Table != null ? (FontStretch)_os2Table.WidthClass : FontStretch.Normal; @@ -205,6 +209,8 @@ namespace Avalonia.Skia } } + public SKTypeface SKTypeface { get; } + public Face Face { get; } public Font Font { get; } @@ -300,12 +306,12 @@ namespace Avalonia.Skia private static FontStyle GetFontStyle(OS2Table.FontStyleSelection styleSelection) { - if((styleSelection & OS2Table.FontStyleSelection.ITALIC) != 0) + if ((styleSelection & OS2Table.FontStyleSelection.ITALIC) != 0) { return FontStyle.Italic; } - if((styleSelection & OS2Table.FontStyleSelection.OBLIQUE) != 0) + if ((styleSelection & OS2Table.FontStyleSelection.OBLIQUE) != 0) { return FontStyle.Oblique; } @@ -315,18 +321,18 @@ namespace Avalonia.Skia private Blob? GetTable(Face face, Tag tag) { - var size = _typeface.GetTableSize(tag); + var size = SKTypeface.GetTableSize(tag); var data = Marshal.AllocCoTaskMem(size); var releaseDelegate = new ReleaseDelegate(() => Marshal.FreeCoTaskMem(data)); - return _typeface.TryGetTableData(tag, 0, size, data) ? + return SKTypeface.TryGetTableData(tag, 0, size, data) ? new Blob(data, size, MemoryMode.ReadOnly, releaseDelegate) : null; } public SKFont CreateSKFont(float size) - => new(_typeface, size, skewX: (FontSimulations & FontSimulations.Oblique) != 0 ? -0.3f : 0.0f) + => new(SKTypeface, size, skewX: (FontSimulations & FontSimulations.Oblique) != 0 ? -0.3f : 0.0f) { LinearMetrics = true, Embolden = (FontSimulations & FontSimulations.Bold) != 0 @@ -348,7 +354,7 @@ namespace Avalonia.Skia Font.Dispose(); Face.Dispose(); - _typeface.Dispose(); + SKTypeface.Dispose(); } public void Dispose() @@ -359,14 +365,14 @@ namespace Avalonia.Skia public bool TryGetTable(uint tag, out byte[] table) { - return _typeface.TryGetTableData(tag, out table); + return SKTypeface.TryGetTableData(tag, out table); } public bool TryGetStream([NotNullWhen(true)] out Stream? stream) { try { - var asset = _typeface.OpenStream(); + var asset = SKTypeface.OpenStream(); var size = asset.Length; var buffer = new byte[size]; diff --git a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs index 4a9502e49e..fd4e49d012 100644 --- a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs +++ b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs @@ -1,8 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; using Avalonia.Media; -using Avalonia.Platform; using Avalonia.Media.Imaging; +using Avalonia.Platform; using SkiaSharp; namespace Avalonia.Skia @@ -70,7 +70,7 @@ namespace Avalonia.Skia { return new SKPoint((float)p.X, (float)p.Y); } - + public static SKPoint ToSKPoint(this Vector p) { return new SKPoint((float)p.X, (float)p.Y); @@ -80,17 +80,17 @@ namespace Avalonia.Skia { return new SKRect((float)r.X, (float)r.Y, (float)r.Right, (float)r.Bottom); } - + internal static SKRect ToSKRect(this LtrbRect r) { return new SKRect((float)r.Left, (float)r.Right, (float)r.Right, (float)r.Bottom); } - + public static SKRectI ToSKRectI(this PixelRect r) { return new SKRectI(r.X, r.Y, r.Right, r.Bottom); } - + internal static SKRectI ToSKRectI(this LtrbPixelRect r) { return new SKRectI(r.Left, r.Top, r.Right, r.Bottom); @@ -106,7 +106,7 @@ namespace Avalonia.Skia { r.RadiiTopLeft.ToSKPoint(), r.RadiiTopRight.ToSKPoint(), r.RadiiBottomRight.ToSKPoint(), r.RadiiBottomLeft.ToSKPoint(), - }); + }); return result; } @@ -115,17 +115,17 @@ namespace Avalonia.Skia { return new Rect(r.Left, r.Top, r.Right - r.Left, r.Bottom - r.Top); } - + internal static LtrbRect ToAvaloniaLtrbRect(this SKRect r) { return new LtrbRect(r.Left, r.Top, r.Right, r.Bottom); } - + public static PixelRect ToAvaloniaPixelRect(this SKRectI r) { return new PixelRect(r.Left, r.Top, r.Right - r.Left, r.Bottom - r.Top); } - + internal static LtrbPixelRect ToAvaloniaLtrbPixelRect(this SKRectI r) { return new LtrbPixelRect(r.Left, r.Top, r.Right, r.Bottom); @@ -173,7 +173,7 @@ namespace Avalonia.Skia return sm; } - + internal static Matrix ToAvaloniaMatrix(this SKMatrix m) => new( m.ScaleX, m.SkewY, m.Persp0, m.SkewX, m.ScaleY, m.Persp1, @@ -253,9 +253,12 @@ namespace Avalonia.Skia switch (m) { default: - case GradientSpreadMethod.Pad: return SKShaderTileMode.Clamp; - case GradientSpreadMethod.Reflect: return SKShaderTileMode.Mirror; - case GradientSpreadMethod.Repeat: return SKShaderTileMode.Repeat; + case GradientSpreadMethod.Pad: + return SKShaderTileMode.Clamp; + case GradientSpreadMethod.Reflect: + return SKShaderTileMode.Mirror; + case GradientSpreadMethod.Repeat: + return SKShaderTileMode.Repeat; } } @@ -264,9 +267,12 @@ namespace Avalonia.Skia switch (a) { default: - case TextAlignment.Left: return SKTextAlign.Left; - case TextAlignment.Center: return SKTextAlign.Center; - case TextAlignment.Right: return SKTextAlign.Right; + case TextAlignment.Left: + return SKTextAlign.Left; + case TextAlignment.Center: + return SKTextAlign.Center; + case TextAlignment.Right: + return SKTextAlign.Right; } } @@ -295,9 +301,12 @@ namespace Avalonia.Skia switch (a) { default: - case SKTextAlign.Left: return TextAlignment.Left; - case SKTextAlign.Center: return TextAlignment.Center; - case SKTextAlign.Right: return TextAlignment.Right; + case SKTextAlign.Left: + return TextAlignment.Left; + case SKTextAlign.Center: + return TextAlignment.Center; + case SKTextAlign.Right: + return TextAlignment.Right; } } @@ -308,7 +317,18 @@ namespace Avalonia.Skia SKFontStyleSlant.Upright => FontStyle.Normal, SKFontStyleSlant.Italic => FontStyle.Italic, SKFontStyleSlant.Oblique => FontStyle.Oblique, - _ => throw new ArgumentOutOfRangeException(nameof (slant), slant, null) + _ => throw new ArgumentOutOfRangeException(nameof(slant), slant, null) + }; + } + + public static SKFontStyleSlant ToSkia(this FontStyle style) + { + return style switch + { + FontStyle.Normal => SKFontStyleSlant.Upright, + FontStyle.Italic => SKFontStyleSlant.Italic, + FontStyle.Oblique => SKFontStyleSlant.Oblique, + _ => throw new ArgumentOutOfRangeException(nameof(style), style, null) }; } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs index 6259547a48..2713e7133b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; using Avalonia.Fonts.Inter; using Avalonia.Headless; using Avalonia.Media; @@ -103,7 +101,7 @@ namespace Avalonia.Skia.UnitTests.Media { Assert.True(FontManager.Current.TryGetGlyphTypeface(Typeface.Default, out _)); - for (int i = 0;i < 10; i++) + for (int i = 0; i < 10; i++) { FontManager.Current.TryGetGlyphTypeface(new Typeface("Unknown"), out _); } @@ -313,7 +311,7 @@ namespace Avalonia.Skia.UnitTests.Media { Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("微軟正黑體"), out var glyphTypeface)); - Assert.Equal("Microsoft JhengHei",glyphTypeface.FamilyName); + Assert.Equal("Microsoft JhengHei", glyphTypeface.FamilyName); } } } @@ -325,7 +323,7 @@ namespace Avalonia.Skia.UnitTests.Media { using (AvaloniaLocator.EnterScope()) { - FontManager.Current.AddFontCollection(new InterFontCollection()); + FontManager.Current.AddFontCollection(new InterFontCollection()); Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("fonts:Inter#Inter"), out var glyphTypeface)); @@ -346,12 +344,12 @@ namespace Avalonia.Skia.UnitTests.Media { using (AvaloniaLocator.EnterScope()) { - AvaloniaLocator.CurrentMutable.BindToSelf(new FontManagerOptions - { - DefaultFamilyName = s_fontUri, - FontFamilyMappings = new Dictionary - { - { "Segoe UI", new FontFamily("fonts:Inter#Inter") } + AvaloniaLocator.CurrentMutable.BindToSelf(new FontManagerOptions + { + DefaultFamilyName = s_fontUri, + FontFamilyMappings = new Dictionary + { + { "Segoe UI", new FontFamily("fonts:Inter#Inter") } } }); @@ -428,6 +426,33 @@ namespace Avalonia.Skia.UnitTests.Media } } + + [Win32Fact("Windows specific font")] + public void Should_Get_Regular_Font_After_Matching_Italic_Font() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()))) + { + using (AvaloniaLocator.EnterScope()) + { + Assert.True(FontManager.Current.TryMatchCharacter('こ', FontStyle.Italic, FontWeight.Normal, FontStretch.Normal, null, null, out var italicTypeface)); + + Assert.Equal(FontSimulations.None, italicTypeface.GlyphTypeface.FontSimulations); + + Assert.Equal("Yu Gothic UI", italicTypeface.GlyphTypeface.FamilyName); + + Assert.NotEqual(FontStyle.Normal, italicTypeface.Style); + + Assert.True(FontManager.Current.TryMatchCharacter('こ', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var regularTypeface)); + + Assert.Equal("Yu Gothic UI", regularTypeface.GlyphTypeface.FamilyName); + + Assert.Equal(FontStyle.Normal, regularTypeface.Style); + + Assert.NotEqual(((GlyphTypefaceImpl)italicTypeface.GlyphTypeface).SKTypeface, ((GlyphTypefaceImpl)regularTypeface.GlyphTypeface).SKTypeface); + } + } + } + [Fact] public void Should_Fallback_When_Font_Family_Is_Empty() {