From ae0573a789f829d1f5d168e313a79fcbcb9ffc83 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 27 May 2025 12:17:46 +0200 Subject: [PATCH 01/94] Make typeface matching and synthetic typeface creation customizable (#18890) * Make typeface matching and synthetic typeface creation customizable * Rename test collection * Revert breaking change * Directly use the DefaultFontFamily name when the alias is being used --- src/Avalonia.Base/Media/FontManager.cs | 66 ++--------- .../Media/Fonts/EmbeddedFontCollection.cs | 14 ++- .../Media/Fonts/FontCollectionBase.cs | 84 ++++++++++++- .../Media/Fonts/IFontCollection.cs | 11 ++ .../Media/Fonts/SystemFontCollection.cs | 6 +- .../HeadlessPlatformStubs.cs | 6 +- .../Media/EmbeddedFontCollectionTests.cs | 34 +++++- .../Media/FontCollectionTests.cs | 111 +++++++++++++++++- 8 files changed, 260 insertions(+), 72 deletions(-) diff --git a/src/Avalonia.Base/Media/FontManager.cs b/src/Avalonia.Base/Media/FontManager.cs index c51dbbfee3..77118b06a9 100644 --- a/src/Avalonia.Base/Media/FontManager.cs +++ b/src/Avalonia.Base/Media/FontManager.cs @@ -109,9 +109,9 @@ namespace Avalonia.Media var familyName = fontFamily.FamilyNames[i]; - if(_fontFamilyMappings != null && _fontFamilyMappings.TryGetValue(familyName, out var mappedFontFamily)) + if (_fontFamilyMappings != null && _fontFamilyMappings.TryGetValue(familyName, out var mappedFontFamily)) { - if(mappedFontFamily.Key != null) + if (mappedFontFamily.Key != null) { key = mappedFontFamily.Key; } @@ -123,6 +123,11 @@ namespace Avalonia.Media familyName = mappedFontFamily.FamilyNames.PrimaryFamilyName; } + if (familyName == FontFamily.DefaultFontFamilyName) + { + return TryGetGlyphTypeface(new Typeface(DefaultFontFamily, typeface.Style, typeface.Weight, typeface.Stretch), out glyphTypeface); + } + if (TryGetGlyphTypefaceByKeyAndName(typeface, key, familyName, out glyphTypeface) && glyphTypeface.FamilyName.Contains(familyName)) { @@ -274,6 +279,11 @@ namespace Avalonia.Media var familyName = fontFamily.FamilyNames[i]; var source = key.Source.EnsureAbsolute(key.BaseUri); + if(familyName == FontFamily.DefaultFontFamilyName) + { + familyName = DefaultFontFamily.Name; + } + if (TryGetFontCollection(source, out var fontCollection) && fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface)) { @@ -286,58 +296,6 @@ namespace Avalonia.Media return PlatformImpl.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, culture, out typeface); } - /// - /// Tries to create a synthetic glyph typefacefor specified source glyph typeface and font properties. - /// - /// The font manager implementation. - /// The source glyph typeface. - /// The requested font style. - /// The requested font weight. - /// The created synthetic glyph typeface. - /// - /// True, if the could create a synthetic glyph typeface, False otherwise. - /// - internal static bool TryCreateSyntheticGlyphTypeface(IFontManagerImpl fontManager, IGlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, - [NotNullWhen(true)] out IGlyphTypeface? syntheticGlyphTypeface) - { - if (fontManager == null) - { - syntheticGlyphTypeface = null; - - return false; - } - - if (glyphTypeface is IGlyphTypeface2 glyphTypeface2) - { - var fontSimulations = FontSimulations.None; - - if (style != FontStyle.Normal && glyphTypeface2.Style != style) - { - fontSimulations |= FontSimulations.Oblique; - } - - if ((int)weight >= 600 && glyphTypeface2.Weight < weight) - { - fontSimulations |= FontSimulations.Bold; - } - - if (fontSimulations != FontSimulations.None && glyphTypeface2.TryGetStream(out var stream)) - { - using (stream) - { - fontManager.TryCreateGlyphTypeface(stream, fontSimulations, - out syntheticGlyphTypeface); - - return syntheticGlyphTypeface != null; - } - } - } - - syntheticGlyphTypeface = null; - - return false; - } - internal IReadOnlyList GetFamilyTypefaces(FontFamily fontFamily) { var key = fontFamily.Key; diff --git a/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs b/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs index f06e5d1562..06f9e82858 100644 --- a/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs @@ -71,14 +71,16 @@ namespace Avalonia.Media.Fonts if (TryGetNearestMatch(glyphTypefaces, key, out glyphTypeface)) { - if(_fontManager != null && FontManager.TryCreateSyntheticGlyphTypeface(_fontManager, glyphTypeface, style, weight, out var syntheticGlyphTypeface)) + var matchedKey = new FontCollectionKey(glyphTypeface.Style, glyphTypeface.Weight, glyphTypeface.Stretch); + + if(matchedKey != key) { - glyphTypeface = syntheticGlyphTypeface; + if (TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out var syntheticGlyphTypeface)) + { + glyphTypeface = syntheticGlyphTypeface; + } } - //Make sure we cache the found match - glyphTypefaces.TryAdd(key, glyphTypeface); - return true; } } @@ -143,7 +145,7 @@ namespace Avalonia.Media.Fonts } } - bool IFontCollection2.TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) { familyTypefaces = null; diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs index 4f8376d267..a022fcfe4d 100644 --- a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs +++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs @@ -23,7 +23,7 @@ namespace Avalonia.Media.Fonts public abstract bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface); - public bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch, + public virtual bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch, string? familyName, CultureInfo? culture, out Typeface match) { match = default; @@ -59,6 +59,88 @@ namespace Avalonia.Media.Fonts return false; } + public virtual bool TryCreateSyntheticGlyphTypeface( + IGlyphTypeface glyphTypeface, + FontStyle style, + FontWeight weight, + FontStretch stretch, + [NotNullWhen(true)] out IGlyphTypeface? syntheticGlyphTypeface) + { + syntheticGlyphTypeface = null; + + //Source family should be present in the cache. + if (!_glyphTypefaceCache.TryGetValue(glyphTypeface.FamilyName, out var glyphTypefaces)) + { + return false; + } + + var fontManager = FontManager.Current.PlatformImpl; + + var key = new FontCollectionKey(style, weight, stretch); + + var currentKey = + new FontCollectionKey(glyphTypeface.Style, glyphTypeface.Weight, glyphTypeface.Stretch); + + if (currentKey == key) + { + return false; + } + + if (glyphTypeface is not IGlyphTypeface2 glyphTypeface2) + { + return false; + } + + var fontSimulations = FontSimulations.None; + + if (style != FontStyle.Normal && glyphTypeface2.Style != style) + { + fontSimulations |= FontSimulations.Oblique; + } + + if ((int)weight >= 600 && glyphTypeface2.Weight < weight) + { + fontSimulations |= FontSimulations.Bold; + } + + if (fontSimulations != FontSimulations.None && glyphTypeface2.TryGetStream(out var stream)) + { + using (stream) + { + if (fontManager.TryCreateGlyphTypeface(stream, fontSimulations, out syntheticGlyphTypeface)) + { + //Add the TypographicFamilyName to the cache + if (!string.IsNullOrEmpty(glyphTypeface2.TypographicFamilyName)) + { + AddGlyphTypefaceByFamilyName(glyphTypeface2.TypographicFamilyName, syntheticGlyphTypeface); + } + + foreach (var kvp in glyphTypeface2.FamilyNames) + { + AddGlyphTypefaceByFamilyName(kvp.Value, syntheticGlyphTypeface); + } + + return true; + } + + return false; + } + } + + return false; + + void AddGlyphTypefaceByFamilyName(string familyName, IGlyphTypeface glyphTypeface) + { + var typefaces = _glyphTypefaceCache.GetOrAdd(familyName, + x => + { + return new ConcurrentDictionary(); + }); + + typefaces.TryAdd(key, glyphTypeface); + } + } + public abstract void Initialize(IFontManagerImpl fontManager); public abstract IEnumerator GetEnumerator(); diff --git a/src/Avalonia.Base/Media/Fonts/IFontCollection.cs b/src/Avalonia.Base/Media/Fonts/IFontCollection.cs index a2fbdb69b0..2a30f0abd8 100644 --- a/src/Avalonia.Base/Media/Fonts/IFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/IFontCollection.cs @@ -59,5 +59,16 @@ namespace Avalonia.Media.Fonts /// True, if the could get the list of typefaces, False otherwise. /// bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces); + + /// + /// Try to get a synthetic glyph typeface for given parameters. + /// + /// The glyph typeface we try to synthesize. + /// The font style. + /// The font weight. + /// The font stretch. + /// + /// Returns true if a synthetic glyph typface can be created; otherwise, false + bool TryCreateSyntheticGlyphTypeface(IGlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? syntheticGlyphTypeface); } } diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index d59b1d1954..3a98a30b90 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -91,9 +91,11 @@ namespace Avalonia.Media.Fonts } //Try to create a synthetic glyph typeface - if (FontManager.TryCreateSyntheticGlyphTypeface(_fontManager.PlatformImpl, glyphTypeface, style, weight, out var syntheticGlyphTypeface)) + if (TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out var syntheticGlyphTypeface)) { glyphTypeface = syntheticGlyphTypeface; + + return true; } } @@ -159,7 +161,7 @@ namespace Avalonia.Media.Fonts } } - bool IFontCollection2.TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) { familyTypefaces = null; diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs index 893bb7ec95..4595df43a9 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs @@ -245,7 +245,11 @@ namespace Avalonia.Headless public virtual bool TryCreateGlyphTypeface(Stream stream, FontSimulations fontSimulations, out IGlyphTypeface glyphTypeface) { - glyphTypeface = new HeadlessGlyphTypefaceImpl(FontFamily.DefaultFontFamilyName, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal); + glyphTypeface = new HeadlessGlyphTypefaceImpl( + FontFamily.DefaultFontFamilyName, + fontSimulations.HasFlag(FontSimulations.Oblique) ? FontStyle.Italic : FontStyle.Normal, + fontSimulations.HasFlag(FontSimulations.Bold) ? FontWeight.Bold : FontWeight.Normal, + FontStretch.Normal); TryCreateGlyphTypefaceCount++; diff --git a/tests/Avalonia.Skia.UnitTests/Media/EmbeddedFontCollectionTests.cs b/tests/Avalonia.Skia.UnitTests/Media/EmbeddedFontCollectionTests.cs index fbf71795ba..a1ba9d92f8 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/EmbeddedFontCollectionTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/EmbeddedFontCollectionTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using Avalonia.Media.Fonts; using Avalonia.UnitTests; @@ -18,6 +19,7 @@ namespace Avalonia.Skia.UnitTests.Media private const string s_manrope = "resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Manrope"; + [InlineData(FontWeight.SemiLight, FontStyle.Normal)] [InlineData(FontWeight.Bold, FontStyle.Italic)] [InlineData(FontWeight.Heavy, FontStyle.Oblique)] @@ -28,7 +30,7 @@ namespace Avalonia.Skia.UnitTests.Media { var source = new Uri(s_notoMono, UriKind.Absolute); - var fontCollection = new EmbeddedFontCollection(source, source); + var fontCollection = new TestEmbeddedFontCollection(source, source); fontCollection.Initialize(new CustomFontManagerImpl()); @@ -47,7 +49,7 @@ namespace Avalonia.Skia.UnitTests.Media { var source = new Uri(s_notoMono, UriKind.Absolute); - var fontCollection = new EmbeddedFontCollection(source, source); + var fontCollection = new TestEmbeddedFontCollection(source, source); fontCollection.Initialize(new CustomFontManagerImpl()); @@ -62,7 +64,7 @@ namespace Avalonia.Skia.UnitTests.Media { var source = new Uri("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#T", UriKind.Absolute); - var fontCollection = new EmbeddedFontCollection(source, source); + var fontCollection = new TestEmbeddedFontCollection(source, source); fontCollection.Initialize(new CustomFontManagerImpl()); @@ -79,7 +81,7 @@ namespace Avalonia.Skia.UnitTests.Media { var source = new Uri(s_manrope, UriKind.Absolute); - var fontCollection = new EmbeddedFontCollection(source, source); + var fontCollection = new TestEmbeddedFontCollection(source, source); fontCollection.Initialize(new CustomFontManagerImpl()); @@ -102,7 +104,7 @@ namespace Avalonia.Skia.UnitTests.Media { var source = new Uri(s_manrope, UriKind.Absolute); - var fontCollection = new TestEmbeddedFontCollection(source, source); + var fontCollection = new TestEmbeddedFontCollection(source, source, true); fontCollection.Initialize(new CustomFontManagerImpl()); @@ -120,11 +122,31 @@ namespace Avalonia.Skia.UnitTests.Media private class TestEmbeddedFontCollection : EmbeddedFontCollection { - public TestEmbeddedFontCollection(Uri key, Uri source) : base(key, source) + private bool _createSyntheticTypefaces; + + public TestEmbeddedFontCollection(Uri key, Uri source, bool createSyntheticTypefaces = false) : base(key, source) { + _createSyntheticTypefaces = createSyntheticTypefaces; } public IDictionary> GlyphTypefaceCache => _glyphTypefaceCache; + + public override bool TryCreateSyntheticGlyphTypeface( + IGlyphTypeface glyphTypeface, + FontStyle style, + FontWeight weight, + FontStretch stretch, + [NotNullWhen(true)] out IGlyphTypeface? syntheticGlyphTypeface) + { + if (!_createSyntheticTypefaces) + { + syntheticGlyphTypeface = null; + + return false; + } + + return base.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface); + } } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs index 7d63b9a79e..2dc8d7e772 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs @@ -1,7 +1,10 @@ #nullable enable +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Avalonia.Media; using Avalonia.Media.Fonts; using Avalonia.UnitTests; @@ -11,6 +14,9 @@ namespace Avalonia.Skia.UnitTests.Media { public class FontCollectionTests { + private const string NotoMono = + "resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests"; + [InlineData("Hello World 6", "Hello World 6", FontStyle.Normal, FontWeight.Normal)] [InlineData("Hello World Italic", "Hello World", FontStyle.Italic, FontWeight.Normal)] [InlineData("Hello World Italic Bold", "Hello World", FontStyle.Italic, FontWeight.Bold)] @@ -41,8 +47,6 @@ namespace Avalonia.Skia.UnitTests.Media Assert.True(fontCollection.TryGetGlyphTypeface("Arial", FontStyle.Normal, FontWeight.ExtraBlack, FontStretch.Normal, out var glyphTypeface)); - Assert.True(glyphTypeface.FontSimulations == FontSimulations.Bold); - Assert.True(fontCollection.GlyphTypefaceCache.TryGetValue("Arial", out var glyphTypefaces)); Assert.Equal(2, glyphTypefaces.Count); @@ -64,5 +68,108 @@ namespace Avalonia.Skia.UnitTests.Media public IDictionary> GlyphTypefaceCache => _glyphTypefaceCache; } + + [Fact] + public void Should_Use_Fallback() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var source = new Uri(NotoMono, UriKind.Absolute); + + var fallback = new FontFallback { FontFamily = new FontFamily("Arial"), UnicodeRange = new UnicodeRange('A', 'A') }; + + var fontCollection = new CustomizableFontCollection(source, source, new[] { fallback }); + + fontCollection.Initialize(new CustomFontManagerImpl()); + + Assert.True(fontCollection.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var match)); + + Assert.Equal("Arial", match.FontFamily.Name); + } + } + + [Fact] + public void Should_Ignore_FontFamily() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var source = new Uri(NotoMono + "#Noto Mono", UriKind.Absolute); + + var ignorable = new FontFamily(new Uri(NotoMono, UriKind.Absolute), "Noto Mono"); + + var typeface = new Typeface(ignorable); + + var fontCollection = new CustomizableFontCollection(source, source, null, new[] { ignorable }); + + fontCollection.Initialize(new CustomFontManagerImpl()); + + Assert.False(fontCollection.TryCreateSyntheticGlyphTypeface( + typeface.GlyphTypeface, + FontStyle.Italic, + FontWeight.DemiBold, + FontStretch.Normal, + out var syntheticGlyphTypeface)); + } + } + + private class CustomizableFontCollection : EmbeddedFontCollection + { + private readonly IReadOnlyList? _fallbacks; + private readonly IReadOnlyList? _ignorables; + + public CustomizableFontCollection(Uri key, Uri source, IReadOnlyList? fallbacks = null, IReadOnlyList? ignorables = null) : base(key, source) + { + _fallbacks = fallbacks; + _ignorables = ignorables; + } + + public override bool TryMatchCharacter( + int codepoint, + FontStyle style, + FontWeight weight, + FontStretch stretch, + string? familyName, + CultureInfo? culture, + out Typeface match) + { + if(_fallbacks is not null) + { + foreach (var fallback in _fallbacks) + { + if (fallback.UnicodeRange.IsInRange(codepoint)) + { + match = new Typeface(fallback.FontFamily, style, weight, stretch); + + return true; + } + } + } + + return base.TryMatchCharacter(codepoint, style, weight, stretch, familyName, culture, out match); + } + + public override bool TryCreateSyntheticGlyphTypeface( + IGlyphTypeface glyphTypeface, + FontStyle style, + FontWeight weight, + FontStretch stretch, + [NotNullWhen(true)] out IGlyphTypeface? syntheticGlyphTypeface) + { + syntheticGlyphTypeface = null; + + if(_ignorables is not null) + { + foreach (var ignorable in _ignorables) + { + if (glyphTypeface.FamilyName == ignorable.Name || glyphTypeface is IGlyphTypeface2 glyphTypeface2 && glyphTypeface2.TypographicFamilyName == ignorable.Name) + { + return false; + } + } + } + + return base.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface); + } + } } } From edeca6a4db9e5e7b1c77c8013a241b85ea6c332f Mon Sep 17 00:00:00 2001 From: Tom Edwards <109803929+TomEdwardsEnscape@users.noreply.github.com> Date: Wed, 28 May 2025 01:29:50 +0200 Subject: [PATCH 02/94] Fixed transitions with delay but no duration completing instantly (#18929) --- .../Animation/TransitionInstance.cs | 12 +++++- .../Animation/TransitionsTests.cs | 43 ++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Base/Animation/TransitionInstance.cs b/src/Avalonia.Base/Animation/TransitionInstance.cs index 9c9494ff87..676a30e81e 100644 --- a/src/Avalonia.Base/Animation/TransitionInstance.cs +++ b/src/Avalonia.Base/Animation/TransitionInstance.cs @@ -33,9 +33,17 @@ namespace Avalonia.Animation // ^- normalizedDelayEnd // [<---- normalizedInterpVal --->] - var normalizedInterpVal = 1d; + double normalizedInterpVal; - if (!MathUtilities.AreClose(_duration.TotalSeconds, 0d)) + if (t < _delay) + { + normalizedInterpVal = 0d; + } + else if (MathUtilities.AreClose(_duration.TotalSeconds, 0d)) + { + normalizedInterpVal = 1d; + } + else { var normalizedTotalDur = _delay + _duration; var normalizedDelayEnd = _delay.TotalSeconds / normalizedTotalDur.TotalSeconds; diff --git a/tests/Avalonia.Base.UnitTests/Animation/TransitionsTests.cs b/tests/Avalonia.Base.UnitTests/Animation/TransitionsTests.cs index 2737c2cebf..caaa29d05f 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/TransitionsTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/TransitionsTests.cs @@ -84,7 +84,8 @@ namespace Avalonia.Base.UnitTests.Animation var clock = new TestClock(); var i = -1; - + var completed = false; + new TransitionInstance(clock, TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(70)).Subscribe( nextValue => { @@ -124,12 +125,50 @@ namespace Avalonia.Base.UnitTests.Animation Assert.Equal(1d, nextValue); break; } - }); + }, () => completed = true); for (var z = 0; z <= 10; z++) { clock.Pulse(TimeSpan.FromMilliseconds(10)); } + + Assert.True(completed); + } + + [Fact] + public void TransitionInstance_With_Delay_But_Zero_Duration_Is_Completed_After_Delay() + { + var clock = new TestClock(); + + var i = -1; + var completed = false; + + new TransitionInstance(clock, TimeSpan.FromMilliseconds(30), TimeSpan.Zero).Subscribe( + nextValue => + { + switch (i++) + { + case 0: + Assert.Equal(0, nextValue); + break; + case 1: + Assert.Equal(0, nextValue); + break; + case 2: + Assert.Equal(0, nextValue); + break; + case 3: // one iteration sooner than the test above, because the start of the transition is also the end + Assert.Equal(1, nextValue); + break; + } + }, () => completed = true); + + for (var z = 0; z <= 4; z++) + { + clock.Pulse(TimeSpan.FromMilliseconds(10)); + } + + Assert.True(completed); } } } From 513d1d96ecdcb121b160b81d412e4ea785c3daae Mon Sep 17 00:00:00 2001 From: Tom Edwards <109803929+TomEdwardsEnscape@users.noreply.github.com> Date: Wed, 28 May 2025 01:39:34 +0200 Subject: [PATCH 03/94] Fixed RectangleGeometry not cloning its radius properties (#18934) --- src/Avalonia.Base/Media/RectangleGeometry.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Media/RectangleGeometry.cs b/src/Avalonia.Base/Media/RectangleGeometry.cs index e97a240fc4..29c78d630b 100644 --- a/src/Avalonia.Base/Media/RectangleGeometry.cs +++ b/src/Avalonia.Base/Media/RectangleGeometry.cs @@ -100,7 +100,7 @@ namespace Avalonia.Media } /// - public override Geometry Clone() => new RectangleGeometry(Rect); + public override Geometry Clone() => new RectangleGeometry(Rect, RadiusX, RadiusY); private protected sealed override IGeometryImpl? CreateDefiningGeometry() { From 66a8d1bfe083534ab9d7395417e8e9edf6271ec6 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sat, 31 May 2025 15:23:13 +0200 Subject: [PATCH 04/94] Fix MeasureCore when there's a size constraint and there's a negative margin. (#18462) --- src/Avalonia.Base/Layout/Layoutable.cs | 6 ++-- .../Layout/LayoutableTests.cs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index 47f2217816..fbf278f5d7 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -596,15 +596,15 @@ namespace Avalonia.Layout (width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale); } + width += margin.Left + margin.Right; + height += margin.Top + margin.Bottom; + if (width > availableSize.Width) width = availableSize.Width; if (height > availableSize.Height) height = availableSize.Height; - width += margin.Left + margin.Right; - height += margin.Top + margin.Bottom; - if (width < 0) width = 0; diff --git a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs index b8cba8c169..5784c46964 100644 --- a/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs +++ b/tests/Avalonia.Base.UnitTests/Layout/LayoutableTests.cs @@ -435,6 +435,35 @@ namespace Avalonia.Base.UnitTests.Layout }); } + [Fact] + public void Constraint_And_Negative_Margin() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var textBlock = new TextBlock + { + Margin = new Thickness(-10), + Text = "Lorem ipsum dolor sit amet", + }; + + var border = new Border + { + MaxWidth = 100, + Child = textBlock, + }; + + border.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + border.Arrange(new Rect(default, border.DesiredSize)); + + Assert.Multiple(() => + { + Assert.Equal(new Size(100, 0), border.DesiredSize); + Assert.Equal(new Rect(0, 0, 100, 0), border.Bounds); + Assert.Equal(new Size(100, 0), textBlock.DesiredSize); + Assert.Equal(new Rect(-10, -10, 120, 20), textBlock.Bounds); + }); + } + private class TestLayoutable : Layoutable { public Size ArrangeSize { get; private set; } From c0bd5078f57633a79e89888136c903843242225f Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sat, 31 May 2025 15:27:46 +0200 Subject: [PATCH 05/94] Don't round size in TextBlock when UseLayoutRounding is false (#18456) * Don't round size when UselayoutRounding is false for TextBlock. Fixes #18423 * Added back size rounding * Removed rounding again for the text size and fixed padding rounding in RenderCore --- src/Avalonia.Controls/TextBlock.cs | 31 +++++--- .../TextBlockTests.cs | 74 +++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 4067afb166..cc4cd02dfe 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -604,8 +604,13 @@ namespace Avalonia.Controls context.FillRectangle(background, new Rect(Bounds.Size)); } - var scale = LayoutHelper.GetLayoutScale(this); - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale); + var padding = Padding; + if (UseLayoutRounding) + { + var scale = LayoutHelper.GetLayoutScale(this); + padding = LayoutHelper.RoundLayoutThickness(padding, scale); + } + var top = padding.Top; var textHeight = TextLayout.Height; @@ -708,8 +713,14 @@ namespace Avalonia.Controls protected override Size MeasureOverride(Size availableSize) { - var scale = LayoutHelper.GetLayoutScale(this); - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale); + var padding = Padding; + + if (UseLayoutRounding) + { + var scale = LayoutHelper.GetLayoutScale(this); + padding = LayoutHelper.RoundLayoutThickness(Padding, scale); + } + var deflatedSize = availableSize.Deflate(padding); if (_constraint != deflatedSize) @@ -741,15 +752,17 @@ namespace Avalonia.Controls var textLayout = TextLayout; // The textWidth used here is matching that TextPresenter uses to measure the text. - var size = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height).Inflate(padding), 1); - - return size; + return new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height).Inflate(padding); } protected override Size ArrangeOverride(Size finalSize) { - var scale = LayoutHelper.GetLayoutScale(this); - var padding = LayoutHelper.RoundLayoutThickness(Padding, scale); + var padding = Padding; + if (UseLayoutRounding) + { + var scale = LayoutHelper.GetLayoutScale(this); + padding = LayoutHelper.RoundLayoutThickness(Padding, scale); + } var availableSize = finalSize.Deflate(padding); diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index fd854ed2b3..ee075e1cda 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -458,6 +458,80 @@ namespace Avalonia.Controls.UnitTests Assert.True(target.DesiredSize.Height > 0); } + [Fact] + public void TextBlock_With_UseLayoutRounding_True_Should_Round_DesiredSize() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "1980" }; + + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + Assert.Equal(target.DesiredSize, new Size(40, 10)); + } + + [Fact] + public void TextBlock_With_UseLayoutRounding_True_Should_Round_Padding_And_DesiredSize() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "1980", Padding = new(2.25) }; + + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + Assert.Equal(target.DesiredSize, new Size(44, 14)); + } + + [Fact] + public void TextBlock_With_UseLayoutRounding_False_Should_Not_Round_DesiredSize() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "1980", UseLayoutRounding = false }; + + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + Assert.Equal(target.DesiredSize, new Size(40, 9.6)); + } + + [Fact] + public void TextBlock_With_UseLayoutRounding_False_Should_Not_Round_Bounds() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "1980", UseLayoutRounding = false }; + + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + target.Arrange(new Rect(default, target.DesiredSize)); + + Assert.Equal(target.Bounds, new Rect(0, 0, 40, 9.6)); + } + + [Fact] + public void TextBlock_With_UseLayoutRounding_False_Should_Not_Round_Padding_In_MeasureOverride() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "1980", UseLayoutRounding = false, Padding = new(2.25) }; + + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + Assert.Equal(target.DesiredSize, new Size(44.5, 14.1)); + } + + [Fact] + public void TextBlock_With_UseLayoutRounding_False_Should_Not_Round_Padding_In_ArrangeOverride() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "1980", UseLayoutRounding = false, Padding = new(2.25) }; + + target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + target.Arrange(new Rect(default, target.DesiredSize)); + + Assert.Equal(target.Bounds, new Rect(0, 0, 44.5, 14.1)); + } + private class TestTextBlock : TextBlock { public Size Constraint => _constraint; From 3e01a53decf7af8ba33f70673bed33488c7b7acc Mon Sep 17 00:00:00 2001 From: Compunet <117437050+dme-compunet@users.noreply.github.com> Date: Sat, 31 May 2025 17:27:25 +0300 Subject: [PATCH 06/94] Reuse single HarfBuzz buffer in TextShaperImpl (#18892) --- src/Skia/Avalonia.Skia/TextShaperImpl.cs | 80 +++++++++++++----------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index cedb2f63cf..efce67e90b 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -14,6 +14,9 @@ namespace Avalonia.Skia { internal class TextShaperImpl : ITextShaperImpl { + [ThreadStatic] + private static Buffer? s_buffer; + private static readonly ConcurrentDictionary s_cachedLanguage = new(); public ShapedBuffer ShapeText(ReadOnlyMemory text, TextShaperOptions options) @@ -24,69 +27,70 @@ namespace Avalonia.Skia var bidiLevel = options.BidiLevel; var culture = options.Culture; - using (var buffer = new Buffer()) - { - // HarfBuzz needs the surrounding characters to correctly shape the text - var containingText = GetContainingMemory(text, out var start, out var length).Span; - buffer.AddUtf16(containingText, start, length); + var buffer = s_buffer ??= new Buffer(); - MergeBreakPair(buffer); + buffer.Reset(); - buffer.GuessSegmentProperties(); + // HarfBuzz needs the surrounding characters to correctly shape the text + var containingText = GetContainingMemory(text, out var start, out var length).Span; + buffer.AddUtf16(containingText, start, length); - buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft; + MergeBreakPair(buffer); - var usedCulture = culture ?? CultureInfo.CurrentCulture; + buffer.GuessSegmentProperties(); - buffer.Language = s_cachedLanguage.GetOrAdd(usedCulture.LCID, _ => new Language(usedCulture)); + buffer.Direction = (bidiLevel & 1) == 0 ? Direction.LeftToRight : Direction.RightToLeft; - var font = ((GlyphTypefaceImpl)typeface).Font; + var usedCulture = culture ?? CultureInfo.CurrentCulture; - font.Shape(buffer, GetFeatures(options)); + buffer.Language = s_cachedLanguage.GetOrAdd(usedCulture.LCID, _ => new Language(usedCulture)); - if (buffer.Direction == Direction.RightToLeft) - { - buffer.Reverse(); - } + var font = ((GlyphTypefaceImpl)typeface).Font; - font.GetScale(out var scaleX, out _); + font.Shape(buffer, GetFeatures(options)); - var textScale = fontRenderingEmSize / scaleX; + if (buffer.Direction == Direction.RightToLeft) + { + buffer.Reverse(); + } - var bufferLength = buffer.Length; + font.GetScale(out var scaleX, out _); - var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel); + var textScale = fontRenderingEmSize / scaleX; - var glyphInfos = buffer.GetGlyphInfoSpan(); + var bufferLength = buffer.Length; - var glyphPositions = buffer.GetGlyphPositionSpan(); + var shapedBuffer = new ShapedBuffer(text, bufferLength, typeface, fontRenderingEmSize, bidiLevel); - for (var i = 0; i < bufferLength; i++) - { - var sourceInfo = glyphInfos[i]; + var glyphInfos = buffer.GetGlyphInfoSpan(); - var glyphIndex = (ushort)sourceInfo.Codepoint; + var glyphPositions = buffer.GetGlyphPositionSpan(); - var glyphCluster = (int)sourceInfo.Cluster; + for (var i = 0; i < bufferLength; i++) + { + var sourceInfo = glyphInfos[i]; - var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale) + options.LetterSpacing; + var glyphIndex = (ushort)sourceInfo.Codepoint; - var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); + var glyphCluster = (int)sourceInfo.Cluster; - if (glyphCluster < containingText.Length && containingText[glyphCluster] == '\t') - { - glyphIndex = typeface.GetGlyph(' '); + var glyphAdvance = GetGlyphAdvance(glyphPositions, i, textScale) + options.LetterSpacing; - glyphAdvance = options.IncrementalTabWidth > 0 ? - options.IncrementalTabWidth : - 4 * typeface.GetGlyphAdvance(glyphIndex) * textScale; - } + var glyphOffset = GetGlyphOffset(glyphPositions, i, textScale); - shapedBuffer[i] = new Media.TextFormatting.GlyphInfo(glyphIndex, glyphCluster, glyphAdvance, glyphOffset); + if (glyphCluster < containingText.Length && containingText[glyphCluster] == '\t') + { + glyphIndex = typeface.GetGlyph(' '); + + glyphAdvance = options.IncrementalTabWidth > 0 ? + options.IncrementalTabWidth : + 4 * typeface.GetGlyphAdvance(glyphIndex) * textScale; } - return shapedBuffer; + shapedBuffer[i] = new Media.TextFormatting.GlyphInfo(glyphIndex, glyphCluster, glyphAdvance, glyphOffset); } + + return shapedBuffer; } private static void MergeBreakPair(Buffer buffer) From 40a045fed551dc3b5967a30d41f147a5f850f6ad Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 31 May 2025 12:48:17 -0700 Subject: [PATCH 07/94] Don't expect WasmRuntimeAssetsLocation to be always set (#18947) * Try to gracefully fallback when WasmRuntimeAssetsLocation is unset * Fix RenderWorker trimming annotations * Avoid reflection binding here --------- Co-authored-by: Julien Lebosquain --- samples/ControlCatalog/Pages/TabControlPage.xaml | 2 +- src/Browser/Avalonia.Browser/Rendering/RenderWorker.cs | 10 +++++++--- .../Avalonia.Browser/build/Avalonia.Browser.props | 3 +++ .../Avalonia.Browser/build/Avalonia.Browser.targets | 3 +-- .../build/Microsoft.AspNetCore.StaticWebAssets.props | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/samples/ControlCatalog/Pages/TabControlPage.xaml b/samples/ControlCatalog/Pages/TabControlPage.xaml index abcad56e9b..a3bacfd92a 100644 --- a/samples/ControlCatalog/Pages/TabControlPage.xaml +++ b/samples/ControlCatalog/Pages/TabControlPage.xaml @@ -9,7 +9,7 @@ - From 0bcf2265026d224b5c13bae3138264ebe26a40e0 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Sun, 29 Jun 2025 15:35:11 +0300 Subject: [PATCH 58/94] Properly check if operation is pending when executing/aborting (#19132) Co-authored-by: Julien Lebosquain --- .../Threading/Dispatcher.Invoke.cs | 3 +- .../Threading/Dispatcher.Queue.cs | 16 +++++++-- .../Threading/DispatcherOperation.cs | 33 +++++++++++-------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index 2a42caa467..324a50e4b4 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -445,7 +445,8 @@ public partial class Dispatcher // so it is safe to modify the operation outside of the lock. // Just mark the operation as aborted, which we can safely // return to the user. - operation.DoAbort(); + operation.Status = DispatcherOperationStatus.Aborted; + operation.CallAbortCallbacks(); } } diff --git a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs index 21b1ee8f3a..954183ffcc 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Queue.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Queue.cs @@ -134,7 +134,14 @@ public partial class Dispatcher private void ExecuteJob(DispatcherOperation job) { lock (InstanceLock) + { + if(job.Status != DispatcherOperationStatus.Pending) + return; + _queue.RemoveItem(job); + job.Status = DispatcherOperationStatus.Executing; + } + job.Execute(); // The backend might be firing timers with a low priority, // so we manually check if our high priority timers are due for execution @@ -236,11 +243,16 @@ public partial class Dispatcher } } - internal void Abort(DispatcherOperation operation) + internal bool Abort(DispatcherOperation operation) { lock (InstanceLock) + { + if (operation.Status != DispatcherOperationStatus.Pending) + return false; _queue.RemoveItem(operation); - operation.DoAbort(); + operation.Status = DispatcherOperationStatus.Aborted; + } + return true; } // Returns whether or not the priority was set. diff --git a/src/Avalonia.Base/Threading/DispatcherOperation.cs b/src/Avalonia.Base/Threading/DispatcherOperation.cs index 3bb28a17fe..14b0614113 100644 --- a/src/Avalonia.Base/Threading/DispatcherOperation.cs +++ b/src/Avalonia.Base/Threading/DispatcherOperation.cs @@ -11,7 +11,7 @@ namespace Avalonia.Threading; public class DispatcherOperation { protected readonly bool ThrowOnUiThread; - public DispatcherOperationStatus Status { get; protected set; } + public DispatcherOperationStatus Status { get; internal set; } public Dispatcher Dispatcher { get; } public DispatcherPriority Priority @@ -115,13 +115,13 @@ public class DispatcherOperation public bool Abort() { - lock (Dispatcher.InstanceLock) + if (Dispatcher.Abort(this)) { - if (Status != DispatcherOperationStatus.Pending) - return false; - Dispatcher.Abort(this); + CallAbortCallbacks(); return true; } + + return false; } /// @@ -254,20 +254,15 @@ public class DispatcherOperation return GetTask().GetAwaiter(); } - internal void DoAbort() + internal void CallAbortCallbacks() { - Status = DispatcherOperationStatus.Aborted; AbortTask(); _aborted?.Invoke(this, EventArgs.Empty); } internal void Execute() { - lock (Dispatcher.InstanceLock) - { - Status = DispatcherOperationStatus.Executing; - } - + Debug.Assert(Status == DispatcherOperationStatus.Executing); try { using (AvaloniaSynchronizationContext.Ensure(Dispatcher, Priority)) @@ -311,7 +306,19 @@ public class DispatcherOperation internal virtual object? GetResult() => null; - protected virtual void AbortTask() => (TaskSource as TaskCompletionSource)?.SetCanceled(); + protected virtual void AbortTask() + { + object? taskSource; + lock (Dispatcher.InstanceLock) + { + Debug.Assert(Status == DispatcherOperationStatus.Aborted); + // There is no way for TaskSource to become not-null after being null with aborted tasks, + // so it's safe to save it here and use after exiting the lock + taskSource = TaskSource; + } + + (taskSource as TaskCompletionSource)?.SetCanceled(); + } private static CancellationToken CreateCancelledToken() { From 5b1385da6a9b6dab99f575af4ca233080e505ed3 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 30 Jun 2025 00:03:25 +1000 Subject: [PATCH 59/94] remove some un-used code (#19146) Co-authored-by: Julien Lebosquain --- src/Avalonia.X11/X11Window.cs | 5 ----- .../AvaloniaHeadlessPlatform.cs | 2 -- .../HeadlessPlatformRenderInterface.cs | 18 --------------- .../Avalonia.Headless/HeadlessWindowImpl.cs | 14 ------------ src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 22 ------------------- .../NameGenerator/Options.cs | 8 ------- .../CompositionGenerator/Generator.Utils.cs | 17 -------------- .../CompositionGenerator/Generator.cs | 13 ----------- 8 files changed, 99 deletions(-) diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 7cf4d3e2bb..0739f89829 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -948,11 +948,6 @@ namespace Avalonia.X11 XSyncSetCounter(_x11.Display, _xSyncCounter, _xSyncValue); } } - - public void Invalidate(Rect rect) - { - - } public IInputRoot InputRoot => _inputRoot ?? throw new InvalidOperationException($"{nameof(SetInputRoot)} must have been called"); diff --git a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs index e85aaca138..75767988d7 100644 --- a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs +++ b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs @@ -62,8 +62,6 @@ namespace Avalonia.Headless public IWindowImpl CreateEmbeddableWindow() => throw new PlatformNotSupportedException(); - public IPopupImpl CreatePopup() => new HeadlessWindowImpl(true, _frameBufferFormat); - public ITrayIconImpl? CreateTrayIcon() => null; } diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index d018ef491f..2774bf63fe 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -23,8 +23,6 @@ namespace Avalonia.Headless .Bind().ToConstant(new HeadlessTextShaperStub()); } - public IEnumerable InstalledFontNames { get; } = new[] { "Tahoma" }; - public IPlatformRenderInterfaceContext CreateBackendContext(IPlatformGraphicsContext? graphicsContext) => this; public bool SupportsIndividualRoundRects => false; @@ -452,8 +450,6 @@ namespace Avalonia.Headless public Matrix Transform { get; set; } - public RenderOptions RenderOptions { get; set; } - public void Clear(Color color) { @@ -517,16 +513,6 @@ namespace Avalonia.Headless } - public void PushBitmapBlendMode(BitmapBlendingMode blendingMode) - { - - } - - public void PopBitmapBlendMode() - { - - } - public object? GetFeature(Type t) { return null; @@ -540,10 +526,6 @@ namespace Avalonia.Headless { } - public void DrawRectangle(IPen pen, Rect rect, float cornerRadius = 0) - { - } - public void DrawBitmap(IBitmapImpl source, double opacity, Rect sourceRect, Rect destRect) { diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index 7366d8235d..64ce9945d4 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -59,10 +59,6 @@ namespace Avalonia.Headless public Compositor Compositor => AvaloniaHeadlessPlatform.Compositor!; - public void Invalidate(Rect rect) - { - } - public void SetInputRoot(IInputRoot inputRoot) { InputRoot = inputRoot; @@ -96,16 +92,6 @@ namespace Avalonia.Headless Dispatcher.UIThread.Post(() => Deactivated?.Invoke(), DispatcherPriority.Input); } - public void BeginMoveDrag() - { - - } - - public void BeginResizeDrag(WindowEdge edge) - { - - } - public PixelPoint Position { get; set; } public Action? PositionChanged { get; set; } public void Activate() diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index ce5b59a86d..1fd3493e9c 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -1304,18 +1304,6 @@ namespace Avalonia.Skia return (byte)(r * 255); } - private static Color Blend(Color left, Color right) - { - var aa = left.A / 255d; - var ab = right.A / 255d; - return new Color( - (byte)((aa + ab * (1 - aa)) * 255), - Blend(left.R, left.A, right.R, right.A), - Blend(left.G, left.A, right.G, right.A), - Blend(left.B, left.A, right.B, right.A) - ); - } - internal PaintWrapper CreateAcrylicPaint (SKPaint paint, IExperimentalAcrylicMaterial material) { var paintWrapper = new PaintWrapper(paint); @@ -1535,16 +1523,6 @@ namespace Avalonia.Skia _disposable3 = null; } - public IDisposable ApplyTo(SKPaint paint) - { - var state = new PaintState(paint, paint.Color, paint.Shader); - - paint.Color = Paint.Color; - paint.Shader = Paint.Shader; - - return state; - } - /// /// Add new disposable to a wrapper. /// diff --git a/src/tools/Avalonia.Generators/NameGenerator/Options.cs b/src/tools/Avalonia.Generators/NameGenerator/Options.cs index abdaaab72b..76663a5681 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/Options.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/Options.cs @@ -1,13 +1,5 @@ namespace Avalonia.Generators.NameGenerator; -internal enum Options -{ - Public = 0, - Private = 1, - Internal = 2, - Protected = 3, -} - internal enum Behavior { OnlyProperties = 0, diff --git a/src/tools/DevGenerators/CompositionGenerator/Generator.Utils.cs b/src/tools/DevGenerators/CompositionGenerator/Generator.Utils.cs index e2c5d682c1..5b66eb3e25 100644 --- a/src/tools/DevGenerators/CompositionGenerator/Generator.Utils.cs +++ b/src/tools/DevGenerators/CompositionGenerator/Generator.Utils.cs @@ -8,13 +8,6 @@ namespace Avalonia.SourceGenerator.CompositionGenerator { public partial class Generator { - static void CleanDirectory(string path) - { - Directory.CreateDirectory(path); - Directory.Delete(path, true); - Directory.CreateDirectory(path); - } - CompilationUnitSyntax Unit() => CompilationUnit().WithUsings(List(new[] { @@ -40,16 +33,6 @@ namespace Avalonia.SourceGenerator.CompositionGenerator static SyntaxToken Semicolon() => Token(SyntaxKind.SemicolonToken); - - static FieldDeclarationSyntax DeclareConstant(string type, string name, LiteralExpressionSyntax value) - => FieldDeclaration( - VariableDeclaration(ParseTypeName(type), - SingletonSeparatedList( - VariableDeclarator(name).WithInitializer(EqualsValueClause(value)) - )) - ).WithSemicolonToken(Semicolon()) - .WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.ConstKeyword))); - static FieldDeclarationSyntax DeclareField(string type, string name, params SyntaxKind[] modifiers) => DeclareField(type, name, null, modifiers); diff --git a/src/tools/DevGenerators/CompositionGenerator/Generator.cs b/src/tools/DevGenerators/CompositionGenerator/Generator.cs index df2ea423de..2f27a752e2 100644 --- a/src/tools/DevGenerators/CompositionGenerator/Generator.cs +++ b/src/tools/DevGenerators/CompositionGenerator/Generator.cs @@ -530,19 +530,6 @@ return; return body.AddStatements(ParseStatement(code)); } - static ClassDeclarationSyntax WithGetPropertyForAnimation(ClassDeclarationSyntax cl, BlockSyntax body) - { - if (body.Statements.Count == 0) - return cl; - body = body.AddStatements( - ParseStatement("return base.GetPropertyForAnimation(name);")); - var method = ((MethodDeclarationSyntax) ParseMemberDeclaration( - $"public override Avalonia.Rendering.Composition.Expressions.ExpressionVariant GetPropertyForAnimation(string name){{}}")!) - .WithBody(body); - - return cl.AddMembers(method); - } - static ClassDeclarationSyntax WithGetCompositionProperty(ClassDeclarationSyntax cl, BlockSyntax body) { if (body.Statements.Count == 0) From e6ef371a9e6f4944126fc845a54a8c1834522aef Mon Sep 17 00:00:00 2001 From: walterlv Date: Wed, 2 Jul 2025 03:00:06 +0800 Subject: [PATCH 60/94] Avoid memory leak by clearing the shared array pool of LightweightObservableBase (#19167) --- src/Avalonia.Base/Reactive/LightweightObservableBase.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Base/Reactive/LightweightObservableBase.cs b/src/Avalonia.Base/Reactive/LightweightObservableBase.cs index 04759e314d..0688a4edb4 100644 --- a/src/Avalonia.Base/Reactive/LightweightObservableBase.cs +++ b/src/Avalonia.Base/Reactive/LightweightObservableBase.cs @@ -20,7 +20,7 @@ namespace Avalonia.Reactive private List>? _observers = new List>(); public bool HasObservers => _observers?.Count > 0; - + public IDisposable Subscribe(IObserver observer) { _ = observer ?? throw new ArgumentNullException(nameof(observer)); @@ -168,6 +168,8 @@ namespace Avalonia.Reactive for(int i = 0; i < count; i++) { observers[i].OnNext(value); + // Avoid memory leak by clearing the reference. + observers[i] = null!; } ArrayPool>.Shared.Return(observers); From c008fb80833d941b46e614bd6ea1b8b08d79e8b5 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 3 Jul 2025 03:45:51 -0700 Subject: [PATCH 61/94] Add OpenGlControlBase documentation (#19188) --- .../Controls/OpenGlControlBase.cs | 53 +++++++++++++++++-- src/Avalonia.OpenGL/GlInterface.cs | 13 +++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.OpenGL/Controls/OpenGlControlBase.cs b/src/Avalonia.OpenGL/Controls/OpenGlControlBase.cs index 9978a19165..5339744378 100644 --- a/src/Avalonia.OpenGL/Controls/OpenGlControlBase.cs +++ b/src/Avalonia.OpenGL/Controls/OpenGlControlBase.cs @@ -12,6 +12,21 @@ using System.ComponentModel; namespace Avalonia.OpenGL.Controls { + /// + /// Base class for controls that render using OpenGL. + /// Provides infrastructure for OpenGL context management, surface creation, and rendering lifecycle. + /// + /// + /// The control automatically manages OpenGL context creation, surface setup, and cleanup. + /// + /// Important: Any interaction with should only happen within the + /// , , or method overrides. + /// + /// + /// Avalonia ensures proper OpenGL context synchronization and makes the context current only during these method calls. + /// Accessing OpenGL functions outside of these methods may result in undefined behavior, crashes, or rendering corruption. + /// + /// public abstract class OpenGlControlBase : Control { private CompositionSurfaceVisual? _visual; @@ -23,8 +38,15 @@ namespace Avalonia.OpenGL.Controls [MemberNotNullWhen(true, nameof(_resources))] private bool IsInitializedSuccessfully => _initialization is { Status: TaskStatus.RanToCompletion, Result: true }; + + /// + /// Gets the OpenGL version information for the current context. + /// protected GlVersion GlVersion => _resources?.Context.Version ?? default; + /// + /// Initializes a new instance of the class. + /// public OpenGlControlBase() { _update = Update; @@ -57,12 +79,14 @@ namespace Avalonia.OpenGL.Controls _initialization = null; } + /// protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { DoCleanup(); base.OnDetachedFromVisualTree(e); } + /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); @@ -114,7 +138,8 @@ namespace Avalonia.OpenGL.Controls return true; } - + + /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { if (_visual != null && change.Property == BoundsProperty) @@ -220,10 +245,14 @@ namespace Avalonia.OpenGL.Controls return true; } + /// [Obsolete("Use RequestNextFrameRendering()"), EditorBrowsable(EditorBrowsableState.Never)] // ReSharper disable once MemberCanBeProtected.Global public new void InvalidateVisual() => RequestNextFrameRendering(); + /// + /// Requests that the control be rendered on the next frame. + /// public void RequestNextFrameRendering() { if ((_initialization == null || IsInitializedSuccessfully) && @@ -240,22 +269,38 @@ namespace Avalonia.OpenGL.Controls return new PixelSize(Math.Max(1, (int)(Bounds.Width * scaling)), Math.Max(1, (int)(Bounds.Height * scaling))); } - + + /// + /// Called when the OpenGL context is first created. + /// + /// The interface for making OpenGL calls. Use to access additional APIs not covered by . protected virtual void OnOpenGlInit(GlInterface gl) { } + /// + /// Called when the OpenGL context is being destroyed. + /// + /// The OpenGL interface for making OpenGL calls. Use to access additional APIs not covered by . protected virtual void OnOpenGlDeinit(GlInterface gl) { } - + + /// + /// Called when the OpenGL context is lost and cannot be recovered. + /// protected virtual void OnOpenGlLost() { } - + + /// + /// Called to render the OpenGL content for the current frame. + /// + /// The OpenGL interface for making OpenGL calls. Use to access additional APIs not covered by . + /// The framebuffer ID to render into. protected abstract void OnOpenGlRender(GlInterface gl, int fb); } } diff --git a/src/Avalonia.OpenGL/GlInterface.cs b/src/Avalonia.OpenGL/GlInterface.cs index 26bea6206d..12a5ef733e 100644 --- a/src/Avalonia.OpenGL/GlInterface.cs +++ b/src/Avalonia.OpenGL/GlInterface.cs @@ -2,12 +2,20 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; +using Avalonia.Metadata; using Avalonia.Platform.Interop; using Avalonia.SourceGenerator; using static Avalonia.OpenGL.GlConsts; namespace Avalonia.OpenGL { + /// + /// GlInterface only includes essential members and members necessary for Avalonia itself. + /// It is not a general-purpose interface for OpenGL API. + /// + /// + /// Use to get GL procedures you need, or integrate it with third-party GL wrappers. + /// public unsafe partial class GlInterface : GlBasicInfoInterface { private readonly Func _getProcAddress; @@ -50,6 +58,11 @@ namespace Avalonia.OpenGL { } + /// + /// Returns an OpenGL function by name. + /// + /// Function name. + /// Handle of function, which can be casted to unmanaged function pointer. public IntPtr GetProcAddress(string proc) => _getProcAddress(proc); [GetProcAddress("glClearStencil")] From fb83c11b00d9706c8190590205a74c74ee8ac703 Mon Sep 17 00:00:00 2001 From: Egor Rudakov <28976936+EgorRudakov2@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:00:45 +0300 Subject: [PATCH 62/94] Fix random NRE inside `Compositor.CommitCore()` callback. (#19173) * Prevent ContinueWith from scheduling some of rendering callbacks into UI thread. (#19170 fix) * Add _pendingBatch null check --- src/Avalonia.Base/Media/MediaContext.Compositor.cs | 2 +- .../Rendering/Composition/CompositingRenderer.cs | 3 ++- src/Avalonia.Base/Rendering/Composition/Compositor.cs | 7 ++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Base/Media/MediaContext.Compositor.cs b/src/Avalonia.Base/Media/MediaContext.Compositor.cs index 85f7df2587..69663d33b9 100644 --- a/src/Avalonia.Base/Media/MediaContext.Compositor.cs +++ b/src/Avalonia.Base/Media/MediaContext.Compositor.cs @@ -31,7 +31,7 @@ partial class MediaContext _pendingCompositionBatches[compositor] = commit; commit.Processed.ContinueWith(_ => _dispatcher.Post(() => CompositionBatchFinished(compositor, commit), DispatcherPriority.Send), - TaskContinuationOptions.ExecuteSynchronously); + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); return commit; } diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index 98fb147c2c..6a0396e52a 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Numerics; +using System.Threading; using System.Threading.Tasks; using Avalonia.Collections; using Avalonia.Collections.Pooled; @@ -187,7 +188,7 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester { _queuedSceneInvalidation = false; SceneInvalidated?.Invoke(this, new SceneInvalidatedEventArgs(_root, new Rect(_root.ClientSize))); - }, DispatcherPriority.Input), TaskContinuationOptions.ExecuteSynchronously); + }, DispatcherPriority.Input), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } diff --git a/src/Avalonia.Base/Rendering/Composition/Compositor.cs b/src/Avalonia.Base/Rendering/Composition/Compositor.cs index 257e41f2d6..e8cd29f195 100644 --- a/src/Avalonia.Base/Rendering/Composition/Compositor.cs +++ b/src/Avalonia.Base/Rendering/Composition/Compositor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; using Avalonia.Animation.Easings; @@ -114,7 +115,7 @@ namespace Avalonia.Rendering.Composition if (pending != null) pending.Processed.ContinueWith( _ => Dispatcher.Post(_triggerCommitRequested, DispatcherPriority.Send), - TaskContinuationOptions.ExecuteSynchronously); + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); else _triggerCommitRequested(); } @@ -202,10 +203,10 @@ namespace Avalonia.Rendering.Composition { lock (_pendingBatchLock) { - if (_pendingBatch.Processed == t) + if (_pendingBatch?.Processed == t) _pendingBatch = null; } - }, TaskContinuationOptions.ExecuteSynchronously); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); _nextCommit = null; return commit; From 88bfecbee03f1bddbcc193b1890c9ac0665b6b2f Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 4 Jul 2025 00:33:29 -0700 Subject: [PATCH 63/94] Rename "Documentation" subfolder into "docs" (#19189) --- {Documentation => docs}/api-compat.md | 0 {Documentation => docs}/build.md | 0 {Documentation => docs}/debug-xaml-compiler.md | 0 .../images/xcode-product-path.png | Bin {Documentation => docs}/index.md | 0 {Documentation => docs}/macos-native.md | 0 .../porting-code-from-3rd-party-sources.md | 0 {Documentation => docs}/release.md | 0 readme.md | 2 +- 9 files changed, 1 insertion(+), 1 deletion(-) rename {Documentation => docs}/api-compat.md (100%) rename {Documentation => docs}/build.md (100%) rename {Documentation => docs}/debug-xaml-compiler.md (100%) rename {Documentation => docs}/images/xcode-product-path.png (100%) rename {Documentation => docs}/index.md (100%) rename {Documentation => docs}/macos-native.md (100%) rename {Documentation => docs}/porting-code-from-3rd-party-sources.md (100%) rename {Documentation => docs}/release.md (100%) diff --git a/Documentation/api-compat.md b/docs/api-compat.md similarity index 100% rename from Documentation/api-compat.md rename to docs/api-compat.md diff --git a/Documentation/build.md b/docs/build.md similarity index 100% rename from Documentation/build.md rename to docs/build.md diff --git a/Documentation/debug-xaml-compiler.md b/docs/debug-xaml-compiler.md similarity index 100% rename from Documentation/debug-xaml-compiler.md rename to docs/debug-xaml-compiler.md diff --git a/Documentation/images/xcode-product-path.png b/docs/images/xcode-product-path.png similarity index 100% rename from Documentation/images/xcode-product-path.png rename to docs/images/xcode-product-path.png diff --git a/Documentation/index.md b/docs/index.md similarity index 100% rename from Documentation/index.md rename to docs/index.md diff --git a/Documentation/macos-native.md b/docs/macos-native.md similarity index 100% rename from Documentation/macos-native.md rename to docs/macos-native.md diff --git a/Documentation/porting-code-from-3rd-party-sources.md b/docs/porting-code-from-3rd-party-sources.md similarity index 100% rename from Documentation/porting-code-from-3rd-party-sources.md rename to docs/porting-code-from-3rd-party-sources.md diff --git a/Documentation/release.md b/docs/release.md similarity index 100% rename from Documentation/release.md rename to docs/release.md diff --git a/readme.md b/readme.md index 372b939642..5efd1258bd 100644 --- a/readme.md +++ b/readme.md @@ -76,7 +76,7 @@ We have a [range of samples](https://github.com/AvaloniaUI/Avalonia.Samples) to ## Building and Using -See the [build instructions here](Documentation/build.md). +See the [build instructions here](docs/build.md). ## Contributing From b644cee98f10c65ec227a1656c220375a6ed2ab4 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 5 Jul 2025 00:58:30 -0700 Subject: [PATCH 64/94] [12.0] Target SkiaSharp 3.0. Drop 2.88 support (#18981) * Retarget to SkiaSharp 3.0 * Replace SKFilterQuality with SKSamplingOptions * Replace obsolete GRBackendRenderTarget ctor * Replace SkiaMetalApi reflection with stable APIs * Use SKMatrix4x4 where it makes more sense perf wise * Add CS0618 warning as error for Skia * Fix ToSKSamplingOptions implementation * Remove hacky compile time condition * Update API compat * Remove maccatalyst hack --------- Co-authored-by: Julien Lebosquain --- api/Avalonia.Skia.nupkg.xml | 6 + build/SkiaSharp.props | 7 +- src/Skia/Avalonia.Skia/Avalonia.Skia.csproj | 2 + src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 13 ++- .../Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs | 108 ------------------ .../Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs | 15 +-- .../VulkanSkiaExternalObjectsFeature.cs | 4 +- .../Gpu/Vulkan/VulkanSkiaRenderTarget.cs | 2 +- src/Skia/Avalonia.Skia/IDrawableBitmapImpl.cs | 3 +- src/Skia/Avalonia.Skia/ImmutableBitmap.cs | 10 +- src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs | 56 ++++++--- src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs | 7 +- src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs | 6 +- .../Metal/MetalPlatformGraphics.cs | 12 -- tests/Avalonia.RenderTests/TestBase.cs | 6 - .../Avalonia.Skia.RenderTests.csproj | 5 - 16 files changed, 83 insertions(+), 179 deletions(-) delete mode 100644 src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs diff --git a/api/Avalonia.Skia.nupkg.xml b/api/Avalonia.Skia.nupkg.xml index ed29e880a4..b275cbff58 100644 --- a/api/Avalonia.Skia.nupkg.xml +++ b/api/Avalonia.Skia.nupkg.xml @@ -1,6 +1,12 @@  + + CP0002 + M:Avalonia.Skia.SkiaSharpExtensions.ToSKFilterQuality(Avalonia.Media.Imaging.BitmapInterpolationMode) + baseline/netstandard2.0/Avalonia.Skia.dll + target/netstandard2.0/Avalonia.Skia.dll + CP0006 M:Avalonia.Skia.ISkiaGpuWithPlatformGraphicsContext.TryGetGrContext diff --git a/build/SkiaSharp.props b/build/SkiaSharp.props index 5b643efab7..74339fb125 100644 --- a/build/SkiaSharp.props +++ b/build/SkiaSharp.props @@ -1,10 +1,5 @@  - - - - - - + diff --git a/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj b/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj index f30056c8d9..d1f77823e2 100644 --- a/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj +++ b/src/Skia/Avalonia.Skia/Avalonia.Skia.csproj @@ -4,6 +4,8 @@ true true true + + $(WarningsAsErrors);CS0618 diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 1fd3493e9c..70d6e2c10f 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -246,12 +246,12 @@ namespace Avalonia.Skia var d = destRect.ToSKRect(); var paint = SKPaintCache.Shared.Get(); + var samplingOptions = RenderOptions.BitmapInterpolationMode.ToSKSamplingOptions(); paint.Color = new SKColor(255, 255, 255, (byte)(255 * opacity * _currentOpacity)); - paint.FilterQuality = RenderOptions.BitmapInterpolationMode.ToSKFilterQuality(); paint.BlendMode = RenderOptions.BitmapBlendingMode.ToSKBlendMode(); - drawableImage.Draw(this, s, d, paint); + drawableImage.Draw(this, s, d, samplingOptions, paint); SKPaintCache.Shared.ReturnReset(paint); } @@ -844,7 +844,9 @@ namespace Avalonia.Skia /// public Matrix Transform { - get { return _currentTransform ??= Canvas.TotalMatrix.ToAvaloniaMatrix(); } + // There is a Canvas.TotalMatrix (non 4x4 overload), but internally it still uses 4x4 matrix. + // We want to avoid SKMatrix4x4 -> SKMatrix -> Matrix conversion by directly going SKMatrix4x4 -> Matrix. + get { return _currentTransform ??= Canvas.TotalMatrix44.ToAvaloniaMatrix(); } set { CheckLease(); @@ -860,7 +862,9 @@ namespace Avalonia.Skia transform *= _postTransform.Value; } - Canvas.SetMatrix(transform.ToSKMatrix()); + // Canvas.SetMatrix internally uses 4x4 matrix, even with SKMatrix(3x3) overload. + // We want to avoid Matrix -> SKMatrix -> SKMatrix4x4 conversion by directly going Matrix -> SKMatrix4x4. + Canvas.SetMatrix(transform.ToSKMatrix44()); } } @@ -1257,7 +1261,6 @@ namespace Avalonia.Skia using(var shader = tile.ToShader(tileX, tileY, shaderTransform.ToSKMatrix(), new SKRect(0, 0, tile.CullRect.Width, tile.CullRect.Height))) { - paintWrapper.Paint.FilterQuality = SKFilterQuality.None; paintWrapper.Paint.Shader = shader; } } diff --git a/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs b/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs deleted file mode 100644 index d5e2352e13..0000000000 --- a/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalApi.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using Avalonia.Compatibility; -using Avalonia.Platform.Interop; -using SkiaSharp; -using BindingFlags = System.Reflection.BindingFlags; - -namespace Avalonia.Skia.Metal; - -internal unsafe class SkiaMetalApi -{ - delegate* unmanaged[Stdcall] _gr_direct_context_make_metal_with_options; - private delegate* unmanaged[Stdcall] - _gr_backendrendertarget_new_metal; - private readonly ConstructorInfo _contextCtor; - private readonly MethodInfo _contextOptionsToNative; - private readonly ConstructorInfo _renderTargetCtor; - - [DynamicDependency(DynamicallyAccessedMemberTypes.NonPublicConstructors, typeof(GRContext))] - [DynamicDependency(DynamicallyAccessedMemberTypes.NonPublicConstructors, typeof(GRBackendRenderTarget))] - [DynamicDependency(DynamicallyAccessedMemberTypes.NonPublicMethods, typeof(GRContextOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, "SkiaSharp.GRContextOptionsNative", "SkiaSharp")] - public SkiaMetalApi() - { - // Make sure that skia is loaded - GC.KeepAlive(new SKPaint()); - - // https://github.com/mono/SkiaSharp/blob/25e70a390e2128e5a54d28795365bf9fdaa7161c/binding/SkiaSharp/SkiaApi.cs#L9-L13 - // Note, IsIOS also returns true on MacCatalyst. - var libSkiaSharpPath = OperatingSystemEx.IsIOS() || OperatingSystemEx.IsTvOS() ? - "@rpath/libSkiaSharp.framework/libSkiaSharp" : - "libSkiaSharp"; - var dll = NativeLibraryEx.Load(libSkiaSharpPath, typeof(SKPaint).Assembly); - - IntPtr address; - - if (NativeLibraryEx.TryGetExport(dll, "gr_direct_context_make_metal_with_options", out address)) - { - _gr_direct_context_make_metal_with_options = - (delegate* unmanaged[Stdcall] )address; - } - else - { - throw new InvalidOperationException( - "Unable to export gr_direct_context_make_metal_with_options. Make sure SkiaSharp is up to date."); - } - - if(NativeLibraryEx.TryGetExport(dll, "gr_backendrendertarget_new_metal", out address)) - { - _gr_backendrendertarget_new_metal = - (delegate* unmanaged[Stdcall])address; - } - else - { - throw new InvalidOperationException( - "Unable to export gr_backendrendertarget_new_metal. Make sure SkiaSharp is up to date."); - } - - _contextCtor = typeof(GRContext).GetConstructor( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, - new[] { typeof(IntPtr), typeof(bool) }, null) ?? throw new MissingMemberException("GRContext.ctor(IntPtr,bool)"); - - - _renderTargetCtor = typeof(GRBackendRenderTarget).GetConstructor( - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, - new[] { typeof(IntPtr), typeof(bool) }, null) ?? throw new MissingMemberException("GRContext.ctor(IntPtr,bool)"); - - _contextOptionsToNative = typeof(GRContextOptions).GetMethod("ToNative", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - ?? throw new MissingMemberException("GRContextOptions.ToNative()"); - } - - [UnconditionalSuppressMessage("Trimming", "IL3050", Justification = "We have DynamicDependency above.")] - public GRContext CreateContext(IntPtr device, IntPtr queue, GRContextOptions? options) - { - options ??= new(); - var nativeOptions = _contextOptionsToNative.Invoke(options, null)!; - var gcHandle = GCHandle.Alloc(nativeOptions, GCHandleType.Pinned); - try - { - var context = _gr_direct_context_make_metal_with_options(device, queue, gcHandle.AddrOfPinnedObject()); - if (context == IntPtr.Zero) - throw new InvalidOperationException("Unable to create GRContext from Metal device."); - return (GRContext)_contextCtor.Invoke(new object[] { context, true }); - } - finally - { - gcHandle.Free(); - } - } - - internal struct GRMtlTextureInfoNative - { - public IntPtr Texture; - } - - public GRBackendRenderTarget CreateBackendRenderTarget(int width, int height, int samples, IntPtr texture) - { - var info = new GRMtlTextureInfoNative() { Texture = texture }; - var target = _gr_backendrendertarget_new_metal(width, height, samples, &info); - if (target == IntPtr.Zero) - throw new InvalidOperationException("Unable to create GRBackendRenderTarget"); - return (GRBackendRenderTarget)_renderTargetCtor.Invoke(new object[] { target, true }); - } -} diff --git a/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs b/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs index bfd6e1aa59..8faa754ef8 100644 --- a/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs +++ b/src/Skia/Avalonia.Skia/Gpu/Metal/SkiaMetalGpu.cs @@ -9,14 +9,15 @@ namespace Avalonia.Skia.Metal; internal class SkiaMetalGpu : ISkiaGpu, ISkiaGpuWithPlatformGraphicsContext { - private SkiaMetalApi _api = new(); private GRContext? _context; private readonly IMetalDevice _device; public SkiaMetalGpu(IMetalDevice device, long? maxResourceBytes) { - _context = _api.CreateContext(device.Device, device.CommandQueue, - new GRContextOptions() { AvoidStencilBuffers = true }); + _context = GRContext.CreateMetal( + new GRMtlBackendContext { DeviceHandle = device.Device, QueueHandle = device.CommandQueue, }, + new GRContextOptions { AvoidStencilBuffers = true }) + ?? throw new InvalidOperationException("Unable to create GRContext from Metal device."); _device = device; if (maxResourceBytes.HasValue) _context.SetResourceCacheLimit(maxResourceBytes.Value); @@ -35,7 +36,7 @@ internal class SkiaMetalGpu : ISkiaGpu, ISkiaGpuWithPlatformGraphicsContext public IPlatformGraphicsContext? PlatformGraphicsContext => _device; public IScopedResource TryGetGrContext() => - ScopedResource.Create(_context ?? throw new ObjectDisposedException(nameof(SkiaMetalApi)), + ScopedResource.Create(_context ?? throw new ObjectDisposedException(nameof(SkiaMetalGpu)), EnsureCurrent().Dispose); public ISkiaGpuRenderTarget? TryCreateRenderTarget(IEnumerable surfaces) @@ -72,13 +73,13 @@ internal class SkiaMetalGpu : ISkiaGpu, ISkiaGpuWithPlatformGraphicsContext public ISkiaGpuRenderSession BeginRenderingSession() { var session = (_target ?? throw new ObjectDisposedException(nameof(SkiaMetalRenderTarget))).BeginRendering(); - var backendTarget = _gpu._api.CreateBackendRenderTarget(session.Size.Width, session.Size.Height, - 1, session.Texture); + var backendTarget = new GRBackendRenderTarget(session.Size.Width, session.Size.Height, + new GRMtlTextureInfo(session.Texture)); var surface = SKSurface.Create(_gpu._context!, backendTarget, session.IsYFlipped ? GRSurfaceOrigin.BottomLeft : GRSurfaceOrigin.TopLeft, SKColorType.Bgra8888); - + return new SkiaMetalRenderSession(_gpu, surface, session); } diff --git a/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaExternalObjectsFeature.cs b/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaExternalObjectsFeature.cs index 5c5427b8d9..16f705e088 100644 --- a/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaExternalObjectsFeature.cs +++ b/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaExternalObjectsFeature.cs @@ -93,7 +93,7 @@ internal class VulkanSkiaExternalObjectsFeature : IExternalObjectsRenderInterfac Size = info.MemorySize } }; - using var renderTarget = new GRBackendRenderTarget(_properties.Width, _properties.Height, 1, imageInfo); + using var renderTarget = new GRBackendRenderTarget(_properties.Width, _properties.Height, imageInfo); using var surface = SKSurface.Create(_gpu.GrContext, renderTarget, _properties.TopLeftOrigin ? GRSurfaceOrigin.TopLeft : GRSurfaceOrigin.BottomLeft, _properties.Format == PlatformGraphicsExternalImageFormat.R8G8B8A8UNorm @@ -121,4 +121,4 @@ internal class VulkanSkiaExternalObjectsFeature : IExternalObjectsRenderInterfac public IReadOnlyList SupportedImageHandleTypes => _feature.SupportedImageHandleTypes; public IReadOnlyList SupportedSemaphoreTypes => _feature.SupportedSemaphoreTypes; -} \ No newline at end of file +} diff --git a/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs b/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs index b761d21b4e..c86a1204f3 100644 --- a/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/Gpu/Vulkan/VulkanSkiaRenderTarget.cs @@ -54,7 +54,7 @@ class VulkanSkiaRenderTarget : ISkiaGpuRenderTarget Size = sessionImageInfo.MemorySize } }; - using var renderTarget = new GRBackendRenderTarget(size.Width, size.Height, 1, imageInfo); + using var renderTarget = new GRBackendRenderTarget(size.Width, size.Height, imageInfo); var surface = SKSurface.Create(_gpu.GrContext, renderTarget, session.IsYFlipped ? GRSurfaceOrigin.TopLeft : GRSurfaceOrigin.BottomLeft, session.IsRgba ? SKColorType.Rgba8888 : SKColorType.Bgra8888, SKColorSpace.CreateSrgb()); diff --git a/src/Skia/Avalonia.Skia/IDrawableBitmapImpl.cs b/src/Skia/Avalonia.Skia/IDrawableBitmapImpl.cs index be751021d5..9fe6f9d7e3 100644 --- a/src/Skia/Avalonia.Skia/IDrawableBitmapImpl.cs +++ b/src/Skia/Avalonia.Skia/IDrawableBitmapImpl.cs @@ -14,7 +14,8 @@ namespace Avalonia.Skia /// Drawing context. /// Source rect. /// Destination rect. + /// Interpolation sampling options. /// Paint to use. - void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKPaint paint); + void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKSamplingOptions samplingOptions, SKPaint paint); } } diff --git a/src/Skia/Avalonia.Skia/ImmutableBitmap.cs b/src/Skia/Avalonia.Skia/ImmutableBitmap.cs index ec645692b2..c8068fb6fa 100644 --- a/src/Skia/Avalonia.Skia/ImmutableBitmap.cs +++ b/src/Skia/Avalonia.Skia/ImmutableBitmap.cs @@ -50,7 +50,7 @@ namespace Avalonia.Skia { SKImageInfo info = new SKImageInfo(destinationSize.Width, destinationSize.Height, SKColorType.Bgra8888); _bitmap = new SKBitmap(info); - src._image.ScalePixels(_bitmap.PeekPixels(), interpolationMode.ToSKFilterQuality()); + src._image.ScalePixels(_bitmap.PeekPixels(), interpolationMode.ToSKSamplingOptions()); _bitmap.SetImmutable(); _image = SKImage.FromBitmap(_bitmap); @@ -95,11 +95,11 @@ namespace Avalonia.Skia if (_bitmap.Width != desired.Width || _bitmap.Height != desired.Height) { - var scaledBmp = _bitmap.Resize(desired, interpolationMode.ToSKFilterQuality()); + var scaledBmp = _bitmap.Resize(desired, interpolationMode.ToSKSamplingOptions()); _bitmap.Dispose(); _bitmap = scaledBmp; } - + _bitmap.SetImmutable(); _image = SKImage.FromBitmap(_bitmap); @@ -171,9 +171,9 @@ namespace Avalonia.Skia } /// - public void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKPaint paint) + public void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKSamplingOptions samplingOptions, SKPaint paint) { - context.Canvas.DrawImage(_image, sourceRect, destRect, paint); + context.Canvas.DrawImage(_image, sourceRect, destRect, samplingOptions, paint); } public PixelFormat? Format => _bitmap?.ColorType.ToAvalonia(); diff --git a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs index add72caa30..177dbf5cba 100644 --- a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs +++ b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs @@ -9,22 +9,20 @@ namespace Avalonia.Skia { public static class SkiaSharpExtensions { - public static SKFilterQuality ToSKFilterQuality(this BitmapInterpolationMode interpolationMode) + public static SKSamplingOptions ToSKSamplingOptions(this BitmapInterpolationMode interpolationMode) { - switch (interpolationMode) + return interpolationMode switch { - case BitmapInterpolationMode.Unspecified: - case BitmapInterpolationMode.LowQuality: - return SKFilterQuality.Low; - case BitmapInterpolationMode.MediumQuality: - return SKFilterQuality.Medium; - case BitmapInterpolationMode.HighQuality: - return SKFilterQuality.High; - case BitmapInterpolationMode.None: - return SKFilterQuality.None; - default: - throw new ArgumentOutOfRangeException(nameof(interpolationMode), interpolationMode, null); - } + BitmapInterpolationMode.None => + new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), + BitmapInterpolationMode.Unspecified or BitmapInterpolationMode.LowQuality => + new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.None), + BitmapInterpolationMode.MediumQuality => + new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), + BitmapInterpolationMode.HighQuality => + new SKSamplingOptions(SKCubicResampler.Mitchell), + _ => throw new ArgumentOutOfRangeException(nameof(interpolationMode), interpolationMode, null) + }; } public static SKBlendMode ToSKBlendMode(this BitmapBlendingMode blendingMode) @@ -146,11 +144,41 @@ namespace Avalonia.Skia return sm; } + public static SKMatrix44 ToSKMatrix44(this Matrix m) + { + var sm = new SKMatrix44 + { + M00 = (float)m.M11, + M01 = (float)m.M12, + M02 = 0, + M03 = (float)m.M13, + M10 = (float)m.M21, + M11 = (float)m.M22, + M12 = 0, + M13 = (float)m.M23, + M20 = 0, + M21 = 0, + M22 = 1, + M23 = 0, + M30 = (float)m.M31, + M31 = (float)m.M32, + M32 = 0, + M33 = (float)m.M33 + }; + + return sm; + } + internal static Matrix ToAvaloniaMatrix(this SKMatrix m) => new( m.ScaleX, m.SkewY, m.Persp0, m.SkewX, m.ScaleY, m.Persp1, m.TransX, m.TransY, m.Persp2); + internal static Matrix ToAvaloniaMatrix(this SKMatrix44 m) => new( + m.M00, m.M01, m.M03, + m.M10, m.M11, m.M13, + m.M30, m.M31, m.M33); + public static SKColor ToSKColor(this Color c) { return new SKColor(c.R, c.G, c.B, c.A); diff --git a/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs b/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs index 27e36d99b3..87aebc2df1 100644 --- a/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs @@ -2,7 +2,6 @@ using System; using System.IO; using Avalonia.Reactive; using Avalonia.Platform; -using Avalonia.Rendering; using Avalonia.Skia.Helpers; using SkiaSharp; @@ -164,12 +163,12 @@ namespace Avalonia.Skia public bool CanBlit => true; /// - public void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKPaint paint) + public void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKSamplingOptions samplingOptions, SKPaint paint) { using var image = SnapshotImage(); - context.Canvas.DrawImage(image, sourceRect, destRect, paint); + context.Canvas.DrawImage(image, sourceRect, destRect, samplingOptions, paint); } - + /// /// Create Skia image snapshot from a surface. /// diff --git a/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs b/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs index 313c7e06de..387b84046e 100644 --- a/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs +++ b/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs @@ -73,7 +73,7 @@ namespace Avalonia.Skia if (bmp.Width != desired.Width || bmp.Height != desired.Height) { - var scaledBmp = bmp.Resize(desired, interpolationMode.ToSKFilterQuality()); + var scaledBmp = bmp.Resize(desired, interpolationMode.ToSKSamplingOptions()); bmp.Dispose(); bmp = scaledBmp; } @@ -118,7 +118,7 @@ namespace Avalonia.Skia public int Version { get; private set; } = 1; /// - public void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKPaint paint) + public void Draw(DrawingContextImpl context, SKRect sourceRect, SKRect destRect, SKSamplingOptions samplingOptions, SKPaint paint) { lock (_lock) { @@ -132,7 +132,7 @@ namespace Avalonia.Skia _image = GetSnapshot(); _imageValid = true; } - context.Canvas.DrawImage(_image, sourceRect, destRect, paint); + context.Canvas.DrawImage(_image, sourceRect, destRect, samplingOptions, paint); } } diff --git a/src/iOS/Avalonia.iOS/Metal/MetalPlatformGraphics.cs b/src/iOS/Avalonia.iOS/Metal/MetalPlatformGraphics.cs index fb5ffc862c..23e5b08b03 100644 --- a/src/iOS/Avalonia.iOS/Metal/MetalPlatformGraphics.cs +++ b/src/iOS/Avalonia.iOS/Metal/MetalPlatformGraphics.cs @@ -2,7 +2,6 @@ using System; using System.Runtime.Versioning; using Avalonia.Platform; using Metal; -using SkiaSharp; namespace Avalonia.iOS.Metal; @@ -33,17 +32,6 @@ internal class MetalPlatformGraphics : IPlatformGraphics return null; } -#if !TVOS - using var queue = device.CreateCommandQueue(); - using var context = GRContext.CreateMetal(new GRMtlBackendContext { Device = device, Queue = queue }); - if (context is null) - { - // Can be null on macCatalyst because of older Skia bug. - // Fixed in SkiaSharp 3.0 - return null; - } -#endif - return new MetalPlatformGraphics(device); } } diff --git a/tests/Avalonia.RenderTests/TestBase.cs b/tests/Avalonia.RenderTests/TestBase.cs index e808611aec..2e753ec8b4 100644 --- a/tests/Avalonia.RenderTests/TestBase.cs +++ b/tests/Avalonia.RenderTests/TestBase.cs @@ -42,13 +42,7 @@ namespace Avalonia.Direct2D1.RenderTests #endif public static FontFamily TestFontFamily = new FontFamily(s_fontUri); -#if AVALONIA_SKIA3 - // TODO: investigate why output is different. - // Most likely we need to use new SKSamplingOptions API, as old filters are broken with SKBitmap. - private const double AllowedError = 0.15; -#else private const double AllowedError = 0.022; -#endif public TestBase(string outputPath) { diff --git a/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj b/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj index 60082ce2c8..233160bd36 100644 --- a/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj +++ b/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj @@ -5,11 +5,6 @@ true true true - - - true - $(DefineConstants);AVALONIA_SKIA3 - $(DefineConstants);AVALONIA_SKIA2 From 8acea1733bc645782fbeaac62e02351db01fe743 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Mon, 7 Jul 2025 10:51:52 +0200 Subject: [PATCH 65/94] Share Avalonia.Controls with Avalonia.Controls.Documents (#19191) * Share internals with Avalonia.Constrols.Documents * Inlines might be null * Use IAddChild --------- Co-authored-by: Julien Lebosquain --- build/ExternalConsumers.props | 1 + src/Avalonia.Controls/Documents/Span.cs | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/build/ExternalConsumers.props b/build/ExternalConsumers.props index 96cf5cc608..79df2f6be4 100644 --- a/build/ExternalConsumers.props +++ b/build/ExternalConsumers.props @@ -30,5 +30,6 @@ + diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index a1d35d06c7..e3f9a1825e 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -9,7 +9,7 @@ namespace Avalonia.Controls.Documents /// /// Span element used for grouping other Inline elements. /// - public class Span : Inline + public class Span : Inline, IAddChild, IAddChild, IAddChild { /// /// Defines the property. @@ -96,5 +96,20 @@ namespace Avalonia.Controls.Documents void OnInlinesInvalidated(object? sender, EventArgs e) => InlineHost?.Invalidate(); } + + void IAddChild.AddChild(Inline inline) + { + Inlines?.Add(inline); + } + + void IAddChild.AddChild(Control child) + { + Inlines?.Add(new InlineUIContainer(child)); + } + + void IAddChild.AddChild(string text) + { + Inlines?.Add(new Run(text)); + } } } From f31d7a567aeb6343b141ca6d30c59b84691c4807 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Tue, 8 Jul 2025 01:29:42 +0200 Subject: [PATCH 66/94] Use hard links for build (#19209) --- Directory.Build.props | 4 ++++ tests/BuildTests/Directory.Build.props | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 117c0964d2..f124456eab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,5 +9,9 @@ false False 12 + true + true + true + true diff --git a/tests/BuildTests/Directory.Build.props b/tests/BuildTests/Directory.Build.props index 667403a447..a2225dfff6 100644 --- a/tests/BuildTests/Directory.Build.props +++ b/tests/BuildTests/Directory.Build.props @@ -3,6 +3,10 @@ true false + true + true + true + true From 647f3e0de7f7d6e51f1493278c5be56fddbfb027 Mon Sep 17 00:00:00 2001 From: bui bao long <62802719+longbuibao@users.noreply.github.com> Date: Tue, 8 Jul 2025 08:24:24 +0700 Subject: [PATCH 67/94] Feat/19059 add automation for validation error (#19161) * try to add Automation/Peers/DataValidationErrorsAutomationPeer.cs * add missing file * change to use .ToString() instead of fileter only string type * 19059 resolve maxkatz6's comment * 19059 resolve comment * 19059 check if GetTip return null or whitespace * 19059 take the error higher piority than the tooltip --------- Co-authored-by: Long Bui Co-authored-by: Max Katz --- .../Automation/Peers/ControlAutomationPeer.cs | 11 +++++++++-- src/Avalonia.Controls/DataValidationErrors.cs | 1 - 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs index 0dff2db3c2..7115399647 100644 --- a/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs +++ b/src/Avalonia.Controls/Automation/Peers/ControlAutomationPeer.cs @@ -117,15 +117,22 @@ namespace Avalonia.Automation.Peers return result; } protected override string? GetHelpTextCore() - { + { var result = AutomationProperties.GetHelpText(Owner); + if (string.IsNullOrWhiteSpace(result)) + { + var errors = DataValidationErrors.GetErrors(Owner); + var errorsStringList = errors?.Select(x => x.ToString()); + result = errorsStringList != null ? string.Join(Environment.NewLine, errorsStringList.ToArray()) : null; + } + if (string.IsNullOrWhiteSpace(result)) { result = ToolTip.GetTip(Owner) as string; } - return result; + return result; } protected override AutomationPeer? GetParentCore() { diff --git a/src/Avalonia.Controls/DataValidationErrors.cs b/src/Avalonia.Controls/DataValidationErrors.cs index f11c621030..21e9eae26d 100644 --- a/src/Avalonia.Controls/DataValidationErrors.cs +++ b/src/Avalonia.Controls/DataValidationErrors.cs @@ -5,7 +5,6 @@ using Avalonia.Reactive; using Avalonia.Controls.Metadata; using Avalonia.Controls.Templates; using Avalonia.Data; - namespace Avalonia.Controls { /// From 44295dd73c244383f49781ed421c18884f1053de Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 8 Jul 2025 08:34:42 +0200 Subject: [PATCH 68/94] Fix TextLineIImpl.GetTextBounds for clustered trailing zero width characters (#19208) * Make sure we only apply the cluster offset if we are inside the current cluster * Better naming * Guard coveredLength against invalid values --------- Co-authored-by: Max Katz --- .../Media/TextFormatting/TextLineImpl.cs | 11 ++++-- .../TextFormatting/TextFormatterTests.cs | 2 +- .../Media/TextFormatting/TextLineTests.cs | 36 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index f13fd26f27..31cddd7bf4 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -706,6 +706,11 @@ namespace Avalonia.Media.TextFormatting lastBounds = currentBounds; + if(coveredLength <= 0) + { + throw new InvalidOperationException("Covered length must be greater than zero."); + } + remainingLength -= coveredLength; } @@ -1090,7 +1095,8 @@ namespace Avalonia.Media.TextFormatting var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); //Adjust characterLength by the cluster offset to only cover the remaining length of the cluster. - var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset; + var characterLength = Math.Max(0, Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - + endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset); if (characterLength == 0 && currentRun.Text.Length > 0 && startIndex < currentRun.Text.Length) { @@ -1172,7 +1178,8 @@ namespace Avalonia.Media.TextFormatting startIndex -= clusterOffset; } - var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset; + var characterLength = Math.Max(0, Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - + endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset); if (characterLength == 0 && currentRun.Text.Length > 0 && startIndex < currentRun.Text.Length) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index da00ce0672..39bf60a42c 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -1158,7 +1158,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } - private class ListTextSource : ITextSource + internal class ListTextSource : ITextSource { private readonly List _runs; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index dcf285263e..27fb7d754b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -1715,6 +1715,42 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Should_GetTextBounds_For_Clustered_Zero_Width_Characters() + { + const string text = "\r\n"; + + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + + var textSource = new TextFormatterTests.ListTextSource(new TextHidden(1) ,new TextCharacters(text, defaultProperties)); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left, + true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0)); + + Assert.NotNull(textLine); + + var textBounds = textLine.GetTextBounds(2, 1); + + Assert.NotEmpty(textBounds); + + var firstBounds = textBounds[0]; + + Assert.NotEmpty(firstBounds.TextRunBounds); + + var firstRunBounds = firstBounds.TextRunBounds[0]; + + Assert.Equal(2, firstRunBounds.TextSourceCharacterIndex); + + Assert.Equal(1, firstRunBounds.Length); + } + } + private class FixedRunsTextSource : ITextSource { private readonly IReadOnlyList _textRuns; From 7808957a65d1f05abec5edbaaea1c78724dd034d Mon Sep 17 00:00:00 2001 From: SeWZC <36623158+SeWZC@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:31:47 +0800 Subject: [PATCH 69/94] Fix: Unable to input numbers, symbols, and English letters in Avalonia on X11 with fcitx5 ForwardKey messages (#19207) --- .../DBusIme/Fcitx/FcitxX11TextInputMethod.cs | 6 ++-- src/Avalonia.FreeDesktop/IX11InputMethod.cs | 1 + src/Avalonia.X11/X11Window.Ime.cs | 29 +++++++++++++------ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs index ac4da69a07..f50c33a5d8 100644 --- a/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs +++ b/src/Avalonia.FreeDesktop/DBusIme/Fcitx/FcitxX11TextInputMethod.cs @@ -180,13 +180,15 @@ namespace Avalonia.FreeDesktop.DBusIme.Fcitx mods |= KeyModifiers.Shift; if (state.HasAllFlags(FcitxKeyState.FcitxKeyState_Super)) mods |= KeyModifiers.Meta; + var isPressKey = ev.type == (int)FcitxKeyEventType.FCITX_PRESS_KEY; FireForward(new X11InputMethodForwardedKey { Modifiers = mods, KeyVal = (int)ev.keyval, - Type = ev.type == (int)FcitxKeyEventType.FCITX_PRESS_KEY ? + Type = isPressKey ? RawKeyEventType.KeyDown : - RawKeyEventType.KeyUp + RawKeyEventType.KeyUp, + WithText = isPressKey, }); } diff --git a/src/Avalonia.FreeDesktop/IX11InputMethod.cs b/src/Avalonia.FreeDesktop/IX11InputMethod.cs index 7274b58876..51176485c0 100644 --- a/src/Avalonia.FreeDesktop/IX11InputMethod.cs +++ b/src/Avalonia.FreeDesktop/IX11InputMethod.cs @@ -18,6 +18,7 @@ namespace Avalonia.FreeDesktop public int KeyVal { get; set; } public KeyModifiers Modifiers { get; set; } public RawKeyEventType Type { get; set; } + public bool WithText { get; set; } } internal interface IX11InputMethodControl : IDisposable diff --git a/src/Avalonia.X11/X11Window.Ime.cs b/src/Avalonia.X11/X11Window.Ime.cs index a5a0df926a..768a79af3d 100644 --- a/src/Avalonia.X11/X11Window.Ime.cs +++ b/src/Avalonia.X11/X11Window.Ime.cs @@ -88,15 +88,26 @@ namespace Avalonia.X11 var x11Key = (X11Key)forwardedKey.KeyVal; var keySymbol = _x11.HasXkb ? GetKeySymbolXkb(x11Key) : GetKeySymbolXCore(x11Key); - ScheduleInput(new RawKeyEventArgs( - _keyboard, - (ulong)_x11.LastActivityTimestamp.ToInt64(), - InputRoot, - forwardedKey.Type, - X11KeyTransform.KeyFromX11Key(x11Key), - (RawInputModifiers)forwardedKey.Modifiers, - PhysicalKey.None, - keySymbol)); + ScheduleInput(forwardedKey.WithText ? + new RawKeyEventArgsWithText( + _keyboard, + (ulong)_x11.LastActivityTimestamp.ToInt64(), + InputRoot, + forwardedKey.Type, + X11KeyTransform.KeyFromX11Key(x11Key), + (RawInputModifiers)forwardedKey.Modifiers, + PhysicalKey.None, + keySymbol, + keySymbol) : + new RawKeyEventArgs( + _keyboard, + (ulong)_x11.LastActivityTimestamp.ToInt64(), + InputRoot, + forwardedKey.Type, + X11KeyTransform.KeyFromX11Key(x11Key), + (RawInputModifiers)forwardedKey.Modifiers, + PhysicalKey.None, + keySymbol)); } private void UpdateImePosition() => _imeControl?.UpdateWindowInfo(_position ?? default, RenderScaling); From dc19c39df5b9c1cf69c29625da44ffa245dc44e5 Mon Sep 17 00:00:00 2001 From: Ahmed Elsayed Date: Thu, 10 Jul 2025 10:21:35 +0300 Subject: [PATCH 70/94] fix: Selecting multiple lines in RTL textbox (#19093) * Reverse text runs for RTL flow direction * Optimize text run traversal. * Remove unused LINQ directive from TextLineImpl.cs * Add RTL newline handling test in TextLineTests * Add RTL newline handling tests for text formatting. --- .../Media/TextFormatting/TextLineImpl.cs | 6 ++- .../Media/TextFormatting/TextLineTests.cs | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 31cddd7bf4..3a4e83663a 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1415,10 +1415,12 @@ namespace Avalonia.Media.TextFormatting } var width = widthIncludingWhitespace; + var isRtl = _paragraphProperties.FlowDirection == FlowDirection.RightToLeft; - for (var i = _textRuns.Length - 1; i >= 0; i--) + for (int i = 0; i < _textRuns.Length; i++) { - var currentRun = _textRuns[i]; + var index = isRtl ? i : _textRuns.Length - 1 - i; + var currentRun = _textRuns[index]; if (currentRun is ShapedTextRun shapedText) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 27fb7d754b..b690386422 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -1163,6 +1163,57 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + + [Fact] + public void Should_Handle_NewLine_In_RTL_Text() + { + using (Start()) + { + var typeface = Typeface.Default; + + var defaultProperties = new GenericTextRunProperties(typeface); + + var textSource = new SingleBufferTextSource("test\r\n", defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(FlowDirection.RightToLeft, TextAlignment.Right, + true, true, defaultProperties, TextWrapping.Wrap, 0, 0, 0)); + + Assert.NotNull(textLine); + + Assert.NotEqual(textLine.NewLineLength, 0); + + } + } + + [Theory] + [InlineData("hello\r\nworld")] + [InlineData("مرحباً\r\nبالعالم")] + [InlineData("hello مرحباً\r\nworld بالعالم")] + [InlineData("مرحباً hello\r\nبالعالم nworld")] + public void Should_Set_NewLineLength_For_CRLF_In_RTL_Text(string text) + { + using (Start()) + { + var typeface = Typeface.Default; + var defaultProperties = new GenericTextRunProperties(typeface); + var textSource = new SingleBufferTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(FlowDirection.RightToLeft, TextAlignment.Right, + true, true, defaultProperties, TextWrapping.Wrap, 0, 0, 0)); + + Assert.NotNull(textLine); + Assert.NotEqual(0, textLine.NewLineLength); + } + } + private class TextHidden : TextRun { public TextHidden(int length) From 01e09e4f988956859c6f8f083bc1b98bd75409ab Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Thu, 10 Jul 2025 09:08:14 +0000 Subject: [PATCH 71/94] Revert 15603, add InputPaneActivationRequested in TextInputMethodClient (#19225) * revert 15603, add InputPaneActivationRequested in TextInputMethodClient * forward ShowInputPanel call to RaiseInputPaneActivationRequested --- .../Platform/Input/AndroidInputMethod.cs | 17 ++++++++++++- .../Input/TextInput/TextInputMethodClient.cs | 17 ++++++++++++- src/Avalonia.Controls/TextBox.cs | 2 -- .../TextBoxTextInputMethodClient.cs | 24 ++++++------------- .../BrowserTextInputMethod.cs | 19 +++++++++++++-- .../NuiAvaloniaViewTextEditable.cs | 8 +++++++ 6 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs index 1223fb9ece..8003db6607 100644 --- a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs @@ -72,6 +72,13 @@ namespace Avalonia.Android.Platform.Input public void SetClient(TextInputMethodClient? client) { + if(_client != null) + { + _client.SurroundingTextChanged -= _client_SurroundingTextChanged; + _client.SelectionChanged -= _client_SelectionChanged; + _client.InputPaneActivationRequested -= _client_InputPaneActivationRequested; + } + _client = client; if (IsActive) @@ -86,16 +93,24 @@ namespace Avalonia.Android.Platform.Input _client.SurroundingTextChanged += _client_SurroundingTextChanged; _client.SelectionChanged += _client_SelectionChanged; + _client.InputPaneActivationRequested += _client_InputPaneActivationRequested; } else { - _host.ClearFocus(); _imm.RestartInput(View); _inputConnection = null; _imm.HideSoftInputFromWindow(_host.WindowToken, HideSoftInputFlags.ImplicitOnly); } } + private void _client_InputPaneActivationRequested(object? sender, EventArgs e) + { + if(IsActive) + { + _imm.ShowSoftInput(_host, ShowFlags.Implicit); + } + } + private void _client_SelectionChanged(object? sender, EventArgs e) { if (_inputConnection is null || _inputConnection.IsInBatchEdit || _inputConnection.IsInUpdate) diff --git a/src/Avalonia.Base/Input/TextInput/TextInputMethodClient.cs b/src/Avalonia.Base/Input/TextInput/TextInputMethodClient.cs index ca91b861a8..7f9870315b 100644 --- a/src/Avalonia.Base/Input/TextInput/TextInputMethodClient.cs +++ b/src/Avalonia.Base/Input/TextInput/TextInputMethodClient.cs @@ -28,6 +28,11 @@ namespace Avalonia.Input.TextInput /// Fires when client wants to reset IME state /// public event EventHandler? ResetRequested; + + /// + /// Fires when client requests the input panel be opened. + /// + public event EventHandler? InputPaneActivationRequested; /// /// The visual that's showing the text @@ -78,7 +83,12 @@ namespace Avalonia.Input.TextInput SetPreeditText(preeditText); } - public virtual void ShowInputPanel() { } + //TODO12: remove + [Obsolete] + public virtual void ShowInputPanel() + { + RaiseInputPaneActivationRequested(); + } protected virtual void RaiseTextViewVisualChanged() { @@ -99,6 +109,11 @@ namespace Avalonia.Input.TextInput { SelectionChanged?.Invoke(this, EventArgs.Empty); } + + protected virtual void RaiseInputPaneActivationRequested() + { + InputPaneActivationRequested?.Invoke(this, EventArgs.Empty); + } protected virtual void RequestReset() { diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index a5728d44ea..3927fe3833 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1791,8 +1791,6 @@ namespace Avalonia.Controls if (e.Pointer.Type != PointerType.Mouse && !_isDoubleTapped) { - _imClient.ShowInputPanel(); - var text = Text; var clickInfo = e.GetCurrentPoint(this); if (text != null && !(clickInfo.Pointer?.Captured is Border)) diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 5d35124c69..12e8e97640 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -13,7 +13,6 @@ namespace Avalonia.Controls private TextPresenter? _presenter; private bool _selectionChanged; private bool _isInChange; - private ITextInputMethodImpl? _im; public override Visual TextViewVisual => _presenter!; @@ -119,6 +118,7 @@ namespace Avalonia.Controls if (_parent != null) { _parent.PropertyChanged -= OnParentPropertyChanged; + _parent.Tapped -= OnParentTapped; } _parent = parent; @@ -126,11 +126,7 @@ namespace Avalonia.Controls if (_parent != null) { _parent.PropertyChanged += OnParentPropertyChanged; - _im = (_parent.VisualRoot as ITextInputMethodRoot)?.InputMethod; - } - else - { - _im = null; + _parent.Tapped += OnParentTapped; } var oldPresenter = _presenter; @@ -154,6 +150,11 @@ namespace Avalonia.Controls RaiseCursorRectangleChanged(); } + private void OnParentTapped(object? sender, Input.TappedEventArgs e) + { + RaiseInputPaneActivationRequested(); + } + public override void SetPreeditText(string? preeditText) => SetPreeditText(preeditText, null); public override void SetPreeditText(string? preeditText, int? cursorPos) @@ -167,17 +168,6 @@ namespace Avalonia.Controls _presenter.SetCurrentValue(TextPresenter.PreeditTextCursorPositionProperty, cursorPos); } - public override void ShowInputPanel() - { - base.ShowInputPanel(); - - if (_parent is { } && _im is { }) - { - _im.SetOptions(TextInputOptions.FromStyledElement(_parent)); - _im.SetClient(this); - } - } - private static string GetTextLineText(TextLine textLine) { if (textLine.Length == 0) diff --git a/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs b/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs index fbcbf15ee5..11722851b7 100644 --- a/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs +++ b/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs @@ -29,11 +29,13 @@ internal class BrowserTextInputMethod( if (_client != null) { _client.SurroundingTextChanged -= SurroundingTextChanged; + _client.InputPaneActivationRequested -= InputPaneActivationRequested; } if (client != null) { client.SurroundingTextChanged += SurroundingTextChanged; + client.InputPaneActivationRequested += InputPaneActivationRequested; } InputHelper.ClearInputElement(_inputElement); @@ -42,8 +44,7 @@ internal class BrowserTextInputMethod( if (_client != null) { - InputHelper.ShowElement(_inputElement); - InputHelper.FocusElement(_inputElement); + ShowIme(); var surroundingText = _client.SurroundingText ?? ""; var selection = _client.Selection; @@ -56,6 +57,20 @@ internal class BrowserTextInputMethod( } } + private void InputPaneActivationRequested(object? sender, EventArgs e) + { + if (_client != null) + { + ShowIme(); + } + } + + private void ShowIme() + { + InputHelper.ShowElement(_inputElement); + InputHelper.FocusElement(_inputElement); + } + private void SurroundingTextChanged(object? sender, EventArgs e) { if (_client != null) diff --git a/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs b/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs index c3e7577674..c7e05afc52 100644 --- a/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs +++ b/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs @@ -118,6 +118,7 @@ internal class NuiAvaloniaViewTextEditable client.TextViewVisualChanged += OnTextViewVisualChanged; client.SurroundingTextChanged += OnSurroundingTextChanged; client.SelectionChanged += OnClientSelectionChanged; + client.InputPaneActivationRequested += OnInputPaneActivationRequested; TextInput.SelectWholeText(); OnClientSelectionChanged(this, EventArgs.Empty); @@ -125,6 +126,12 @@ internal class NuiAvaloniaViewTextEditable finally { _updating = false; } } + private void OnInputPaneActivationRequested(object? sender, EventArgs e) + { + var inputContext = TextInput.GetInputMethodContext(); + inputContext.ShowInputPanel(); + } + private void OnClientSelectionChanged(object? sender, EventArgs e) => InvokeUpdate(client => { if (client.Selection.End == 0 || client.Selection.Start == client.Selection.End) @@ -152,6 +159,7 @@ internal class NuiAvaloniaViewTextEditable _client!.TextViewVisualChanged -= OnTextViewVisualChanged; _client!.SurroundingTextChanged -= OnSurroundingTextChanged; _client!.SelectionChanged -= OnClientSelectionChanged; + _client!.InputPaneActivationRequested -= OnInputPaneActivationRequested; } if (Window.Instance.GetDefaultLayer().Children.Contains((View)TextInput)) From ba6faa32a7f223b9e923cf868ebe3d1307103b02 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Thu, 10 Jul 2025 09:55:22 +0000 Subject: [PATCH 72/94] made mobile textbox contextmenu transient (#19182) Co-authored-by: Julien Lebosquain --- src/Avalonia.Themes.Fluent/Controls/TextBox.xaml | 4 ++-- src/Avalonia.Themes.Simple/Controls/TextBox.xaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml b/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml index c9f4bc6b31..b1ef58bcf7 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml @@ -21,12 +21,12 @@ m10.051 7.0032c2.215 0 4.0105 1.7901 4.0105 3.9984s-1.7956 3.9984-4.0105 3.9984c-2.215 0-4.0105-1.7901-4.0105-3.9984s1.7956-3.9984 4.0105-3.9984zm0 1.4994c-1.3844 0-2.5066 1.1188-2.5066 2.499s1.1222 2.499 2.5066 2.499 2.5066-1.1188 2.5066-2.499-1.1222-2.499-2.5066-2.499zm0-5.0026c4.6257 0 8.6188 3.1487 9.7267 7.5613 0.10085 0.40165-0.14399 0.80877-0.54686 0.90931-0.40288 0.10054-0.81122-0.14355-0.91208-0.54521-0.94136-3.7492-4.3361-6.4261-8.2678-6.4261-3.9334 0-7.3292 2.6792-8.2689 6.4306-0.10063 0.40171-0.50884 0.64603-0.91177 0.54571s-0.648-0.5073-0.54737-0.90901c1.106-4.4152 5.1003-7.5667 9.728-7.5667z m0.21967 0.21965c-0.26627 0.26627-0.29047 0.68293-0.07262 0.97654l0.07262 0.08412 4.0346 4.0346c-1.922 1.3495-3.3585 3.365-3.9554 5.7495-0.10058 0.4018 0.14362 0.8091 0.54543 0.9097 0.40182 0.1005 0.80909-0.1436 0.90968-0.5455 0.52947-2.1151 1.8371-3.8891 3.5802-5.0341l1.8096 1.8098c-0.70751 0.7215-1.1438 1.71-1.1438 2.8003 0 2.2092 1.7909 4 4 4 1.0904 0 2.0788-0.4363 2.8004-1.1438l5.9193 5.9195c0.2929 0.2929 0.7677 0.2929 1.0606 0 0.2663-0.2662 0.2905-0.6829 0.0726-0.9765l-0.0726-0.0841-6.1135-6.1142 0.0012-0.0015-1.2001-1.1979-2.8699-2.8693 2e-3 -8e-4 -2.8812-2.8782 0.0012-0.0018-1.1333-1.1305-4.3064-4.3058c-0.29289-0.29289-0.76777-0.29289-1.0607 0zm7.9844 9.0458 3.5351 3.5351c-0.45 0.4358-1.0633 0.704-1.7392 0.704-1.3807 0-2.5-1.1193-2.5-2.5 0-0.6759 0.26824-1.2892 0.7041-1.7391zm1.7959-5.7655c-1.0003 0-1.9709 0.14807-2.8889 0.425l1.237 1.2362c0.5358-0.10587 1.0883-0.16119 1.6519-0.16119 3.9231 0 7.3099 2.6803 8.2471 6.4332 0.1004 0.4018 0.5075 0.6462 0.9094 0.5459 0.4019-0.1004 0.6463-0.5075 0.5459-0.9094-1.103-4.417-5.0869-7.5697-9.7024-7.5697zm0.1947 3.5093 3.8013 3.8007c-0.1018-2.0569-1.7488-3.7024-3.8013-3.8007z - + - + diff --git a/src/Avalonia.Themes.Simple/Controls/TextBox.xaml b/src/Avalonia.Themes.Simple/Controls/TextBox.xaml index b5cce6e93e..867d5367c3 100644 --- a/src/Avalonia.Themes.Simple/Controls/TextBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/TextBox.xaml @@ -5,7 +5,7 @@ m10.051 7.0032c2.215 0 4.0105 1.7901 4.0105 3.9984s-1.7956 3.9984-4.0105 3.9984c-2.215 0-4.0105-1.7901-4.0105-3.9984s1.7956-3.9984 4.0105-3.9984zm0 1.4994c-1.3844 0-2.5066 1.1188-2.5066 2.499s1.1222 2.499 2.5066 2.499 2.5066-1.1188 2.5066-2.499-1.1222-2.499-2.5066-2.499zm0-5.0026c4.6257 0 8.6188 3.1487 9.7267 7.5613 0.10085 0.40165-0.14399 0.80877-0.54686 0.90931-0.40288 0.10054-0.81122-0.14355-0.91208-0.54521-0.94136-3.7492-4.3361-6.4261-8.2678-6.4261-3.9334 0-7.3292 2.6792-8.2689 6.4306-0.10063 0.40171-0.50884 0.64603-0.91177 0.54571s-0.648-0.5073-0.54737-0.90901c1.106-4.4152 5.1003-7.5667 9.728-7.5667z m0.21967 0.21965c-0.26627 0.26627-0.29047 0.68293-0.07262 0.97654l0.07262 0.08412 4.0346 4.0346c-1.922 1.3495-3.3585 3.365-3.9554 5.7495-0.10058 0.4018 0.14362 0.8091 0.54543 0.9097 0.40182 0.1005 0.80909-0.1436 0.90968-0.5455 0.52947-2.1151 1.8371-3.8891 3.5802-5.0341l1.8096 1.8098c-0.70751 0.7215-1.1438 1.71-1.1438 2.8003 0 2.2092 1.7909 4 4 4 1.0904 0 2.0788-0.4363 2.8004-1.1438l5.9193 5.9195c0.2929 0.2929 0.7677 0.2929 1.0606 0 0.2663-0.2662 0.2905-0.6829 0.0726-0.9765l-0.0726-0.0841-6.1135-6.1142 0.0012-0.0015-1.2001-1.1979-2.8699-2.8693 2e-3 -8e-4 -2.8812-2.8782 0.0012-0.0018-1.1333-1.1305-4.3064-4.3058c-0.29289-0.29289-0.76777-0.29289-1.0607 0zm7.9844 9.0458 3.5351 3.5351c-0.45 0.4358-1.0633 0.704-1.7392 0.704-1.3807 0-2.5-1.1193-2.5-2.5 0-0.6759 0.26824-1.2892 0.7041-1.7391zm1.7959-5.7655c-1.0003 0-1.9709 0.14807-2.8889 0.425l1.237 1.2362c0.5358-0.10587 1.0883-0.16119 1.6519-0.16119 3.9231 0 7.3099 2.6803 8.2471 6.4332 0.1004 0.4018 0.5075 0.6462 0.9094 0.5459 0.4019-0.1004 0.6463-0.5075 0.5459-0.9094-1.103-4.417-5.0869-7.5697-9.7024-7.5697zm0.1947 3.5093 3.8013 3.8007c-0.1018-2.0569-1.7488-3.7024-3.8013-3.8007z - + Date: Sun, 13 Jul 2025 17:50:30 +0200 Subject: [PATCH 73/94] Update readme.md Updated VS Marketplace link --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 5efd1258bd..08fd014798 100644 --- a/readme.md +++ b/readme.md @@ -28,7 +28,7 @@ You can also see what [breaking changes](https://github.com/AvaloniaUI/Avalonia/ See our [Get Started](https://avaloniaui.net/gettingstarted?utm_source=github&utm_medium=referral&utm_content=readme_link) guide to begin developing apps with Avalonia UI. ### Visual Studio -The Avalonia [Visual Studio Extension](https://marketplace.visualstudio.com/items?itemName=AvaloniaTeam.AvaloniaforVisualStudio) contains project and control templates that will help you get started, or you can use the .NET Core CLI. For a starter guide see our [documentation](https://docs.avaloniaui.net/docs/getting-started). +The Avalonia [Visual Studio Extension](https://marketplace.visualstudio.com/items?itemName=AvaloniaTeam.AvaloniaVS) contains project and control templates that will help you get started, or you can use the .NET Core CLI. For a starter guide see our [documentation](https://docs.avaloniaui.net/docs/getting-started). ### JetBrains Rider [JetBrains Rider](https://www.jetbrains.com/rider/whatsnew/?mkt_tok=eyJpIjoiTURBNU1HSmhNV0kwTUdFMiIsInQiOiJtNnU2VEc1TlNLa1ZRVkROYmdZYVpYREJsaU1qdUhmS3dxSzRHczdYWHl0RVlTNDMwSFwvNUs3VENTNVM0bVcyNFdaRmVYZzVWTTF1N3VrQWNGTkJreEhlam1hMlB4UVVWcHBGM1dNOUxoXC95YnRQdGgyUXl1YmZCM3h3d3BVWWdBIn0%3D#avalonia-support) now has official support for Avalonia. From 91199c0e77df8da364e22adc88bfaf2becb556b0 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Tue, 22 Jul 2025 03:02:51 +0500 Subject: [PATCH 74/94] Stopgap fix for (#19302) https://github.com/AvaloniaUI/Avalonia/issues/19291 --- src/Avalonia.X11/X11Clipboard.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.X11/X11Clipboard.cs b/src/Avalonia.X11/X11Clipboard.cs index 3dea3f812d..911d80db19 100644 --- a/src/Avalonia.X11/X11Clipboard.cs +++ b/src/Avalonia.X11/X11Clipboard.cs @@ -304,7 +304,7 @@ namespace Avalonia.X11 public Task ClearAsync() { - return SetTextAsync(null); + return SetTextAsync(string.Empty); } public Task SetDataObjectAsync(IDataObject data) From a15eae6833b934f2470d1af1c78fec896a19dc72 Mon Sep 17 00:00:00 2001 From: Vadym Artemchuk Date: Tue, 22 Jul 2025 11:37:08 +0300 Subject: [PATCH 75/94] explicit dispose skia objects (#19100) --- src/Skia/Avalonia.Skia/Gpu/OpenGl/FboSkiaSurface.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Skia/Avalonia.Skia/Gpu/OpenGl/FboSkiaSurface.cs b/src/Skia/Avalonia.Skia/Gpu/OpenGl/FboSkiaSurface.cs index 4a3031d9ad..d9027e24d7 100644 --- a/src/Skia/Avalonia.Skia/Gpu/OpenGl/FboSkiaSurface.cs +++ b/src/Skia/Avalonia.Skia/Gpu/OpenGl/FboSkiaSurface.cs @@ -89,10 +89,10 @@ namespace Avalonia.Skia throw new OpenGlException("Unable to create FBO with stencil"); } - var target = new GRBackendRenderTarget(pixelSize.Width, pixelSize.Height, 0, 8, + using var target = new GRBackendRenderTarget(pixelSize.Width, pixelSize.Height, 0, 8, new GRGlFramebufferInfo((uint)_fbo, SKColorType.Rgba8888.ToGlSizedFormat())); - _surface = SKSurface.Create(_grContext, target, - surfaceOrigin, SKColorType.Rgba8888, new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal)); + using var properties = new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal); + _surface = SKSurface.Create(_grContext, target, surfaceOrigin, SKColorType.Rgba8888, properties); CanBlit = gl.IsBlitFramebufferAvailable; } From ec0edda7c30701909d393d99180b16e2aaf65c27 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Sun, 27 Jul 2025 22:27:51 +0500 Subject: [PATCH 76/94] [X11] Added INCR support (#18428) * Refactor X11 clipboard to use session-based approach * INCR client * Implemented INCR server * Detect INCR threshold * missing return * Handle review comments --------- Co-authored-by: Julien Lebosquain --- .../Clipboard/ClipboardReadSession.cs | 152 ++++++++++++++ .../Clipboard/EventStreamWindow.cs | 110 ++++++++++ .../{ => Clipboard}/X11Clipboard.cs | 198 +++++++++--------- src/Avalonia.X11/X11Platform.cs | 2 +- src/Avalonia.X11/XLib.cs | 6 + 5 files changed, 370 insertions(+), 98 deletions(-) create mode 100644 src/Avalonia.X11/Clipboard/ClipboardReadSession.cs create mode 100644 src/Avalonia.X11/Clipboard/EventStreamWindow.cs rename src/Avalonia.X11/{ => Clipboard}/X11Clipboard.cs (67%) diff --git a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs new file mode 100644 index 0000000000..7f964e8007 --- /dev/null +++ b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs @@ -0,0 +1,152 @@ +using System; +using System.Buffers; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using static Avalonia.X11.XLib; +namespace Avalonia.X11.Clipboard; + +class ClipboardReadSession : IDisposable +{ + private readonly AvaloniaX11Platform _platform; + private readonly EventStreamWindow _window; + private readonly X11Info _x11; + + public ClipboardReadSession(AvaloniaX11Platform platform) + { + _platform = platform; + _window = new EventStreamWindow(platform); + _x11 = _platform.Info; + XSelectInput(_x11.Display, _window.Handle, new IntPtr((int)XEventMask.PropertyChangeMask)); + } + + public void Dispose() => _window.Dispose(); + + class PropertyReadResult(IntPtr data, IntPtr actualTypeAtom, int actualFormat, IntPtr nItems) + : IDisposable + { + public IntPtr Data => data; + public IntPtr ActualTypeAtom => actualTypeAtom; + public int ActualFormat => actualFormat; + public IntPtr NItems => nItems; + + public void Dispose() + { + XFree(Data); + } + } + + private async Task + WaitForSelectionNotifyAndGetProperty(IntPtr property) + { + var ev = await _window.WaitForEventAsync(ev => + ev.type == XEventName.SelectionNotify + && ev.SelectionEvent.selection == _x11.Atoms.CLIPBOARD + && ev.SelectionEvent.property == property + ); + + if (ev == null) + return null; + + var sel = ev.Value.SelectionEvent; + + return ReadProperty(sel.property); + } + + private PropertyReadResult ReadProperty(IntPtr property) + { + XGetWindowProperty(_x11.Display, _window.Handle, property, IntPtr.Zero, new IntPtr (0x7fffffff), true, + (IntPtr)Atom.AnyPropertyType, + out var actualTypeAtom, out var actualFormat, out var nitems, out var bytes_after, out var prop); + return new (prop, actualTypeAtom, actualFormat, nitems); + } + + private Task ConvertSelectionAndGetProperty( + IntPtr target, IntPtr property) + { + XConvertSelection(_platform.Display, _x11.Atoms.CLIPBOARD, target, property, _window.Handle, + IntPtr.Zero); + return WaitForSelectionNotifyAndGetProperty(property); + } + + public async Task SendFormatRequest() + { + using var res = await ConvertSelectionAndGetProperty(_x11.Atoms.TARGETS, _x11.Atoms.TARGETS); + if (res == null) + return null; + + if (res.NItems == IntPtr.Zero) + return null; + if (res.ActualFormat != 32) + return null; + else + { + var formats = new IntPtr[res.NItems.ToInt32()]; + Marshal.Copy(res.Data, formats, 0, formats.Length); + return formats; + } + } + + public class GetDataResult(byte[]? data, MemoryStream? stream, IntPtr actualTypeAtom) + { + public IntPtr TypeAtom => actualTypeAtom; + public byte[] AsBytes() => data ?? stream!.ToArray(); + public MemoryStream AsStream() => stream ?? new MemoryStream(data!); + } + + private async Task ReadIncr(IntPtr property) + { + XFlush(_platform.Display); + var ms = new MemoryStream(); + void Append(PropertyReadResult res) + { + var len = (int)res.NItems * (res.ActualFormat / 8); + var data = ArrayPool.Shared.Rent(len); + Marshal.Copy(res.Data, data, 0, len); + ms.Write(data, 0, len); + ArrayPool.Shared.Return(data); + } + IntPtr actualTypeAtom = IntPtr.Zero; + while (true) + { + var ev = await _window.WaitForEventAsync(x => + x is { type: XEventName.PropertyNotify, PropertyEvent.state: 0 } && + x.PropertyEvent.atom == property); + + if (ev == null) + return null; + + using var part = ReadProperty(property); + + if (actualTypeAtom == IntPtr.Zero) + actualTypeAtom = part.ActualTypeAtom; + if(part.NItems == IntPtr.Zero) + break; + + Append(part); + } + + return new(null, ms, actualTypeAtom); + } + + public async Task SendDataRequest(IntPtr format) + { + using var res = await ConvertSelectionAndGetProperty(format, format); + if (res == null) + return null; + + if (res.NItems == IntPtr.Zero) + return null; + if (res.ActualTypeAtom == _x11.Atoms.INCR) + { + return await ReadIncr(format); + } + else + { + var data = new byte[(int)res.NItems * (res.ActualFormat / 8)]; + Marshal.Copy(res.Data, data, 0, data.Length); + return new (data, null, res.ActualTypeAtom); + } + + } +} \ No newline at end of file diff --git a/src/Avalonia.X11/Clipboard/EventStreamWindow.cs b/src/Avalonia.X11/Clipboard/EventStreamWindow.cs new file mode 100644 index 0000000000..913e5ed258 --- /dev/null +++ b/src/Avalonia.X11/Clipboard/EventStreamWindow.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace Avalonia.X11; + +internal class EventStreamWindow : IDisposable +{ + private readonly AvaloniaX11Platform _platform; + private IntPtr _handle; + public IntPtr Handle => _handle; + private readonly List<(Func filter, TaskCompletionSource tcs, TimeSpan timeout)> _listeners = new(); + // We are adding listeners to an intermediate collection to avoid freshly added listeners to be called + // in the same event loop iteration and potentially processing an event that was not meant for them. + private readonly List<(Func filter, TaskCompletionSource tcs, TimeSpan timeout)> _addedListeners = new(); + private readonly DispatcherTimer _timeoutTimer; + private readonly bool _isForeign; + private static readonly Stopwatch _time = Stopwatch.StartNew(); + + public EventStreamWindow(AvaloniaX11Platform platform, IntPtr? foreignWindow = null) + { + _platform = platform; + if (foreignWindow.HasValue) + { + _isForeign = true; + _handle = foreignWindow.Value; + _platform.Windows[_handle] = OnEvent; + } + else + _handle = XLib.CreateEventWindow(platform, OnEvent); + + _timeoutTimer = new(TimeSpan.FromSeconds(1), DispatcherPriority.Background, OnTimer); + } + + void MergeListeners() + { + _listeners.AddRange(_addedListeners); + _addedListeners.Clear(); + } + + private void OnTimer(object? sender, EventArgs eventArgs) + { + MergeListeners(); + for (var i = 0; i < _listeners.Count; i++) + { + var (filter, tcs, timeout) = _listeners[i]; + if (timeout < _time.Elapsed) + { + _listeners.RemoveAt(i); + i--; + tcs.SetResult(null); + } + } + if(_listeners.Count == 0) + _timeoutTimer.Stop(); + } + + private void OnEvent(ref XEvent xev) + { + MergeListeners(); + for (var i = 0; i < _listeners.Count; i++) + { + var (filter, tcs, timeout) = _listeners[i]; + if (filter(xev)) + { + _listeners.RemoveAt(i); + i--; + tcs.SetResult(xev); + } + } + } + + public Task WaitForEventAsync(Func predicate, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromSeconds(5); + + if (timeout < TimeSpan.Zero) + throw new TimeoutException(); + if(timeout > TimeSpan.FromDays(1)) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + var tcs = new TaskCompletionSource(); + _addedListeners.Add((predicate, tcs, _time.Elapsed + timeout.Value)); + + _timeoutTimer.Start(); + return tcs.Task; + } + + public void Dispose() + { + _timeoutTimer.Stop(); + + _platform.Windows.Remove(_handle); + if (_isForeign) + XLib.XSelectInput(_platform.Display, _handle, IntPtr.Zero); + else + XLib.XDestroyWindow(_platform.Display, _handle); + + _handle = IntPtr.Zero; + var toDispose = _listeners.ToList(); + toDispose.AddRange(_addedListeners); + _listeners.Clear(); + _addedListeners.Clear(); + foreach(var l in toDispose) + l.tcs.SetResult(null); + } +} diff --git a/src/Avalonia.X11/X11Clipboard.cs b/src/Avalonia.X11/Clipboard/X11Clipboard.cs similarity index 67% rename from src/Avalonia.X11/X11Clipboard.cs rename to src/Avalonia.X11/Clipboard/X11Clipboard.cs index 911d80db19..75f224126a 100644 --- a/src/Avalonia.X11/X11Clipboard.cs +++ b/src/Avalonia.X11/Clipboard/X11Clipboard.cs @@ -1,27 +1,31 @@ using System; +using System.Buffers; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.X11.Clipboard; using static Avalonia.X11.XLib; namespace Avalonia.X11 { internal class X11Clipboard : IClipboard { + private readonly AvaloniaX11Platform _platform; private readonly X11Info _x11; private IDataObject? _storedDataObject; private IntPtr _handle; private TaskCompletionSource? _storeAtomTcs; - private TaskCompletionSource? _requestedFormatsTcs; - private TaskCompletionSource? _requestedDataTcs; private readonly IntPtr[] _textAtoms; private readonly IntPtr _avaloniaSaveTargetsAtom; + private int _maximumPropertySize; public X11Clipboard(AvaloniaX11Platform platform) { + _platform = platform; _x11 = platform.Info; _handle = CreateEventWindow(platform, OnEvent); _avaloniaSaveTargetsAtom = XInternAtom(_x11.Display, "AVALONIA_SAVE_TARGETS_PROPERTY_ATOM", false); @@ -32,13 +36,15 @@ namespace Avalonia.X11 _x11.Atoms.UTF8_STRING, _x11.Atoms.UTF16_STRING }.Where(a => a != IntPtr.Zero).ToArray(); - } - private bool IsStringAtom(IntPtr atom) - { - return _textAtoms.Contains(atom); + var extendedMaxRequestSize = XExtendedMaxRequestSize(_platform.Display); + var maxRequestSize = XMaxRequestSize(_platform.Display); + _maximumPropertySize = + (int)Math.Min(0x100000, (extendedMaxRequestSize == IntPtr.Zero + ? maxRequestSize + : extendedMaxRequestSize).ToInt64() - 0x100); } - + private Encoding? GetStringEncoding(IntPtr atom) { return (atom == _x11.Atoms.XA_STRING @@ -50,17 +56,17 @@ namespace Avalonia.X11 ? Encoding.Unicode : null; } - + private unsafe void OnEvent(ref XEvent ev) { if (ev.type == XEventName.SelectionClear) - { + { _storeAtomTcs?.TrySetResult(true); return; } if (ev.type == XEventName.SelectionRequest) - { + { var sel = ev.SelectionRequestEvent; var resp = new XEvent { @@ -80,7 +86,7 @@ namespace Avalonia.X11 { resp.SelectionEvent.property = WriteTargetToProperty(sel.target, sel.requestor, sel.property); } - + XSendEvent(_x11.Display, sel.requestor, false, new IntPtr((int)EventMask.NoEventMask), ref resp); } @@ -94,21 +100,19 @@ namespace Avalonia.X11 _x11.Atoms.XA_ATOM, 32, PropertyMode.Replace, atoms, atoms.Length); return property; } - else if(target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) + else if (target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) { return property; } - else if ((textEnc = GetStringEncoding(target)) != null + else if ((textEnc = GetStringEncoding(target)) != null && _storedDataObject?.Contains(DataFormats.Text) == true) { var text = _storedDataObject.GetText(); - if(text == null) + if (text == null) return IntPtr.Zero; var data = textEnc.GetBytes(text); - fixed (void* pdata = data) - XChangeProperty(_x11.Display, window, property, target, 8, - PropertyMode.Replace, - pdata, data.Length); + SendDataToClient(window, property, target, data); + return property; } else if (target == _x11.Atoms.MULTIPLE && _x11.Atoms.MULTIPLE != IntPtr.Zero) @@ -136,11 +140,12 @@ namespace Avalonia.X11 return property; } - else if(_x11.Atoms.GetAtomName(target) is { } atomName && _storedDataObject?.Contains(atomName) == true) + else if (_x11.Atoms.GetAtomName(target) is { } atomName && + _storedDataObject?.Contains(atomName) == true) { var objValue = _storedDataObject.Get(atomName); - - if(!(objValue is byte[] bytes)) + + if (!(objValue is byte[] bytes)) { if (objValue is string s) bytes = Encoding.UTF8.GetBytes(s); @@ -148,93 +153,66 @@ namespace Avalonia.X11 return IntPtr.Zero; } - XChangeProperty(_x11.Display, window, property, target, 8, - PropertyMode.Replace, - bytes, bytes.Length); + SendDataToClient(window, property, target, bytes); return property; } else return IntPtr.Zero; } - if (ev.type == XEventName.SelectionNotify && ev.SelectionEvent.selection == _x11.Atoms.CLIPBOARD) - { - var sel = ev.SelectionEvent; - if (sel.property == IntPtr.Zero) - { - _requestedFormatsTcs?.TrySetResult(null); - _requestedDataTcs?.TrySetResult(null); - } - XGetWindowProperty(_x11.Display, _handle, sel.property, IntPtr.Zero, new IntPtr (0x7fffffff), true, (IntPtr)Atom.AnyPropertyType, - out var actualTypeAtom, out var actualFormat, out var nitems, out var bytes_after, out var prop); - Encoding? textEnc; - if (nitems == IntPtr.Zero) - { - _requestedFormatsTcs?.TrySetResult(null); - _requestedDataTcs?.TrySetResult(null); - } - else - { - if (sel.property == _x11.Atoms.TARGETS) - { - if (actualFormat != 32) - _requestedFormatsTcs?.TrySetResult(null); - else - { - var formats = new IntPtr[nitems.ToInt32()]; - Marshal.Copy(prop, formats, 0, formats.Length); - _requestedFormatsTcs?.TrySetResult(formats); - } - } - else if ((textEnc = GetStringEncoding(actualTypeAtom)) != null) - { - var text = textEnc.GetString((byte*)prop.ToPointer(), nitems.ToInt32()); - _requestedDataTcs?.TrySetResult(text); - } - else - { - if (actualTypeAtom == _x11.Atoms.INCR) - { - // TODO: Actually implement that monstrosity - _requestedDataTcs?.TrySetResult(null); - } - else - { - var data = new byte[(int)nitems * (actualFormat / 8)]; - Marshal.Copy(prop, data, 0, data.Length); - _requestedDataTcs?.TrySetResult(data); - } - } - } - - XFree(prop); - } } - private Task SendFormatRequest() + async void SendIncrDataToClient(IntPtr window, IntPtr property, IntPtr target, Stream data) { - if (_requestedFormatsTcs == null || _requestedFormatsTcs.Task.IsCompleted) - _requestedFormatsTcs = new TaskCompletionSource(); - XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD, _x11.Atoms.TARGETS, _x11.Atoms.TARGETS, _handle, - IntPtr.Zero); - return _requestedFormatsTcs.Task; + data.Position = 0; + using var events = new EventStreamWindow(_platform, window); + using var _ = data; + XSelectInput(_x11.Display, window, new IntPtr((int)XEventMask.PropertyChangeMask)); + var size = new IntPtr(data.Length); + XChangeProperty(_x11.Display, window, property, _x11.Atoms.INCR, 32, PropertyMode.Replace, ref size, 1); + var buffer = ArrayPool.Shared.Rent((int)Math.Min(_maximumPropertySize, data.Length)); + while (true) + { + if (null == await events.WaitForEventAsync(x => + x.type == XEventName.PropertyNotify && x.PropertyEvent.atom == property + && x.PropertyEvent.state == 1, TimeSpan.FromMinutes(1))) + break; + var read = await data.ReadAsync(buffer, 0, buffer.Length); + if(read == 0) + break; + XChangeProperty(_x11.Display, window, property, target, 8, PropertyMode.Replace, buffer, read); + } + ArrayPool.Shared.Return(buffer); + + // Finish the transfer + XChangeProperty(_x11.Display, window, property, target, 8, PropertyMode.Replace, IntPtr.Zero, 0); } - private Task SendDataRequest(IntPtr format) + void SendDataToClient(IntPtr window, IntPtr property, IntPtr target, byte[] bytes) { - if (_requestedDataTcs == null || _requestedDataTcs.Task.IsCompleted) - _requestedDataTcs = new TaskCompletionSource(); - XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD, format, format, _handle, IntPtr.Zero); - return _requestedDataTcs.Task; + if (bytes.Length < _maximumPropertySize) + { + XChangeProperty(_x11.Display, window, property, target, 8, + PropertyMode.Replace, + bytes, bytes.Length); + } + else + SendIncrDataToClient(window, property, target, new MemoryStream(bytes)); } private bool HasOwner => XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD) != IntPtr.Zero; + private ClipboardReadSession OpenReadSession() => new(_platform); + public async Task GetTextAsync() { if (!HasOwner) return null; - var res = await SendFormatRequest(); + if (TryGetInProcessDataObject() is { } inProc) + return inProc.GetText(); + + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); var target = _x11.Atoms.UTF8_STRING; if (res != null) { @@ -247,7 +225,17 @@ namespace Avalonia.X11 } } - return (string?)await SendDataRequest(target); + return ConvertData(await session.SendDataRequest(target)) as string; + } + + private object? ConvertData(ClipboardReadSession.GetDataResult? result) + { + if (result == null) + return null; + if (GetStringEncoding(result.TypeAtom) is { } textEncoding) + return textEncoding.GetString(result.AsBytes()); + // TODO: image encoding + return result.AsBytes(); } @@ -272,6 +260,12 @@ namespace Avalonia.X11 private Task StoreAtomsInClipboardManager(IDataObject data) { + // Skip storing atoms if the data object contains any non-trivial formats or trivial formats are too big + if (data.GetDataFormats().Any(f => f != DataFormats.Text) + || data.GetText()?.Length * 2 > 64 * 1024 + ) + return Task.CompletedTask; + if (_x11.Atoms.CLIPBOARD_MANAGER != IntPtr.Zero && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) { var clipboardManager = XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD_MANAGER); @@ -314,19 +308,24 @@ namespace Avalonia.X11 return StoreAtomsInClipboardManager(data); } - public Task TryGetInProcessDataObjectAsync() + private IDataObject? TryGetInProcessDataObject() { if (XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD) == _handle) - return Task.FromResult(_storedDataObject); - return Task.FromResult(null); + return _storedDataObject; + return null; } + public Task TryGetInProcessDataObjectAsync() => Task.FromResult(TryGetInProcessDataObject()); + public async Task GetFormatsAsync() { if (!HasOwner) return []; - - var res = await SendFormatRequest(); + if (TryGetInProcessDataObject() is { } inProc) + return inProc.GetDataFormats().ToArray(); + + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); if (res == null) return []; @@ -347,15 +346,20 @@ namespace Avalonia.X11 { if (!HasOwner) return null; + + if(TryGetInProcessDataObject() is {} inProc) + return inProc.Get(format); + if (format == DataFormats.Text) return await GetTextAsync(); var formatAtom = _x11.Atoms.GetAtom(format); - var res = await SendFormatRequest(); + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); if (res is null || !res.Contains(formatAtom)) return null; - - return await SendDataRequest(formatAtom); + + return ConvertData(await session.SendDataRequest(formatAtom)); } /// diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index 0e89a46332..9fbaa4926e 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -81,7 +81,7 @@ namespace Avalonia.X11 .Bind().ToConstant(new KeyGestureFormatInfo(new Dictionary() { }, meta: "Super")) .Bind().ToFunc(() => KeyboardDevice) .Bind().ToConstant(new X11CursorFactory(Display)) - .Bind().ToConstant(new X11Clipboard(this)) + .Bind().ToLazy(() => new X11Clipboard(this)) .Bind().ToSingleton() .Bind().ToConstant(new X11IconLoader()) .Bind().ToConstant(new LinuxMountedVolumeInfoProvider()) diff --git a/src/Avalonia.X11/XLib.cs b/src/Avalonia.X11/XLib.cs index cfd3a03c8f..2c8ecf2c94 100644 --- a/src/Avalonia.X11/XLib.cs +++ b/src/Avalonia.X11/XLib.cs @@ -559,6 +559,12 @@ namespace Avalonia.X11 [DllImport(libX11)] public static extern void XFreeEventData(IntPtr display, void* cookie); + [DllImport(libX11)] + public static extern IntPtr XMaxRequestSize(IntPtr display); + + [DllImport(libX11)] + public static extern IntPtr XExtendedMaxRequestSize(IntPtr display); + [DllImport(libX11Randr)] public static extern int XRRQueryExtension (IntPtr dpy, out int event_base_return, From aa254413a6e9c0a5818abf09d0a38f7c2df99c49 Mon Sep 17 00:00:00 2001 From: kerams Date: Tue, 29 Jul 2025 14:27:09 +0200 Subject: [PATCH 77/94] Fix multiline selection crash (#19337) --- .../Platform/Input/AvaloniaInputConnection.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs b/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs index 80d86f1c86..39e1574901 100644 --- a/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs +++ b/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs @@ -285,14 +285,14 @@ namespace Avalonia.Android.Platform.Input public ICharSequence? GetTextAfterCursorFormatted(int n, [GeneratedEnum] GetTextFlags flags) { var end = Math.Min(_editBuffer.Selection.End, _editBuffer.Text.Length); - return new Java.Lang.String(_editBuffer.Text.Substring(end, Math.Min(n, _editBuffer.Text.Length - end))); + return SafeSubstring(_editBuffer.Text, end, Math.Min(n, _editBuffer.Text.Length - end)); } public ICharSequence? GetTextBeforeCursorFormatted(int n, [GeneratedEnum] GetTextFlags flags) { var start = Math.Max(0, _editBuffer.Selection.Start - n); var length = _editBuffer.Selection.Start - start; - return _editBuffer.Text == null ? null : new Java.Lang.String(_editBuffer.Text.Substring(start, length)); + return SafeSubstring(_editBuffer.Text, start, length); } public bool PerformPrivateCommand(string? action, Bundle? data) @@ -330,5 +330,13 @@ namespace Avalonia.Android.Platform.Input EndBatchEdit(); } } + + private static ICharSequence? SafeSubstring(string? text, int start, int length) + { + if (text == null || text.Length < start + length) + return null; + else + return new Java.Lang.String(text.Substring(start, length)); + } } } From af5c8869aafdef09006438c202c5937480e81ccf Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 30 Jul 2025 08:16:44 +0000 Subject: [PATCH 78/94] remove textbox holding handler in text selection handle (#19186) --- .../Primitives/TextSelectionCanvas.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs index c99b210665..4248ea65a1 100644 --- a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs +++ b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs @@ -70,6 +70,10 @@ namespace Avalonia.Controls.Primitives _caretHandle.PointerReleased += Handle_PointerReleased; _endHandle.PointerReleased += Handle_PointerReleased; + _startHandle.Holding += Caret_Holding; + _caretHandle.Holding += Caret_Holding; + _endHandle.Holding += Caret_Holding; + IsVisible = ShowHandles; ClipToBounds = false; @@ -214,7 +218,12 @@ namespace Avalonia.Controls.Primitives public void MoveHandlesToSelection() { - if (_presenter == null || _textBox == null || _startHandle.IsDragging || _endHandle.IsDragging) + if (_presenter == null + || _textBox == null + || _startHandle.IsDragging + || _endHandle.IsDragging + || _textBox.ContextFlyout?.IsOpen == true + || _textBox.ContextMenu?.IsOpen == true) { return; } @@ -268,7 +277,6 @@ namespace Avalonia.Controls.Primitives _textBox.RemoveHandler(TextBox.TextChangingEvent, TextChanged); _textBox.RemoveHandler(KeyDownEvent, TextBoxKeyDown); _textBox.RemoveHandler(PointerReleasedEvent, TextBoxPointerReleased); - _textBox.RemoveHandler(Gestures.HoldingEvent, TextBoxHolding); _textBox.PropertyChanged -= TextBoxPropertyChanged; _textBox.EffectiveViewportChanged -= TextBoxEffectiveViewportChanged; @@ -287,7 +295,6 @@ namespace Avalonia.Controls.Primitives _textBox.AddHandler(TextBox.TextChangingEvent, TextChanged, handledEventsToo: true); _textBox.AddHandler(KeyDownEvent, TextBoxKeyDown, handledEventsToo: true); _textBox.AddHandler(PointerReleasedEvent, TextBoxPointerReleased, handledEventsToo: true); - _textBox.AddHandler(Gestures.HoldingEvent, TextBoxHolding, handledEventsToo: true); _textBox.PropertyChanged += TextBoxPropertyChanged; _textBox.EffectiveViewportChanged += TextBoxEffectiveViewportChanged; @@ -310,7 +317,7 @@ namespace Avalonia.Controls.Primitives } } - private void TextBoxHolding(object? sender, HoldingRoutedEventArgs e) + private void Caret_Holding(object? sender, HoldingRoutedEventArgs e) { if (ShowContextMenu()) e.Handled = true; From 230159c174628638ae5f7e60bab46410260bb609 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Wed, 30 Jul 2025 09:34:39 +0000 Subject: [PATCH 79/94] Use captured element if available as source for tap gestures (#19222) * update mouse test to better simulate clicks on captured controls * add tap failing test * use captured element if available as source for tap gestures --- src/Avalonia.Base/Input/Gestures.cs | 34 ++++++++++++------- .../Input/GesturesTests.cs | 34 +++++++++++++++++++ tests/Avalonia.UnitTests/MouseTestHelper.cs | 6 ++-- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/Avalonia.Base/Input/Gestures.cs b/src/Avalonia.Base/Input/Gestures.cs index 156178c061..3298af3a0f 100644 --- a/src/Avalonia.Base/Input/Gestures.cs +++ b/src/Avalonia.Base/Input/Gestures.cs @@ -218,9 +218,16 @@ namespace Avalonia.Input public static void RemoveScrollGestureInertiaStartingHandler(Interactive element, EventHandler handler) => element.RemoveHandler(ScrollGestureInertiaStartingEvent, handler); + private static object? GetCaptured(RoutedEventArgs? args) + { + if (args is not PointerEventArgs pointerEventArgs) + return null; + return pointerEventArgs.Pointer?.Captured ?? pointerEventArgs.Source; + } + private static void PointerPressed(RoutedEventArgs ev) { - if (ev.Source is null) + if (GetCaptured(ev) is not { } source) { return; } @@ -228,11 +235,11 @@ namespace Avalonia.Input if (ev.Route == RoutingStrategies.Bubble) { var e = (PointerPressedEventArgs)ev; - var visual = (Visual)ev.Source; + var visual = (Visual)source; if(s_gestureState != null) { - if(s_gestureState.Value.Type == GestureStateType.Holding && ev.Source is Interactive i) + if(s_gestureState.Value.Type == GestureStateType.Holding && source is Interactive i) { i.RaiseEvent(new HoldingRoutedEventArgs(HoldingState.Cancelled, s_lastPressPoint, s_gestureState.Value.Pointer.Type, e)); } @@ -246,8 +253,8 @@ namespace Avalonia.Input if (e.ClickCount % 2 == 1) { s_gestureState = new GestureState(GestureStateType.Pending, e.Pointer); - s_lastPress.SetTarget(ev.Source); - s_lastPressPoint = e.GetPosition((Visual)ev.Source); + s_lastPress.SetTarget(source); + s_lastPressPoint = e.GetPosition((Visual)source); s_holdCancellationToken = new CancellationTokenSource(); var token = s_holdCancellationToken.Token; var settings = ((IInputRoot?)visual.GetVisualRoot())?.PlatformSettings; @@ -256,7 +263,7 @@ namespace Avalonia.Input { DispatcherTimer.RunOnce(() => { - if (s_gestureState != null && !token.IsCancellationRequested && e.Source is InputElement i && GetIsHoldingEnabled(i) && (e.Pointer.Type != PointerType.Mouse || GetIsHoldWithMouseEnabled(i))) + if (s_gestureState != null && !token.IsCancellationRequested && source is InputElement i && GetIsHoldingEnabled(i) && (e.Pointer.Type != PointerType.Mouse || GetIsHoldWithMouseEnabled(i))) { s_gestureState = new GestureState(GestureStateType.Holding, s_gestureState.Value.Pointer); i.RaiseEvent(new HoldingRoutedEventArgs(HoldingState.Started, s_lastPressPoint, s_gestureState.Value.Pointer.Type, e)); @@ -267,8 +274,8 @@ namespace Avalonia.Input else if (e.ClickCount % 2 == 0 && e.GetCurrentPoint(visual).Properties.IsLeftButtonPressed) { if (s_lastPress.TryGetTarget(out var target) && - target == e.Source && - e.Source is Interactive i) + target == source && + source is Interactive i) { s_gestureState = new GestureState(GestureStateType.DoubleTapped, e.Pointer); i.RaiseEvent(new TappedEventArgs(DoubleTappedEvent, e)); @@ -283,10 +290,12 @@ namespace Avalonia.Input { var e = (PointerReleasedEventArgs)ev; + var source = GetCaptured(ev); + if (s_lastPress.TryGetTarget(out var target) && - target == e.Source && - e.InitialPressMouseButton is MouseButton.Left or MouseButton.Right && - e.Source is Interactive i) + target == source && + e.InitialPressMouseButton is MouseButton.Left or MouseButton.Right && + source is Interactive i) { var point = e.GetCurrentPoint((Visual)target); var settings = ((IInputRoot?)i.GetVisualRoot())?.PlatformSettings; @@ -325,9 +334,10 @@ namespace Avalonia.Input if (ev.Route == RoutingStrategies.Bubble) { var e = (PointerEventArgs)ev; + var source = GetCaptured(e); if (s_lastPress.TryGetTarget(out var target)) { - if (e.Pointer == s_gestureState?.Pointer && ev.Source is Interactive i) + if (e.Pointer == s_gestureState?.Pointer && source is Interactive i) { var point = e.GetCurrentPoint((Visual)target); var settings = ((IInputRoot?)i.GetVisualRoot())?.PlatformSettings; diff --git a/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs b/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs index a2afdd0af2..e0a35b9ab2 100644 --- a/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs +++ b/tests/Avalonia.Base.UnitTests/Input/GesturesTests.cs @@ -88,6 +88,40 @@ namespace Avalonia.Base.UnitTests.Input Assert.False(raised); } + [Fact] + public void Tapped_Should_Be_Raised_From_Captured_Control() + { + Border inner = new Border() + { + Focusable = true, + Name = "Inner" + }; + Border border = new Border() + { + Focusable = true, + Child = inner, + Name = "Parent" + }; + var root = new TestRoot + { + Child = border + }; + var raised = false; + + border.PointerPressed += (s, e) => + { + e.Pointer.Capture(inner); + }; + _mouse.Click(border, MouseButton.Left); + + root.AddHandler(Gestures.TappedEvent, (_, _) => raised = true); + + _mouse.Click(border, MouseButton.Left); + + + Assert.True(raised); + } + [Fact] public void RightTapped_Should_Be_Raised_For_Right_Button() { diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs index e0d269083a..c963efedd0 100644 --- a/tests/Avalonia.UnitTests/MouseTestHelper.cs +++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs @@ -104,7 +104,8 @@ namespace Avalonia.UnitTests Point position = default, KeyModifiers modifiers = default) { Down(target, source, button, position, modifiers); - Up(target, source, button, position, modifiers); + var captured = (_pointer.Captured as Interactive) ?? source; + Up(captured, captured, button, position, modifiers); } public void DoubleClick(Interactive target, MouseButton button = MouseButton.Left, Point position = default, @@ -115,7 +116,8 @@ namespace Avalonia.UnitTests Point position = default, KeyModifiers modifiers = default) { Down(target, source, button, position, modifiers, clickCount: 1); - Up(target, source, button, position, modifiers); + var captured = (_pointer.Captured as Interactive) ?? source; + Up(captured, captured, button, position, modifiers); Down(target, source, button, position, modifiers, clickCount: 2); } From 016f32d90bd0cb1626be89b9740306b7aa2a7a02 Mon Sep 17 00:00:00 2001 From: Betta_Fish <96322503+zxbmmmmmmmmm@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:41:09 +0800 Subject: [PATCH 80/94] [Grid] Fix inner size calculation when Row/ColumnDefinition is not set but spacing is set (#19227) * fix: avoid negative values when calculating combinedRowSpacing/combinedColumnSpacing * add tests --- src/Avalonia.Controls/Grid.cs | 8 ++--- .../Avalonia.Controls.UnitTests/GridTests.cs | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls/Grid.cs b/src/Avalonia.Controls/Grid.cs index 2e49104694..df70cdf6d0 100644 --- a/src/Avalonia.Controls/Grid.cs +++ b/src/Avalonia.Controls/Grid.cs @@ -448,8 +448,8 @@ namespace Avalonia.Controls MeasureCellsGroup(extData.CellGroup1, false, false); double rowSpacing = RowSpacing; double columnSpacing = ColumnSpacing; - double combinedRowSpacing = RowSpacing * (RowDefinitions.Count - 1); - double combinedColumnSpacing = ColumnSpacing * (ColumnDefinitions.Count - 1); + double combinedRowSpacing = RowSpacing * (DefinitionsV.Count - 1); + double combinedColumnSpacing = ColumnSpacing * (DefinitionsU.Count - 1); Size innerAvailableSize = new Size(constraint.Width - combinedColumnSpacing, constraint.Height - combinedRowSpacing); { // after Group1 is measured, only Group3 may have cells belonging to Auto rows. @@ -551,8 +551,8 @@ namespace Avalonia.Controls Debug.Assert(DefinitionsU.Count > 0 && DefinitionsV.Count > 0); double rowSpacing = RowSpacing; double columnSpacing = ColumnSpacing; - double combinedRowSpacing = rowSpacing * (RowDefinitions.Count - 1); - double combinedColumnSpacing = columnSpacing * (ColumnDefinitions.Count - 1); + double combinedRowSpacing = rowSpacing * (DefinitionsV.Count - 1); + double combinedColumnSpacing = columnSpacing * (DefinitionsU.Count - 1); SetFinalSize(DefinitionsU, arrangeSize.Width - combinedColumnSpacing, true); SetFinalSize(DefinitionsV, arrangeSize.Height - combinedRowSpacing, false); diff --git a/tests/Avalonia.Controls.UnitTests/GridTests.cs b/tests/Avalonia.Controls.UnitTests/GridTests.cs index c1bbc5acc3..7205db7950 100644 --- a/tests/Avalonia.Controls.UnitTests/GridTests.cs +++ b/tests/Avalonia.Controls.UnitTests/GridTests.cs @@ -1900,6 +1900,36 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(grid1.Children[4].Bounds.Width, grid2.Children[0].Bounds.Width); } + + [Fact] + public void Grid_With_ColumnSpacing_And_ColumnDefinitions_Unset() + { + var target = new Grid + { + Height = 300, + Width = 100, + ColumnSpacing = 10, + RowDefinitions = RowDefinitions.Parse("Auto,*"),//Set RowDefinitions to avoid + Children = + { + new Border + { + [Grid.RowProperty] = 0, + Height = 80, + Margin = new Thickness(10), + }, + new Border + { + [Grid.RowProperty] = 1, + Margin = new Thickness(20), + }, + }, + }; + target.Measure(new Size(100, 300)); + target.Arrange(new Rect(target.DesiredSize)); + Assert.Equal(new Rect(10, 10, 80, 80), target.Children[0].Bounds); + Assert.Equal(new Rect(20, 120, 60, 160),target.Children[1].Bounds); + } private class TestControl : Control { public Size MeasureSize { get; set; } From debd14f68a57c8603d70ae29da064eb21ac5e349 Mon Sep 17 00:00:00 2001 From: Versette <70504431+Versette@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:07:17 +0300 Subject: [PATCH 81/94] Fix typo in XML documentation for ExtendClientAreaChromeHints enum (#19245) --- src/Avalonia.Controls/Platform/ExtendClientAreaChromeHints.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Platform/ExtendClientAreaChromeHints.cs b/src/Avalonia.Controls/Platform/ExtendClientAreaChromeHints.cs index 8513dd1697..3e177889d6 100644 --- a/src/Avalonia.Controls/Platform/ExtendClientAreaChromeHints.cs +++ b/src/Avalonia.Controls/Platform/ExtendClientAreaChromeHints.cs @@ -9,7 +9,7 @@ namespace Avalonia.Platform public enum ExtendClientAreaChromeHints { /// - /// The will be no chrome at all. + /// There will be no chrome at all. /// NoChrome, From 83eded1c8314f8d5d7af8c4e9e6b8a209a5083fc Mon Sep 17 00:00:00 2001 From: oktrue <58470982+oktrue@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:08:32 +0300 Subject: [PATCH 82/94] Typos fix (#19257) * vertext -> vertex * Eqaul -> Equal --- samples/GpuInterop/VulkanDemo/VulkanContent.cs | 10 +++++----- ...rsTests_Eqaul.cs => ObjectConvertersTests_Equal.cs} | 0 ...s_NotEqaul.cs => ObjectConvertersTests_NotEqual.cs} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename tests/Avalonia.Base.UnitTests/Data/{ObjectConvertersTests_Eqaul.cs => ObjectConvertersTests_Equal.cs} (100%) rename tests/Avalonia.Base.UnitTests/Data/{ObjectConvertersTests_NotEqaul.cs => ObjectConvertersTests_NotEqual.cs} (100%) diff --git a/samples/GpuInterop/VulkanDemo/VulkanContent.cs b/samples/GpuInterop/VulkanDemo/VulkanContent.cs index ab9d133cc0..73042ad8ba 100644 --- a/samples/GpuInterop/VulkanDemo/VulkanContent.cs +++ b/samples/GpuInterop/VulkanDemo/VulkanContent.cs @@ -142,7 +142,7 @@ unsafe class VulkanContent : IDisposable var model = Matrix4x4.CreateFromYawPitchRoll((float)yaw, (float)pitch, (float)roll); - var vertexConstant = new VertextPushConstant() + var vertexConstant = new VertexPushConstant() { Disco = (float)disco, MinY = _minY, @@ -206,7 +206,7 @@ unsafe class VulkanContent : IDisposable _pipelineLayout,0,1, &dset, null); api.CmdPushConstants(commandBufferHandle, _pipelineLayout, ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, 0, - (uint)Marshal.SizeOf(), &vertexConstant); + (uint)Marshal.SizeOf(), &vertexConstant); api.CmdBindVertexBuffers(commandBufferHandle, 0, 1, _vertexBuffer, 0); api.CmdBindIndexBuffer(commandBufferHandle, _indexBuffer, 0, IndexType.Uint16); @@ -613,14 +613,14 @@ unsafe class VulkanContent : IDisposable var vertexPushConstantRange = new PushConstantRange() { Offset = 0, - Size = (uint)Marshal.SizeOf(), + Size = (uint)Marshal.SizeOf(), StageFlags = ShaderStageFlags.VertexBit }; var fragPushConstantRange = new PushConstantRange() { //Offset = vertexPushConstantRange.Size, - Size = (uint)Marshal.SizeOf(), + Size = (uint)Marshal.SizeOf(), StageFlags = ShaderStageFlags.FragmentBit }; @@ -809,7 +809,7 @@ unsafe class VulkanContent : IDisposable private DescriptorSet _descriptorSet; [StructLayout(LayoutKind.Sequential, Pack = 4)] - private struct VertextPushConstant + private struct VertexPushConstant { public float MaxY; public float MinY; diff --git a/tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_Eqaul.cs b/tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_Equal.cs similarity index 100% rename from tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_Eqaul.cs rename to tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_Equal.cs diff --git a/tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_NotEqaul.cs b/tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_NotEqual.cs similarity index 100% rename from tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_NotEqaul.cs rename to tests/Avalonia.Base.UnitTests/Data/ObjectConvertersTests_NotEqual.cs From fd72a4c3b4b2f819eb443a5ed24437257141046d Mon Sep 17 00:00:00 2001 From: Handsome08 <409005970@qq.com> Date: Wed, 30 Jul 2025 19:04:21 +0800 Subject: [PATCH 83/94] Fix X11Screen info update incorrectly when screen changed. (#19262) --- .../Screens/X11Screen.Providers.cs | 35 ++++++++++++++----- src/Avalonia.X11/Screens/X11Screens.cs | 11 ++++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/Avalonia.X11/Screens/X11Screen.Providers.cs b/src/Avalonia.X11/Screens/X11Screen.Providers.cs index 82eb21b287..f516e0f44f 100644 --- a/src/Avalonia.X11/Screens/X11Screen.Providers.cs +++ b/src/Avalonia.X11/Screens/X11Screen.Providers.cs @@ -15,22 +15,21 @@ internal partial class X11Screens // Length of a EDID-Block-Length(128 bytes), XRRGetOutputProperty multiplies offset and length by 4 private const int EDIDStructureLength = 32; - public virtual void Refresh() + public virtual void Refresh(MonitorInfo newInfo) { if (scalingProvider == null) return; - var namePtr = XGetAtomName(x11.Display, info.Name); + var namePtr = XGetAtomName(x11.Display, newInfo.Name); var name = Marshal.PtrToStringAnsi(namePtr); XFree(namePtr); DisplayName = name; - IsPrimary = info.IsPrimary; - Bounds = new PixelRect(info.X, info.Y, info.Width, info.Height); - + IsPrimary = newInfo.IsPrimary; + Bounds = new PixelRect(newInfo.X, newInfo.Y, newInfo.Width, newInfo.Height); Size? pSize = null; - for (int o = 0; o < info.Outputs.Length; o++) + for (int o = 0; o < newInfo.Outputs.Length; o++) { - var outputSize = GetPhysicalMonitorSizeFromEDID(info.Outputs[o]); + var outputSize = GetPhysicalMonitorSizeFromEDID(newInfo.Outputs[o]); if (outputSize != null) { pSize = outputSize; @@ -121,7 +120,7 @@ internal partial class X11Screens PhysicalSize = pixelRect.Size.ToSize(Scaling); UpdateWorkArea(); } - public override void Refresh() + public override void Refresh(MonitorInfo newInfo) { } } @@ -131,6 +130,7 @@ internal partial class X11Screens nint[] ScreenKeys { get; } event Action? Changed; X11Screen CreateScreenFromKey(nint key); + MonitorInfo GetMonitorInfoByKey(nint key); } internal unsafe struct MonitorInfo @@ -224,6 +224,20 @@ internal partial class X11Screens throw new ArgumentOutOfRangeException(nameof(key)); } + + public MonitorInfo GetMonitorInfoByKey(nint key) + { + var infos = MonitorInfos; + for (var i = 0; i < infos.Length; i++) + { + if (infos[i].Name == key) + { + return infos[i]; + } + } + + throw new ArgumentOutOfRangeException(nameof(key)); + } } private class FallbackScreensImpl : IX11RawScreenInfoProvider @@ -251,6 +265,11 @@ internal partial class X11Screens return new FallBackScreen(new PixelRect(0, 0, _geo.width, _geo.height), _info); } + public MonitorInfo GetMonitorInfoByKey(nint key) + { + return default; + } + public nint[] ScreenKeys => new[] { IntPtr.Zero }; } } diff --git a/src/Avalonia.X11/Screens/X11Screens.cs b/src/Avalonia.X11/Screens/X11Screens.cs index b8ff80734c..cd6232b6f2 100644 --- a/src/Avalonia.X11/Screens/X11Screens.cs +++ b/src/Avalonia.X11/Screens/X11Screens.cs @@ -15,7 +15,7 @@ namespace Avalonia.X11.Screens _impl = (info.RandrVersion != null && info.RandrVersion >= new Version(1, 5)) ? new Randr15ScreensImpl(platform) : (IX11RawScreenInfoProvider)new FallbackScreensImpl(platform); - _impl.Changed += () => Changed?.Invoke(); + _impl.Changed += OnChanged; } protected override int GetScreenCount() => _impl.ScreenKeys.Length; @@ -24,6 +24,13 @@ namespace Avalonia.X11.Screens protected override X11Screen CreateScreenFromKey(nint key) => _impl.CreateScreenFromKey(key); - protected override void ScreenChanged(X11Screen screen) => screen.Refresh(); + protected override void ScreenChanged(X11Screen screen) + { + var handle = screen.TryGetPlatformHandle()?.Handle; + if (handle != null) + { + screen.Refresh(_impl.GetMonitorInfoByKey(handle.Value)); + } + } } } From 3ad4973b5868c19740987375471467bc1319b4f4 Mon Sep 17 00:00:00 2001 From: Vladislav Pozdniakov Date: Wed, 30 Jul 2025 12:45:10 +0100 Subject: [PATCH 84/94] Fixes/osx thick titlebar pointer events streaming, (tabs interface) #15696 (#19320) * Added failing test for OSXThickTitleBar drag events outside thick title area * MacOS. Added event tracking loop for drag events started in thick titlebar (NSToolbar) * Review fix: forward events to AppKit during tracking loop (#19320) --- native/Avalonia.Native/src/OSX/AvnWindow.mm | 87 ++++++++++++++++++ .../Pages/WindowDecorationsPage.axaml | 1 + .../Pages/WindowDecorationsPage.axaml.cs | 6 ++ .../IntegrationTestApp/ShowWindowTest.axaml | 24 ++++- .../ShowWindowTest.axaml.cs | 48 ++++++++++ .../PointerTests_MacOS.cs | 89 +++++++++++++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm index 0bd4b0f462..03daa2f296 100644 --- a/native/Avalonia.Native/src/OSX/AvnWindow.mm +++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm @@ -476,8 +476,95 @@ } } +- (BOOL)isPointInTitlebar:(NSPoint)windowPoint +{ + auto parent = _parent.tryGetWithCast(); + if (!parent || !_isExtended) { + return NO; + } + + AvnView* view = parent->View; + NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; + double titlebarHeight = [self getExtendedTitleBarHeight]; + + // Check if click is in titlebar area (top portion of view) + if (viewPoint.y <= titlebarHeight) { + // Verify we're actually in a toolbar-related area + NSView* hitView = [[self findRootView:view] hitTest:windowPoint]; + if (hitView) { + NSString* hitViewClass = [hitView className]; + if ([hitViewClass containsString:@"Toolbar"] || [hitViewClass containsString:@"Titlebar"]) { + return YES; + } + } + } + return NO; +} + +- (void)forwardToAvnView:(NSEvent *)event +{ + auto parent = _parent.tryGetWithCast(); + if (!parent) { + return; + } + + switch(event.type) { + case NSEventTypeLeftMouseDown: + [parent->View mouseDown:event]; + break; + case NSEventTypeLeftMouseUp: + [parent->View mouseUp:event]; + break; + case NSEventTypeLeftMouseDragged: + [parent->View mouseDragged:event]; + break; + case NSEventTypeRightMouseDown: + [parent->View rightMouseDown:event]; + break; + case NSEventTypeRightMouseUp: + [parent->View rightMouseUp:event]; + break; + case NSEventTypeRightMouseDragged: + [parent->View rightMouseDragged:event]; + break; + case NSEventTypeOtherMouseDown: + [parent->View otherMouseDown:event]; + break; + case NSEventTypeOtherMouseUp: + [parent->View otherMouseUp:event]; + break; + case NSEventTypeOtherMouseDragged: + [parent->View otherMouseDragged:event]; + break; + case NSEventTypeMouseMoved: + [parent->View mouseMoved:event]; + break; + default: + break; + } +} + - (void)sendEvent:(NSEvent *_Nonnull)event { + // Event-tracking loop for thick titlebar mouse events + if (event.type == NSEventTypeLeftMouseDown && [self isPointInTitlebar:event.locationInWindow]) + { + NSEventMask mask = NSEventMaskLeftMouseDragged | NSEventMaskLeftMouseUp; + NSEvent *ev = event; + while (ev.type != NSEventTypeLeftMouseUp) + { + [self forwardToAvnView:ev]; + [super sendEvent:ev]; + ev = [NSApp nextEventMatchingMask:mask + untilDate:[NSDate distantFuture] + inMode:NSEventTrackingRunLoopMode + dequeue:YES]; + } + [self forwardToAvnView:ev]; + [super sendEvent:ev]; + return; + } + [super sendEvent:event]; auto parent = _parent.tryGetWithCast(); diff --git a/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml b/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml index 21a5b1d883..d6b418952a 100644 --- a/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml +++ b/samples/IntegrationTestApp/Pages/WindowDecorationsPage.axaml @@ -9,6 +9,7 @@ + - + + diff --git a/samples/IntegrationTestApp/ShowWindowTest.axaml.cs b/samples/IntegrationTestApp/ShowWindowTest.axaml.cs index 6dfc3825a0..8e33ff3419 100644 --- a/samples/IntegrationTestApp/ShowWindowTest.axaml.cs +++ b/samples/IntegrationTestApp/ShowWindowTest.axaml.cs @@ -30,6 +30,8 @@ namespace IntegrationTestApp { private readonly DispatcherTimer? _timer; private readonly TextBox? _orderTextBox; + private int _mouseMoveCount; + private int _mouseReleaseCount; public ShowWindowTest() { @@ -37,6 +39,10 @@ namespace IntegrationTestApp DataContext = this; PositionChanged += (s, e) => CurrentPosition.Text = $"{Position}"; + PointerMoved += OnPointerMoved; + PointerReleased += OnPointerReleased; + PointerExited += (_, e) => ResetCounters(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { _orderTextBox = CurrentOrder; @@ -74,5 +80,47 @@ namespace IntegrationTestApp private void AddToWidth_Click(object? sender, RoutedEventArgs e) => Width = Bounds.Width + 10; private void AddToHeight_Click(object? sender, RoutedEventArgs e) => Height = Bounds.Height + 10; + + private void OnPointerMoved(object? sender, Avalonia.Input.PointerEventArgs e) + { + _mouseMoveCount++; + UpdateCounterDisplays(); + } + + private void OnPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e) + { + _mouseReleaseCount++; + UpdateCounterDisplays(); + } + + public void ResetCounters() + { + _mouseMoveCount = 0; + _mouseReleaseCount = 0; + UpdateCounterDisplays(); + } + + private void UpdateCounterDisplays() + { + var mouseMoveCountTextBox = this.FindControl("MouseMoveCount"); + var mouseReleaseCountTextBox = this.FindControl("MouseReleaseCount"); + + if (mouseMoveCountTextBox != null) + mouseMoveCountTextBox.Text = _mouseMoveCount.ToString(); + + if (mouseReleaseCountTextBox != null) + mouseReleaseCountTextBox.Text = _mouseReleaseCount.ToString(); + } + + public void ShowTitleAreaControl() + { + var titleAreaControl = this.FindControl("TitleAreaControl"); + if (titleAreaControl == null) return; + titleAreaControl.IsVisible = true; + + var titleBarHeight = ExtendClientAreaTitleBarHeightHint > 0 ? ExtendClientAreaTitleBarHeightHint : 30; + titleAreaControl.Margin = new Thickness(110, -titleBarHeight, 8, 0); + titleAreaControl.Height = titleBarHeight; + } } } diff --git a/tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs b/tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs new file mode 100644 index 0000000000..c080c0e601 --- /dev/null +++ b/tests/Avalonia.IntegrationTests.Appium/PointerTests_MacOS.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Interactions; +using Xunit; + +namespace Avalonia.IntegrationTests.Appium; + +[Collection("WindowDecorations")] +public class PointerTests_MacOS : TestBase, IDisposable +{ + public PointerTests_MacOS(DefaultAppFixture fixture) + : base(fixture, "Window Decorations") + { + } + + [PlatformFact(TestPlatforms.MacOS)] + public void OSXThickTitleBar_Pointer_Events_Continue_Outside_Window_During_Drag() + { + // issue #15696 + SetParameters(true, false, true, true, true); + + var showNewWindowDecorations = Session.FindElementByAccessibilityId("ShowNewWindowDecorations"); + showNewWindowDecorations.Click(); + + Thread.Sleep(1000); + + var secondaryWindow = Session.GetWindowById("SecondaryWindow"); + + var titleAreaControl = secondaryWindow.FindElementByAccessibilityId("TitleAreaControl"); + Assert.NotNull(titleAreaControl); + + new Actions(Session).MoveToElement(secondaryWindow).Perform(); + new Actions(Session).MoveToElement(titleAreaControl).Perform(); + new Actions(Session).DragAndDropToOffset(titleAreaControl, 50, -100).Perform(); + + var finalMoveCount = GetMoveCount(secondaryWindow); + var finalReleaseCount = GetReleaseCount(secondaryWindow); + + Assert.True(finalMoveCount >= 10, $"Expected at least 10 new mouse move events outside window, got {finalMoveCount})"); + Assert.Equal(1, finalReleaseCount); + + secondaryWindow.FindElementByAccessibilityId("_XCUI:CloseWindow").Click(); + } + + private void SetParameters( + bool extendClientArea, + bool forceSystemChrome, + bool preferSystemChrome, + bool macOsThickSystemChrome, + bool showTitleAreaControl) + { + var extendClientAreaCheckBox = Session.FindElementByAccessibilityId("WindowExtendClientAreaToDecorationsHint"); + var forceSystemChromeCheckBox = Session.FindElementByAccessibilityId("WindowForceSystemChrome"); + var preferSystemChromeCheckBox = Session.FindElementByAccessibilityId("WindowPreferSystemChrome"); + var macOsThickSystemChromeCheckBox = Session.FindElementByAccessibilityId("WindowMacThickSystemChrome"); + var showTitleAreaControlCheckBox = Session.FindElementByAccessibilityId("WindowShowTitleAreaControl"); + + if (extendClientAreaCheckBox.GetIsChecked() != extendClientArea) + extendClientAreaCheckBox.Click(); + if (forceSystemChromeCheckBox.GetIsChecked() != forceSystemChrome) + forceSystemChromeCheckBox.Click(); + if (preferSystemChromeCheckBox.GetIsChecked() != preferSystemChrome) + preferSystemChromeCheckBox.Click(); + if (macOsThickSystemChromeCheckBox.GetIsChecked() != macOsThickSystemChrome) + macOsThickSystemChromeCheckBox.Click(); + if (showTitleAreaControlCheckBox.GetIsChecked() != showTitleAreaControl) + showTitleAreaControlCheckBox.Click(); + } + + private int GetMoveCount(AppiumWebElement window) + { + var mouseMoveCountTextBox = window.FindElementByAccessibilityId("MouseMoveCount"); + return int.Parse(mouseMoveCountTextBox.Text ?? "0"); + } + + private int GetReleaseCount(AppiumWebElement window) + { + var mouseReleaseCountTextBox = window.FindElementByAccessibilityId("MouseReleaseCount"); + return int.Parse(mouseReleaseCountTextBox.Text ?? "0"); + } + + public void Dispose() + { + SetParameters(false, false, false, false, false); + var applyButton = Session.FindElementByAccessibilityId("ApplyWindowDecorations"); + applyButton.Click(); + } +} From d709b4d0319e4bbd5f054672ca7e3653d03fe022 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 30 Jul 2025 23:04:59 +0900 Subject: [PATCH 85/94] [iOS] Enable Pointer/Trackpad scrolling (#19342) * [iOS] Enable Pointer/Trackpad scrolling * Cleanup * Update comments, make sure StopMomentumScrolling is called when momentum stops * Wrap RawMouseWheel Invoke call with null check * Replace check --- src/iOS/Avalonia.iOS/AvaloniaView.cs | 11 +++ src/iOS/Avalonia.iOS/InputHandler.cs | 132 +++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/src/iOS/Avalonia.iOS/AvaloniaView.cs b/src/iOS/Avalonia.iOS/AvaloniaView.cs index eaad60a860..491eacf489 100644 --- a/src/iOS/Avalonia.iOS/AvaloniaView.cs +++ b/src/iOS/Avalonia.iOS/AvaloniaView.cs @@ -76,6 +76,17 @@ namespace Avalonia.iOS { #if !TVOS MultipleTouchEnabled = true; + + if (OperatingSystem.IsIOSVersionAtLeast(13, 4) || OperatingSystem.IsMacCatalyst()) + { + var scrollGestureRecognizer = new UIPanGestureRecognizer(_input.HandleScrollWheel) + { + // Only respond to scroll events, not touches + MaximumNumberOfTouches = 0, + AllowedScrollTypesMask = UIScrollTypeMask.Discrete | UIScrollTypeMask.Continuous + }; + AddGestureRecognizer(scrollGestureRecognizer); + } #endif } } diff --git a/src/iOS/Avalonia.iOS/InputHandler.cs b/src/iOS/Avalonia.iOS/InputHandler.cs index 2a28950219..552f003a38 100644 --- a/src/iOS/Avalonia.iOS/InputHandler.cs +++ b/src/iOS/Avalonia.iOS/InputHandler.cs @@ -6,6 +6,9 @@ using Avalonia.Input.Raw; using Avalonia.Platform; using Foundation; using UIKit; +#if !TVOS +using CoreAnimation; +#endif namespace Avalonia.iOS; @@ -20,6 +23,14 @@ internal sealed class InputHandler private readonly PenDevice _penDevice = new(releasePointerOnPenUp: true); private static long _nextTouchPointId = 1; private readonly Dictionary _knownTouches = new(); + private Point? _cachedScrollLocation; + + #if !TVOS + private CADisplayLink? _momentumDisplayLink; + private double _momentumVelocityX; + private double _momentumVelocityY; + private const double DecelerationRate = 0.95; + #endif public InputHandler(AvaloniaView view, ITopLevelImpl tl) { @@ -249,6 +260,127 @@ internal sealed class InputHandler return modifier; } + public void HandleScrollWheel(UIPanGestureRecognizer recognizer) + { + switch (recognizer.State) + { + case UIGestureRecognizerState.Began: + // We've started scrolling, stop any previous inertia scrolling + // and cache the current scroll location. + StopMomentumScrolling(); + _cachedScrollLocation = recognizer.LocationInView(_view).ToAvalonia(); + return; + case UIGestureRecognizerState.Changed: + // When you are actively scrolling, we send the scroll events + SendActiveScrollEvent(recognizer); + return; + case UIGestureRecognizerState.Ended: + // When you stop scrolling, we start inertia scrolling + // UpdateInertiaScrolling will check when the inertia stops + // and will call StopMomentumScrolling + StartInertiaScrolling(recognizer); + return; + case UIGestureRecognizerState.Cancelled: + case UIGestureRecognizerState.Failed: + // If the gesture is cancelled or failed, stop. + StopMomentumScrolling(); + return; + default: + return; + } + } + + private void SendActiveScrollEvent(UIPanGestureRecognizer recognizer) + { +#if !TVOS + // iOS 13.4+ and Catalyst support scroll wheel events + if (!OperatingSystem.IsIOSVersionAtLeast(13, 4) && !OperatingSystem.IsMacCatalyst()) + return; + + var velocity = recognizer.VelocityInView(_view); + + // Use much more sensitive scaling for active scrolling to match AppKit. + // macOS uses small deltas, so we need much larger divisors + var scaleFactor = 3000.0; + + var deltaX = velocity.X / scaleFactor; + var deltaY = velocity.Y / scaleFactor; + + _tl.Input?.Invoke(new RawMouseWheelEventArgs( + _mouseDevice, + (ulong)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), + Root, + _cachedScrollLocation ?? new Point(0, 0), + new Vector(deltaX, deltaY), + RawInputModifiers.None + )); +#endif + } + + private void StartInertiaScrolling(UIPanGestureRecognizer recognizer) + { +#if !TVOS + // iOS 13.4+ and Catalyst support scroll wheel events + if (!OperatingSystem.IsIOSVersionAtLeast(13, 4) && !OperatingSystem.IsMacCatalyst()) + return; + + var velocity = recognizer.VelocityInView(_view); + + var scaleFactor = 800.0; + _momentumVelocityX = velocity.X / scaleFactor; + _momentumVelocityY = velocity.Y / scaleFactor; + _momentumDisplayLink = CADisplayLink.Create(UpdateInertiaScrolling); + _momentumDisplayLink.AddToRunLoop(NSRunLoop.Main, NSRunLoopMode.Common); +#endif + } + + private void StopMomentumScrolling() + { +#if !TVOS + if (_momentumDisplayLink != null) + { + // Invalidate removes it from all run loops + // https://developer.apple.com/documentation/quartzcore/cadisplaylink + _momentumDisplayLink.Invalidate(); + _momentumDisplayLink = null; + } + + _momentumVelocityX = 0; + _momentumVelocityY = 0; + _cachedScrollLocation = null; +#endif + } + + private void UpdateInertiaScrolling() + { +#if !TVOS + _momentumVelocityX *= DecelerationRate; + _momentumVelocityY *= DecelerationRate; + + var currentMagnitude = Math.Sqrt(_momentumVelocityX * _momentumVelocityX + _momentumVelocityY * _momentumVelocityY); + + if (currentMagnitude < 0.0001 || _cachedScrollLocation is null) + { + StopMomentumScrolling(); + return; + } + + // UIPanGestureRecognizer will continue to upload the location of the pointer, + // to where it would be if it was moving with the current velocity, + // even though the pointer on screen is not moving. + // We can cache the location when we start scrolling and keep it static + // until the inertia stops. + _tl.Input?.Invoke(new RawMouseWheelEventArgs( + _mouseDevice, + (ulong)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), + Root, + _cachedScrollLocation.Value, + new Vector(_momentumVelocityX, _momentumVelocityY), + RawInputModifiers.None + )); +#endif + } + #pragma warning disable CA1416 private static Dictionary s_keys = new() { From e8cb18bc438f7a3c68e066af0368149123f9a1b2 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 31 Jul 2025 14:01:28 +0200 Subject: [PATCH 86/94] Add GroupBox (#19366) --- .../Pages/HeaderedContentPage.axaml | 3 + src/Avalonia.Controls/GroupBox.cs | 8 ++ .../Accents/FluentControlResources.xaml | 9 +++ .../Controls/FluentControls.xaml | 1 + .../Controls/GroupBox.xaml | 80 +++++++++++++++++++ .../Controls/GroupBox.xaml | 76 ++++++++++++++++++ .../Controls/SimpleControls.xaml | 1 + 7 files changed, 178 insertions(+) create mode 100644 src/Avalonia.Controls/GroupBox.cs create mode 100644 src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml create mode 100644 src/Avalonia.Themes.Simple/Controls/GroupBox.xaml diff --git a/samples/ControlCatalog/Pages/HeaderedContentPage.axaml b/samples/ControlCatalog/Pages/HeaderedContentPage.axaml index ff44c2206d..86a2b00b72 100644 --- a/samples/ControlCatalog/Pages/HeaderedContentPage.axaml +++ b/samples/ControlCatalog/Pages/HeaderedContentPage.axaml @@ -13,6 +13,9 @@ CornerRadius="3"> + + + diff --git a/src/Avalonia.Controls/GroupBox.cs b/src/Avalonia.Controls/GroupBox.cs new file mode 100644 index 0000000000..490143229d --- /dev/null +++ b/src/Avalonia.Controls/GroupBox.cs @@ -0,0 +1,8 @@ +using Avalonia.Controls.Primitives; + +namespace Avalonia.Controls; + +public class GroupBox : HeaderedContentControl +{ + +} diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml index 6d65889cba..d1b1acf2a2 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml @@ -916,6 +916,15 @@ + + 4 + 16 + 0,4,0,12 + 1 + + + + #FFC58AF9 diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index 90279214e5..18996d6a2d 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -22,6 +22,7 @@ + diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml new file mode 100644 index 0000000000..cd0610944b --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml new file mode 100644 index 0000000000..2285128c12 --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml index a13f579567..51171051d1 100644 --- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml +++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml @@ -29,6 +29,7 @@ + From 8b19628be32ca32f49aa6690eab006d61dbaac8b Mon Sep 17 00:00:00 2001 From: Max Katz Date: Thu, 31 Jul 2025 18:02:49 +0200 Subject: [PATCH 87/94] Commit missing resource values (#19371) --- .../Accents/FluentControlResources.xaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml index d1b1acf2a2..b11ea00898 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml @@ -102,6 +102,15 @@ + + 4 + 16 + 0,4,0,12 + 1 + + + + #FF681DA8 From 0f233c24dee4bc106e6ac91d341eaca56d1972e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Fri, 1 Aug 2025 08:55:57 +0000 Subject: [PATCH 88/94] Set IsKeyboardFocusWithin to false when control is detached from visual tree (#19369) * add failing IsKeyboardFocusWithin test for popup * Set IsKeyboardWithin to false when detached from visual tree --- src/Avalonia.Base/Input/InputElement.cs | 2 + .../Primitives/PopupTests.cs | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index 0e0b6eab5a..f00166d6b0 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -566,6 +566,8 @@ namespace Avalonia.Input { FocusManager.GetFocusManager(this)?.ClearFocusOnElementRemoved(this, e.Parent); } + + IsKeyboardFocusWithin = false; } /// diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 139c4656a1..1a9ce7c655 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -669,6 +669,51 @@ namespace Avalonia.Controls.UnitTests.Primitives } } + [Fact] + public void Popup_Should_Clear_Keyboard_Focus_From_Children_When_Closed() + { + using (CreateServicesWithFocus()) + { + var winButton = new Button(); + var window = PreparedWindow(new Panel { Children = { winButton }}); + + var border1 = new Border(); + var border2 = new Border(); + var button = new Button(); + border1.Child = border2; + border2.Child = button; + var popup = new Popup + { + PlacementTarget = window, + Child = new StackPanel + { + Children = + { + border1 + } + } + }; + + ((ISetLogicalParent)popup).SetParent(popup.PlacementTarget); + window.Show(); + winButton.Focus(); + popup.Open(); + + button.Focus(); + + var inputRoot = Assert.IsAssignableFrom(popup.Host); + + var focusManager = inputRoot.FocusManager!; + Assert.Same(button, focusManager.GetFocusedElement()); + + border1.Child = null; + + winButton.Focus(); + + Assert.False(border2.IsKeyboardFocusWithin); + } + } + [Fact] public void Closing_Popup_Sets_Focus_On_PlacementTarget() { From 77e9b293884947d2fb1d424cf89c8e7d93eff101 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Fri, 1 Aug 2025 18:17:35 +0900 Subject: [PATCH 89/94] Use VisualBrush for GroupBox (#19372) --- .../Controls/GroupBox.xaml | 23 +++++++++++-------- .../Controls/GroupBox.xaml | 23 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml index cd0610944b..1fee84269d 100644 --- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml @@ -33,16 +33,19 @@ CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> - - - - - - - - - - + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml index 2285128c12..6267e3731c 100644 --- a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml @@ -31,16 +31,19 @@ CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> - - - - - - - - - - + + + + + + + + + From cd05ff88b70d93eece2737a7e40b7cf9a48a405e Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Sat, 2 Aug 2025 15:11:02 +0900 Subject: [PATCH 90/94] [iOS] Implement Save File Picker Support (#19364) * [iOS] Implement Save File Picker Support * Delete folder instead * Use StorageProviderHelpers * Use FromBytes to create blank file --- samples/ControlCatalog.iOS/Info.plist | 3 +- .../Storage/IOSStorageProvider.cs | 70 +++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/samples/ControlCatalog.iOS/Info.plist b/samples/ControlCatalog.iOS/Info.plist index b4c7c07eb6..a1aa23e506 100644 --- a/samples/ControlCatalog.iOS/Info.plist +++ b/samples/ControlCatalog.iOS/Info.plist @@ -16,7 +16,6 @@ 1 2 - 3 UIRequiredDeviceCapabilities @@ -38,5 +37,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + com.apple.security.files.user-selected.read-write + diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs index 4c1bf97c6f..79d88c13b0 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs @@ -26,7 +26,7 @@ internal class IOSStorageProvider : IStorageProvider public bool CanOpen => true; - public bool CanSave => false; + public bool CanSave => true; public bool CanPickFolder => true; @@ -161,10 +161,72 @@ internal class IOSStorageProvider : IStorageProvider return Task.FromResult(new IOSStorageFolder(uri, wellKnownFolder)); } - public Task SaveFilePickerAsync(FilePickerSaveOptions options) + public async Task SaveFilePickerAsync(FilePickerSaveOptions options) { - return Task.FromException( - new PlatformNotSupportedException("Save file picker is not supported by iOS")); + /* + This requires a bit of dialog here... + To save a file, we need to present the user with a document picker + This requires a temp file to be created and used to "export" the file to. + When the user picks the file location and name, UIDocumentPickerViewController + will give back the URI to the real file location, which we can then use + to give back as an IStorageFile. + https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller + Yes, it is weird, but without the temp file it will explode. + */ + + // Create a temporary file to use with the document picker + var tempFileName = StorageProviderHelpers.NameWithExtension( + options.SuggestedFileName ?? "document", + options.DefaultExtension, + options.FileTypeChoices?.FirstOrDefault()); + + var tempDir = NSFileManager.DefaultManager.GetTemporaryDirectory().Append(Guid.NewGuid().ToString(), true); + if (tempDir == null) + { + throw new InvalidOperationException("Failed to get temporary directory for save file picker"); + } + + var isDirectoryCreated = NSFileManager.DefaultManager.CreateDirectory(tempDir, true, null, out var error); + if (!isDirectoryCreated) + { + throw new InvalidOperationException("Failed to create temporary directory for save file picker"); + } + + var tempFileUrl = tempDir.Append(tempFileName, false); + + // Create an empty file at the temp location + NSData.FromBytes(0, 0).Save(tempFileUrl, false); + + UIDocumentPickerViewController documentPicker; + if (OperatingSystem.IsIOSVersionAtLeast(14)) + { + documentPicker = new UIDocumentPickerViewController(new[] { tempFileUrl }, asCopy: true); + } + else + { +#pragma warning disable CA1422 + documentPicker = new UIDocumentPickerViewController(tempFileUrl, UIDocumentPickerMode.ExportToService); +#pragma warning restore CA1422 + } + + using (documentPicker) + { + if (OperatingSystem.IsIOSVersionAtLeast(13)) + { + documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation); + } + + documentPicker.Title = options.Title; + + var tcs = new TaskCompletionSource(); + documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls)); + var urls = await ShowPicker(documentPicker, tcs); + + // Clean up the temporary directory + NSFileManager.DefaultManager.Remove(tempDir, out _); + + return urls.FirstOrDefault() is { } url ? new IOSStorageFile(url) : null; + } } public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) From 74e8ce7efad55c078b1e91bc11bfe21ab78053c8 Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen Date: Mon, 4 Aug 2025 08:16:31 +0000 Subject: [PATCH 91/94] restrict souce of input event sto parent view on android (#19289) --- .../Avalonia.Android/AvaloniaActivity.cs | 5 +- .../Avalonia.Android/AvaloniaView.Input.cs | 67 +++++++++++++++++++ src/Android/Avalonia.Android/AvaloniaView.cs | 21 +----- .../Platform/Input/AndroidInputMethod.cs | 3 - .../Platform/SkiaPlatform/TopLevelImpl.cs | 61 +++-------------- 5 files changed, 82 insertions(+), 75 deletions(-) create mode 100644 src/Android/Avalonia.Android/AvaloniaView.Input.cs diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs index fa3484f058..cf425d279e 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -8,10 +8,10 @@ using Android.OS; using Android.Runtime; using Android.Views; using AndroidX.AppCompat.App; -using Avalonia.Platform; using Avalonia.Android.Platform; using Avalonia.Android.Platform.Storage; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform; namespace Avalonia.Android; @@ -48,6 +48,9 @@ public class AvaloniaActivity : AppCompatActivity, IAvaloniaActivity SetContentView(_view); + // By default, the view isn't focused if the activity is created anew, so we force focus. + _view.RequestFocus(); + _listener = new GlobalLayoutListener(_view); _view.ViewTreeObserver?.AddOnGlobalLayoutListener(_listener); diff --git a/src/Android/Avalonia.Android/AvaloniaView.Input.cs b/src/Android/Avalonia.Android/AvaloniaView.Input.cs new file mode 100644 index 0000000000..c829be56be --- /dev/null +++ b/src/Android/Avalonia.Android/AvaloniaView.Input.cs @@ -0,0 +1,67 @@ +using System; +using Android.Views; +using Android.Views.InputMethods; +using Avalonia.Android.Platform.SkiaPlatform; + +namespace Avalonia.Android +{ + public partial class AvaloniaView : IInitEditorInfo + { + private Func? _initEditorInfo; + + public override IInputConnection OnCreateInputConnection(EditorInfo? outAttrs) + { + return _initEditorInfo?.Invoke(_view, outAttrs!)!; + } + + void IInitEditorInfo.InitEditorInfo(Func init) + { + _initEditorInfo = init; + } + + protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) + { + base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); + _accessHelper.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); + } + + protected override bool DispatchHoverEvent(MotionEvent? e) + { + return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e); + } + + protected override bool DispatchGenericPointerEvent(MotionEvent? e) + { + var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + + var baseResult = callBase && base.DispatchGenericPointerEvent(e); + + return result ?? baseResult; + } + + public override bool DispatchTouchEvent(MotionEvent? e) + { + var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + var baseResult = callBase && base.DispatchTouchEvent(e); + + if(result == true) + { + // Request focus for this view + RequestFocus(); + } + + return result ?? baseResult; + } + + public override bool DispatchKeyEvent(KeyEvent? e) + { + var res = _view.KeyboardHelper.DispatchKeyEvent(e, out var callBase); + if (res == false) + callBase = !_accessHelper.DispatchKeyEvent(e!) && callBase; + + var baseResult = callBase && base.DispatchKeyEvent(e); + + return res ?? baseResult; + } + } +} diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index ced2f11077..665feb2e2b 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -17,7 +17,7 @@ using Avalonia.Rendering; namespace Avalonia.Android { - public class AvaloniaView : FrameLayout + public partial class AvaloniaView : FrameLayout { private EmbeddableControlRoot _root; private readonly ViewImpl _view; @@ -71,24 +71,6 @@ namespace Avalonia.Android _root = null!; } - protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) - { - base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); - _accessHelper.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); - } - - protected override bool DispatchHoverEvent(MotionEvent? e) - { - return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e); - } - - public override bool DispatchKeyEvent(KeyEvent? e) - { - if (!_view.View.DispatchKeyEvent(e)) - return _accessHelper.DispatchKeyEvent(e!) || base.DispatchKeyEvent(e); - return true; - } - [SupportedOSPlatform("android24.0")] public override void OnVisibilityAggregated(bool isVisible) { @@ -149,7 +131,6 @@ namespace Avalonia.Android { public ViewImpl(AvaloniaView avaloniaView) : base(avaloniaView) { - View.Focusable = true; View.FocusChange += ViewImpl_FocusChange; } diff --git a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs index 8003db6607..2e8e145ef8 100644 --- a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs @@ -44,9 +44,6 @@ namespace Avalonia.Android.Platform.Input public AndroidInputMethod(TView host) { - if (host.OnCheckIsTextEditor() == false) - throw new InvalidOperationException("Host should return true from OnCheckIsTextEditor()"); - _host = host; _imm = host.Context?.GetSystemService(Context.InputMethodService).JavaCast() ?? throw new InvalidOperationException("Context.InputMethodService is expected to be not null."); diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 00ae95abaf..142657c8ce 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -5,9 +5,7 @@ using Android.Content; using Android.Graphics; using Android.Graphics.Drawables; using Android.Runtime; -using Android.Text; using Android.Views; -using Android.Views.InputMethods; using AndroidX.AppCompat.App; using Avalonia.Android.Platform.Input; using Avalonia.Android.Platform.Specific; @@ -15,13 +13,11 @@ using Avalonia.Android.Platform.Specific.Helpers; using Avalonia.Android.Platform.Storage; using Avalonia.Controls; using Avalonia.Controls.Platform; -using Avalonia.Controls.Platform.Surfaces; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; using Avalonia.OpenGL.Egl; -using Avalonia.OpenGL.Surfaces; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Rendering.Composition; @@ -34,7 +30,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform { private readonly AndroidKeyboardEventsHelper _keyboardHelper; private readonly AndroidMotionEventsHelper _pointerHelper; - private readonly AndroidInputMethod _textInputMethod; + private readonly AndroidInputMethod _textInputMethod; private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider? _storageProvider; private readonly AndroidSystemNavigationManagerImpl _systemNavigationManager; @@ -42,7 +38,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly ClipboardImpl _clipboard; private readonly AndroidLauncher? _launcher; private readonly AndroidScreens? _screens; - private ViewImpl _view; + private SurfaceViewImpl _view; private WindowTransparencyLevel _transparencyLevel; public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) @@ -52,8 +48,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform throw new ArgumentException("AvaloniaView.Context must not be null"); } - _view = new ViewImpl(avaloniaView.Context, this, placeOnTop); - _textInputMethod = new AndroidInputMethod(_view); + _view = new SurfaceViewImpl(avaloniaView.Context, this, placeOnTop); + _textInputMethod = new AndroidInputMethod(avaloniaView); _keyboardHelper = new AndroidKeyboardEventsHelper(this); _pointerHelper = new AndroidMotionEventsHelper(this); _clipboard = new ClipboardImpl(avaloniaView.Context.GetSystemService(Context.ClipboardService).JavaCast()); @@ -141,13 +137,13 @@ namespace Avalonia.Android.Platform.SkiaPlatform Resized?.Invoke(size, WindowResizeReason.Layout); } - sealed class ViewImpl : InvalidationAwareSurfaceView, IInitEditorInfo + sealed class SurfaceViewImpl : InvalidationAwareSurfaceView { private readonly TopLevelImpl _tl; private Size _oldSize; private double _oldScaling; - public ViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) + public SurfaceViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) { _tl = tl; if (placeOnTop) @@ -176,30 +172,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform base.DispatchDraw(canvas); } - protected override bool DispatchGenericPointerEvent(MotionEvent? e) - { - var result = _tl._pointerHelper.DispatchMotionEvent(e, out var callBase); - var baseResult = callBase && base.DispatchGenericPointerEvent(e); - - return result ?? baseResult; - } - - public override bool DispatchTouchEvent(MotionEvent? e) - { - var result = _tl._pointerHelper.DispatchMotionEvent(e, out var callBase); - var baseResult = callBase && base.DispatchTouchEvent(e); - - return result ?? baseResult; - } - - public override bool DispatchKeyEvent(KeyEvent? e) - { - var res = _tl._keyboardHelper.DispatchKeyEvent(e, out var callBase); - var baseResult = callBase && base.DispatchKeyEvent(e); - - return res ?? baseResult; - } - public override void SurfaceChanged(ISurfaceHolder holder, Format format, int width, int height) { base.SurfaceChanged(holder, format, width, height); @@ -232,23 +204,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform _tl.Compositor.RequestCompositionUpdate(drawingFinished.Run); base.SurfaceRedrawNeededAsync(holder, drawingFinished); } - - public override bool OnCheckIsTextEditor() - { - return true; - } - - private Func? _initEditorInfo; - - public void InitEditorInfo(Func init) - { - _initEditorInfo = init; - } - - public override IInputConnection OnCreateInputConnection(EditorInfo? outAttrs) - { - return _initEditorInfo?.Invoke(_tl, outAttrs!)!; - } } public IPopupImpl? CreatePopup() => null; @@ -291,6 +246,10 @@ namespace Avalonia.Android.Platform.SkiaPlatform PixelSize EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Size => _view.Size; double EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Scaling => _view.Scaling; + internal AndroidKeyboardEventsHelper KeyboardHelper => _keyboardHelper; + + internal AndroidMotionEventsHelper PointerHelper => _pointerHelper; + public void SetTransparencyLevelHint(IReadOnlyList transparencyLevels) { if (_view.Context is not AvaloniaMainActivity activity) From f3b418d435de52f3c938ed58014a3e5dc13809e3 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 4 Aug 2025 14:30:10 +0500 Subject: [PATCH 92/94] Implemented Dispatcher.Yield / Dispatcher.Resume (#19370) * Refactored DispatcherPriorityAwaitable, implemented Dispatcher.Yield * Picked changes from https://github.com/AvaloniaUI/Avalonia/pull/14212 * Format/compile * Update API suppressions --------- Co-authored-by: Yoh Deadfall Co-authored-by: Julien Lebosquain --- api/Avalonia.nupkg.xml | 60 +++++++++ .../Threading/Dispatcher.Invoke.cs | 66 ++++++++++ .../Threading/DispatcherPriorityAwaitable.cs | 122 +++++++++++++++--- .../DispatcherTests.cs | 100 ++++++++++++++ 4 files changed, 332 insertions(+), 16 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 347e6b3b08..8d64cb2a82 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -109,6 +109,42 @@ baseline/netstandard2.0/Avalonia.Base.dll target/netstandard2.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.get_IsCompleted + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.GetAwaiter + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.GetResult + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.OnCompleted(System.Action) + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable`1.GetAwaiter + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable`1.GetResult + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + CP0002 M:Avalonia.Controls.Primitives.IPopupHost.ConfigurePosition(Avalonia.Visual,Avalonia.Controls.PlacementMode,Avalonia.Point,Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor,Avalonia.Controls.Primitives.PopupPositioning.PopupGravity,Avalonia.Controls.Primitives.PopupPositioning.PopupPositionerConstraintAdjustment,System.Nullable{Avalonia.Rect}) @@ -187,6 +223,30 @@ baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + CP0007 + T:Avalonia.Threading.DispatcherPriorityAwaitable + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0007 + T:Avalonia.Threading.DispatcherPriorityAwaitable`1 + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0008 + T:Avalonia.Threading.DispatcherPriorityAwaitable + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0008 + T:Avalonia.Threading.DispatcherPriorityAwaitable`1 + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + CP0009 T:Avalonia.Diagnostics.StyleDiagnostics diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index 324a50e4b4..afee481252 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -672,4 +672,70 @@ public partial class Dispatcher /// public DispatcherPriorityAwaitable AwaitWithPriority(Task task, DispatcherPriority priority) => new(this, task, priority); + + /// + /// Creates an awaitable object that asynchronously resumes execution on the dispatcher. + /// + /// + /// An awaitable object that asynchronously resumes execution on the dispatcher. + /// + /// + /// This method is equivalent to calling the method + /// and passing in . + /// + public DispatcherPriorityAwaitable Resume() => + Resume(DispatcherPriority.Background); + + /// `` + /// Creates an awaitable object that asynchronously resumes execution on the dispatcher. The work that occurs + /// when control returns to the code awaiting the result of this method is scheduled with the specified priority. + /// + /// The priority at which to schedule the continuation. + /// + /// An awaitable object that asynchronously resumes execution on the dispatcher. + /// + public DispatcherPriorityAwaitable Resume(DispatcherPriority priority) + { + DispatcherPriority.Validate(priority, nameof(priority)); + return new(this, null, priority); + } + + /// + /// Creates an awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// An awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// This method is equivalent to calling the method + /// and passing in . + /// + /// + /// The current thread is not the UI thread. + /// + public static DispatcherPriorityAwaitable Yield() => + Yield(DispatcherPriority.Background); + + /// + /// Creates an cawaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. The work that occurs when + /// control returns to the code awaiting the result of this method is scheduled with the specified priority. + /// + /// The priority at which to schedule the continuation. + /// + /// An awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// The current thread is not the UI thread. + /// + public static DispatcherPriorityAwaitable Yield(DispatcherPriority priority) + { + // TODO12: Update to use Dispatcher.CurrentDispatcher once multi-dispatcher support is merged + var current = UIThread; + current.VerifyAccess(); + return UIThread.Resume(priority); + } } diff --git a/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs b/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs index 456e2d7551..ab4fb38b5a 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs @@ -1,40 +1,130 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace Avalonia.Threading; -public class DispatcherPriorityAwaitable : INotifyCompletion +/// +/// A simple awaitable type that will return a DispatcherPriorityAwaiter. +/// +public struct DispatcherPriorityAwaitable { private readonly Dispatcher _dispatcher; - private protected readonly Task Task; + private readonly Task? _task; private readonly DispatcherPriority _priority; - internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) + internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task? task, DispatcherPriority priority) { _dispatcher = dispatcher; - Task = task; + _task = task; _priority = priority; } - - public void OnCompleted(Action continuation) => - Task.ContinueWith(_ => _dispatcher.Post(continuation, _priority)); - public bool IsCompleted => Task.IsCompleted; + public DispatcherPriorityAwaiter GetAwaiter() => new(_dispatcher, _task, _priority); +} + +/// +/// A simple awaiter type that will queue the continuation to a dispatcher at a specific priority. +/// +/// +/// This is returned from DispatcherPriorityAwaitable.GetAwaiter() +/// +public struct DispatcherPriorityAwaiter : INotifyCompletion +{ + private readonly Dispatcher _dispatcher; + private readonly Task? _task; + private readonly DispatcherPriority _priority; + + internal DispatcherPriorityAwaiter(Dispatcher dispatcher, Task? task, DispatcherPriority priority) + { + _dispatcher = dispatcher; + _task = task; + _priority = priority; + } + + public void OnCompleted(Action continuation) + { + if(_task == null || _task.IsCompleted) + _dispatcher.Post(continuation, _priority); + else + { + var self = this; + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + self._dispatcher.Post(continuation, self._priority); + }); + } + } + + /// + /// This always returns false since continuation is requested to be queued to a dispatcher queue + /// + public bool IsCompleted => false; + + public void GetResult() + { + if (_task != null) + _task.GetAwaiter().GetResult(); + } +} + +/// +/// A simple awaitable type that will return a DispatcherPriorityAwaiter<T>. +/// +public struct DispatcherPriorityAwaitable +{ + private readonly Dispatcher _dispatcher; + private readonly Task _task; + private readonly DispatcherPriority _priority; - public void GetResult() => Task.GetAwaiter().GetResult(); + internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) + { + _dispatcher = dispatcher; + _task = task; + _priority = priority; + } - public DispatcherPriorityAwaitable GetAwaiter() => this; + public DispatcherPriorityAwaiter GetAwaiter() => new(_dispatcher, _task, _priority); } -public sealed class DispatcherPriorityAwaitable : DispatcherPriorityAwaitable +/// +/// A simple awaiter type that will queue the continuation to a dispatcher at a specific priority. +/// +/// +/// This is returned from DispatcherPriorityAwaitable<T>.GetAwaiter() +/// +public struct DispatcherPriorityAwaiter : INotifyCompletion { - internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) : base( - dispatcher, task, priority) + private readonly Dispatcher _dispatcher; + private readonly Task _task; + private readonly DispatcherPriority _priority; + + internal DispatcherPriorityAwaiter(Dispatcher dispatcher, Task task, DispatcherPriority priority) { + _dispatcher = dispatcher; + _task = task; + _priority = priority; } - public new T GetResult() => ((Task)Task).GetAwaiter().GetResult(); + public void OnCompleted(Action continuation) + { + if(_task.IsCompleted) + _dispatcher.Post(continuation, _priority); + else + { + var self = this; + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + self._dispatcher.Post(continuation, self._priority); + }); + } + } + + /// + /// This always returns false since continuation is requested to be queued to a dispatcher queue + /// + public bool IsCompleted => false; - public new DispatcherPriorityAwaitable GetAwaiter() => this; -} + public void GetResult() => _task.GetAwaiter().GetResult(); +} \ No newline at end of file diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index a667057708..1884a1ab65 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -505,4 +505,104 @@ public partial class DispatcherTests t.GetAwaiter().GetResult(); } } + + + [Fact] + public async Task DispatcherResumeContinuesOnUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + + await Dispatcher.UIThread.Resume(); + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task DispatcherYieldContinuesOnUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Dispatcher.Yield(); + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task DispatcherYieldThrowsOnNonUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + await Assert.ThrowsAsync(async () => await Dispatcher.Yield()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task AwaitWithPriorityRunsOnUIThread() + { + static async Task Workload() + { + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + + return Thread.CurrentThread.ManagedThreadId; + } + + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + Task taskWithoutResult = Workload(); + + await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); + + Assert.True(Dispatcher.UIThread.CheckAccess()); + Task taskWithResult = Workload(); + + await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); + + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } } From df1816bde5e226b34ff9a660bd49c0786c51c257 Mon Sep 17 00:00:00 2001 From: Alexander Marek Date: Mon, 4 Aug 2025 16:12:02 +0200 Subject: [PATCH 93/94] #18626 - improved scrolling performance in VirtualizingStackPanel.cs by reducing Measure/Arrange calls since they cause heavy GC pressure on constrained devices (Android, iOS) especially with complex item views (#18646) Co-authored-by: alexander.marek Co-authored-by: Steven Kirk Co-authored-by: Julien Lebosquain --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 3 + .../VirtualizingStackPanel.cs | 209 ++- .../VirtualizingStackPanelTests.cs | 1407 +++++++++++++---- 3 files changed, 1340 insertions(+), 279 deletions(-) diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index 7694845009..e3a706bfed 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -20,6 +20,9 @@ + Hosts a collection of ListBoxItem. diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index adeebf97d9..e883bb533b 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; +using Avalonia.Reactive; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -51,6 +52,12 @@ namespace Avalonia.Controls RoutedEvent.Register( nameof(VerticalSnapPointsChanged), RoutingStrategies.Bubble); + /// + /// Defines the property. + /// + public static readonly StyledProperty CacheLengthProperty = + AvaloniaProperty.Register(nameof(CacheLength), 0.0, + validate: v => v is >= 0 and <= 2); private static readonly AttachedProperty RecycleKeyProperty = AvaloniaProperty.RegisterAttached("RecycleKey"); @@ -73,12 +80,24 @@ namespace Avalonia.Controls private int _focusedIndex = -1; private Control? _realizingElement; private int _realizingIndex = -1; + private double _bufferFactor; + + private bool _hasReachedStart = false; + private bool _hasReachedEnd = false; + private Rect _extendedViewport; + + static VirtualizingStackPanel() + { + CacheLengthProperty.Changed.AddClassHandler((x, e) => x.OnCacheLengthChanged(e)); + } public VirtualizingStackPanel() { _recycleElement = RecycleElement; _recycleElementOnItemRemoved = RecycleElementOnItemRemoved; _updateElementIndex = UpdateElementIndex; + + _bufferFactor = Math.Max(0, CacheLength); EffectiveViewportChanged += OnEffectiveViewportChanged; } @@ -131,6 +150,20 @@ namespace Avalonia.Controls set => SetValue(AreVerticalSnapPointsRegularProperty, value); } + /// + /// Gets or sets the CacheLength. + /// + /// The factor determines how much additional space to maintain above and below the viewport. + /// A value of 0.5 means half the viewport size will be buffered on each side (up-down or left-right) + /// This uses more memory as more UI elements are realized, but greatly reduces the number of Measure-Arrange + /// cycles which can cause heavy GC pressure depending on the complexity of the item layouts. + /// + public double CacheLength + { + get => GetValue(CacheLengthProperty); + set => SetValue(CacheLengthProperty, value); + } + /// /// Gets the index of the first realized element, or -1 if no elements are realized. /// @@ -141,6 +174,16 @@ namespace Avalonia.Controls /// public int LastRealizedIndex => _realizedElements?.LastIndex ?? -1; + /// + /// Returns the viewport that contains any visible elements + /// + internal Rect ViewPort => _viewport; + + /// + /// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2) + /// + internal Rect ExtendedViewPort => _extendedViewport; + protected override Size MeasureOverride(Size availableSize) { var items = Items; @@ -217,8 +260,12 @@ namespace Avalonia.Controls var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, sizeU, finalSize.Height) : new Rect(0, u, finalSize.Width, sizeU); + e.Arrange(rect); - _scrollAnchorProvider?.RegisterAnchorCandidate(e); + + if (_viewport.Intersects(rect)) + _scrollAnchorProvider?.RegisterAnchorCandidate(e); + u += orientation == Orientation.Horizontal ? rect.Width : rect.Height; } } @@ -230,6 +277,7 @@ namespace Avalonia.Controls var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, _focusedElement.DesiredSize.Width, finalSize.Height) : new Rect(0, u, finalSize.Width, _focusedElement.DesiredSize.Height); + _focusedElement.Arrange(rect); } @@ -416,6 +464,7 @@ namespace Avalonia.Controls // Create and measure the element to be brought into view. Store it in a field so that // it can be re-used in the layout pass. var scrollToElement = GetOrCreateElement(items, index); + scrollToElement.Measure(Size.Infinity); // Get the expected position of the element and put it in place. @@ -483,7 +532,8 @@ namespace Avalonia.Controls { Debug.Assert(_realizedElements is not null); - var viewport = _viewport; + // Use the extended viewport for calculations + var viewport = _extendedViewport; // Get the viewport in the orientation direction. var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y; @@ -653,7 +703,6 @@ namespace Avalonia.Controls return index * estimatedSize; } - private void RealizeElements( IReadOnlyList items, Size availableSize, @@ -666,6 +715,10 @@ namespace Avalonia.Controls var index = viewport.anchorIndex; var horizontal = Orientation == Orientation.Horizontal; var u = viewport.anchorU; + + // Reset boundary flags + _hasReachedStart = false; + _hasReachedEnd = false; // If the anchor element is at the beginning of, or before, the start of the viewport // then we can recycle all elements before it. @@ -678,8 +731,9 @@ namespace Avalonia.Controls _realizingIndex = index; var e = GetOrCreateElement(items, index); _realizingElement = e; + e.Measure(availableSize); - + var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; @@ -691,7 +745,10 @@ namespace Avalonia.Controls _realizingIndex = -1; _realizingElement = null; } while (u < viewport.viewportUEnd && index < items.Count); - + + // Check if we reached the end of the collection + _hasReachedEnd = index >= items.Count; + // Store the last index and end U position for the desired size calculation. viewport.lastIndex = index - 1; viewport.realizedEndU = u; @@ -706,8 +763,8 @@ namespace Avalonia.Controls while (u > viewport.viewportUStart && index >= 0) { var e = GetOrCreateElement(items, index); + e.Measure(availableSize); - var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; u -= sizeU; @@ -716,6 +773,9 @@ namespace Avalonia.Controls viewport.measuredV = Math.Max(viewport.measuredV, sizeV); --index; } + + // Check if we reached the start of the collection + _hasReachedStart = index < 0; // We can now recycle elements before the first element. _realizedElements.RecycleElementsBefore(index + 1, _recycleElement); @@ -748,7 +808,7 @@ namespace Avalonia.Controls { return _realizedElements?.GetElement(index); } - + private static Control? GetRealizedElement( int index, ref int specialIndex, @@ -891,22 +951,146 @@ namespace Avalonia.Controls ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex); } - + private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) { var vertical = Orientation == Orientation.Vertical; var oldViewportStart = vertical ? _viewport.Top : _viewport.Left; var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left; + var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right; + // Update current viewport _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); _isWaitingForViewportUpdate = false; + // Calculate buffer sizes based on viewport dimensions + var viewportSize = vertical ? _viewport.Height : _viewport.Width; + var bufferSize = viewportSize * _bufferFactor; + + // Calculate extended viewport with relative buffers + var extendedViewportStart = vertical ? + Math.Max(0, _viewport.Top - bufferSize) : + Math.Max(0, _viewport.Left - bufferSize); + + var extendedViewportEnd = vertical ? + Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) : + Math.Min(Bounds.Width, _viewport.Right + bufferSize); + + // special case: + // If we are at the start of the list, append 2 * CacheLength additional items + // If we are at the end of the list, prepend 2 * CacheLength additional items + // - this way we always maintain "2 * CacheLength * element" items. + if (vertical) + { + var spaceAbove = _viewport.Top - bufferSize; + var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize); + + if (spaceAbove < 0 && spaceBelow >= 0) + extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove)); + if (spaceAbove >= 0 && spaceBelow < 0) + extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceBelow)); + } + else + { + var spaceLeft = _viewport.Left - bufferSize; + var spaceRight = Bounds.Width - (_viewport.Right + bufferSize); + + if (spaceLeft < 0 && spaceRight >= 0) + extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft)); + if(spaceLeft >= 0 && spaceRight < 0) + extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight)); + } + + Rect extendedViewPort; + if (vertical) + { + extendedViewPort = new Rect( + _viewport.X, + extendedViewportStart, + _viewport.Width, + extendedViewportEnd - extendedViewportStart); + } + else + { + extendedViewPort = new Rect( + extendedViewportStart, + _viewport.Y, + extendedViewportEnd - extendedViewportStart, + _viewport.Height); + } + + // Determine if we need a new measure var newViewportStart = vertical ? _viewport.Top : _viewport.Left; var newViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var newExtendedViewportStart = vertical ? extendedViewPort.Top : extendedViewPort.Left; + var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right; + var needsMeasure = false; + + + // Case 1: Viewport has changed significantly if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) || !MathUtilities.AreClose(oldViewportEnd, newViewportEnd)) { + // Case 1a: The new viewport exceeds the old extended viewport + if (newViewportStart < oldExtendedViewportStart || + newViewportEnd > oldExtendedViewportEnd) + { + needsMeasure = true; + } + // Case 1b: The extended viewport has changed significantly + else if (!MathUtilities.AreClose(oldExtendedViewportStart, newExtendedViewportStart) || + !MathUtilities.AreClose(oldExtendedViewportEnd, newExtendedViewportEnd)) + { + // Check if we're about to scroll into an area where we don't have realized elements + // This would be the case if we're near the edge of our current extended viewport + var nearingEdge = false; + + if (_realizedElements != null) + { + var firstRealizedElementU = _realizedElements.StartU; + var lastRealizedElementU = _realizedElements.StartU; + + for (var i = 0; i < _realizedElements.Count; i++) + { + lastRealizedElementU += _realizedElements.SizeU[i]; + } + + // If scrolling up/left and nearing the top/left edge of realized elements + if (newViewportStart < oldViewportStart && + newViewportStart - newExtendedViewportStart < bufferSize) + { + // Edge case: We're at item 0 with excess measurement space. + // Skip re-measuring since we're at the list start and it won't change the result. + // This prevents redundant Measure-Arrange cycles when at list beginning. + nearingEdge = !_hasReachedStart; + } + + // If scrolling down/right and nearing the bottom/right edge of realized elements + if (newViewportEnd > oldViewportEnd && + newExtendedViewportEnd - newViewportEnd < bufferSize) + { + // Edge case: We're at the last item with excess measurement space. + // Skip re-measuring since we're at the list end and it won't change the result. + // This prevents redundant Measure-Arrange cycles when at list beginning. + nearingEdge = !_hasReachedEnd; + } + } + else + { + nearingEdge = true; + } + + needsMeasure = nearingEdge; + } + } + + if (needsMeasure) + { + // only store the new "old" extended viewport if we _did_ actually measure + _extendedViewport = extendedViewPort; + InvalidateMeasure(); } } @@ -924,6 +1108,15 @@ namespace Avalonia.Controls } } + private void OnCacheLengthChanged(AvaloniaPropertyChangedEventArgs e) + { + var newValue = e.GetNewValue(); + _bufferFactor = newValue; + + // Force a recalculation of the extended viewport on the next layout pass + InvalidateMeasure(); + } + /// public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) { diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 0b33239687..6c6252d836 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; @@ -24,137 +25,167 @@ namespace Avalonia.Controls.UnitTests public class VirtualizingStackPanelTests : ScopedTestBase { private static FuncDataTemplate CanvasWithHeightTemplate = new((_, _) => - new Canvas + new CanvasCountingMeasureArrangeCalls { Width = 100, [!Layoutable.HeightProperty] = new Binding("Height"), }); private static FuncDataTemplate CanvasWithWidthTemplate = new((_, _) => - new Canvas + new CanvasCountingMeasureArrangeCalls { Height = 100, [!Layoutable.WidthProperty] = new Binding("Width"), }); - [Fact] - public void Creates_Initial_Items() + [Theory] + [InlineData(0d , 10)] + [InlineData(0.5d, 20)] + public void Creates_Initial_Items(double bufferFactor, int expectedCount) { using var app = App(); - var (target, scroll, itemsControl) = CreateTarget(); + var (target, scroll, itemsControl) = CreateTarget(bufferFactor:bufferFactor); Assert.Equal(1000, scroll.Extent.Height); - AssertRealizedItems(target, itemsControl, 0, 10); + AssertRealizedItems(target, itemsControl, 0, expectedCount); } - [Fact] - public void Initializes_Initial_Control_Items() + [Theory] + [InlineData(0d, 10)] + [InlineData(0.5d, 20)] // Buffer factor of 0.5. Since at start there is no room, the 10 additional items are just appended + public void Initializes_Initial_Control_Items(double bufferFactor, int expectedCount) { using var app = App(); var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 }); - var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null, bufferFactor:bufferFactor); Assert.Equal(1000, scroll.Extent.Height); - AssertRealizedControlItems