diff --git a/api/Avalonia.Headless.nupkg.xml b/api/Avalonia.Headless.nupkg.xml
new file mode 100644
index 0000000000..96c70a7793
--- /dev/null
+++ b/api/Avalonia.Headless.nupkg.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ CP0002
+ M:Avalonia.Headless.HeadlessWindowExtensions.GetOpenPopups(Avalonia.Controls.TopLevel)
+ baseline/Avalonia.Headless/lib/net10.0/Avalonia.Headless.dll
+ current/Avalonia.Headless/lib/net10.0/Avalonia.Headless.dll
+
+
+ CP0002
+ M:Avalonia.Headless.HeadlessWindowExtensions.GetOpenPopups(Avalonia.Controls.TopLevel)
+ baseline/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll
+ current/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll
+
+
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
index 94fdc5d3e3..591189ae6e 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
@@ -67,10 +67,36 @@ namespace Avalonia.Media.TextFormatting
text = text.Slice(shapeableRun.Length);
- previousProperties = shapeableRun.Properties;
+ // Whitespace says nothing about which font the text around it wants, and it belongs to
+ // the default typeface whenever that covers it - so a run of pure whitespace must not
+ // become the anti-thrashing bias for what follows. Otherwise the words on either side
+ // of a space each resolve their fallback from scratch and can land on different fonts.
+ if (!IsWhiteSpaceOnly(shapeableRun.Text.Span))
+ {
+ previousProperties = shapeableRun.Properties;
+ }
}
}
+ ///
+ /// Returns whether every codepoint in is whitespace. Returns on the
+ /// first codepoint that isn't, so a run of text costs a single lookup.
+ ///
+ private static bool IsWhiteSpaceOnly(ReadOnlySpan text)
+ {
+ var codepoints = new CodepointEnumerator(text);
+
+ while (codepoints.MoveNext(out var codepoint))
+ {
+ if (!codepoint.IsWhiteSpace)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
///
/// Creates a shapeable text run with unique properties.
///
@@ -149,18 +175,19 @@ namespace Avalonia.Media.TextFormatting
GlyphTypeface? fallbackGlyphTypeface = null;
var fallbackResolved = false;
- // A primary that cannot shape this tier's script is not a valid "return target": pass
- // null so the return-to-primary check doesn't hand clusters back to it, which would
- // otherwise block a shaping-capable fallback that merely shares the primary's cmap.
+ // A primary that cannot shape this tier's script is not a valid "return target" for
+ // text: handing clusters back to it would block a shaping-capable fallback that merely
+ // shares the primary's cmap. It still reclaims the spacing whitespace between the
+ // words, which needs no shaping - see TryGetShapeableLength.
var defaultCanShape = !requireShapingCapability || defaultGlyphTypeface.CanShapeScript(firstScript);
- var primaryForReturn = defaultCanShape ? defaultGlyphTypeface : null;
for (var pass = 0; pass < 2; pass++)
{
var requireFullCluster = pass == 0;
if (defaultCanShape &&
- TryGetShapeableLength(textSpan, defaultGlyphTypeface, null, requireFullCluster, out count))
+ TryGetShapeableLength(textSpan, defaultGlyphTypeface, null, defaultCanShapeScript: false,
+ requireFullCluster, out count))
{
// Primary font: the properties already carry this typeface, so reuse them
// directly. This avoids a needless copy and preserves a custom
@@ -170,7 +197,8 @@ namespace Avalonia.Media.TextFormatting
if (allowPreviousTypeface && previousGlyphTypeface is not null &&
(!requireShapingCapability || previousGlyphTypeface.CanShapeScript(firstScript)) &&
- TryGetShapeableLength(textSpan, previousGlyphTypeface, primaryForReturn, requireFullCluster, out count))
+ TryGetShapeableLength(textSpan, previousGlyphTypeface, defaultGlyphTypeface, defaultCanShape,
+ requireFullCluster, out count))
{
return new UnshapedTextRun(text.Slice(0, count),
defaultProperties.WithTypeface(previousTypeface!.Value), biDiLevel);
@@ -200,7 +228,8 @@ namespace Avalonia.Media.TextFormatting
}
if (fallbackGlyphTypeface is not null &&
- TryGetShapeableLength(textSpan, fallbackGlyphTypeface, primaryForReturn, requireFullCluster, out count))
+ TryGetShapeableLength(textSpan, fallbackGlyphTypeface, defaultGlyphTypeface, defaultCanShape,
+ requireFullCluster, out count))
{
return new UnshapedTextRun(text.Slice(0, count),
defaultProperties.WithTypeface(fallbackTypeface), biDiLevel);
@@ -249,7 +278,12 @@ namespace Avalonia.Media.TextFormatting
///
/// The characters to shape.
/// The typeface that is used to find matching characters.
- /// The default typeface.
+ /// The default typeface, or null when there is none to
+ /// return to (the probe for the default typeface itself).
+ ///
+ /// Whether the default typeface can shape this run's script. When false it only reclaims
+ /// spacing whitespace, which needs no shaping.
+ ///
///
/// When true, a grapheme cluster only counts as supported when the typeface has a glyph
/// for every scalar it contains (base plus combining marks); when false, only the base
@@ -261,6 +295,7 @@ namespace Avalonia.Media.TextFormatting
ReadOnlySpan text,
GlyphTypeface glyphTypeface,
GlyphTypeface? defaultGlyphTypeface,
+ bool defaultCanShapeScript,
bool requireFullCluster,
out int length)
{
@@ -287,8 +322,14 @@ namespace Avalonia.Media.TextFormatting
var clusterText = text.Slice(currentGrapheme.Offset, currentGrapheme.Length);
- if (!currentCodepoint.IsWhiteSpace
- && defaultGlyphTypeface != null
+ // A fallback run ends where the default typeface regains coverage, spacing whitespace
+ // included - practically every font maps U+0020, so exempting it would let the run
+ // shape the following space with the fallback's own advance. A default typeface that
+ // cannot shape this script still reclaims that whitespace, which carries no shaping.
+ // Only Zs qualifies: control and format codepoints (bidi controls, prepended number
+ // signs) keep their cluster with the probed font.
+ if (defaultGlyphTypeface != null
+ && (defaultCanShapeScript || currentCodepoint.GeneralCategory == GeneralCategory.SpaceSeparator)
&& ClusterIsCovered(clusterText, currentCodepoint, defaultGlyphTypeface, requireFullCluster))
{
break;
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
index 2289f9508e..16f0e2690b 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
@@ -834,7 +834,10 @@ namespace Avalonia.Media.TextFormatting
return false;
}
- if (currentBounds.Rectangle.Left == lastBounds.Rectangle.Right)
+ // The two edges are computed by summing glyph advances along different paths, so
+ // abutting bounds can land an ULP apart - compare them the way the rest of layout
+ // compares coordinates, or a single directional span gets reported as two.
+ if (MathUtilities.AreClose(currentBounds.Rectangle.Left, lastBounds.Rectangle.Right))
{
foreach (var runBounds in currentBounds.TextRunBounds)
{
@@ -846,7 +849,7 @@ namespace Avalonia.Media.TextFormatting
return true;
}
- if (currentBounds.Rectangle.Right == lastBounds.Rectangle.Left)
+ if (MathUtilities.AreClose(currentBounds.Rectangle.Right, lastBounds.Rectangle.Left))
{
for (int i = 0; i < currentBounds.TextRunBounds.Count; i++)
{
diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs
index 75dd723fe3..ba9ca5b572 100644
--- a/src/Avalonia.Controls/Primitives/Popup.cs
+++ b/src/Avalonia.Controls/Primitives/Popup.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Reactive;
@@ -153,6 +154,7 @@ namespace Avalonia.Controls.Primitives
private bool _isUsingOverlayLayer;
private PopupOpenState? _openState;
private Action? _popupHostChangedHandler;
+ private List? _openedPopups;
///
/// Initializes static members of the class.
@@ -177,6 +179,11 @@ namespace Avalonia.Controls.Primitives
internal IPopupHost? Host => _openState?.PopupHost;
+ ///
+ /// Gets the popups that are currently open directly inside this popup, in the order they were opened.
+ ///
+ public IReadOnlyList OpenedPopups => _openedPopups ?? (IReadOnlyList)[];
+
///
/// Gets or sets a hint to the window manager that a shadow should be added to the popup.
///
@@ -575,7 +582,12 @@ namespace Avalonia.Controls.Primitives
}
}
- _openState = new PopupOpenState(placementTarget, topLevel, popupHost, cleanupPopup);
+ _openState = new PopupOpenState(placementTarget, topLevel, popupHost, cleanupPopup, FindParentPopup(placementTarget));
+
+ if (_openState.ParentPopup is { } parentPopup)
+ parentPopup.AddOpenedPopup(this);
+ else
+ topLevel.AddOpenedPopup(this);
WindowManagerAddShadowHintChanged(popupHost, WindowManagerAddShadowHint);
@@ -844,6 +856,11 @@ namespace Avalonia.Controls.Primitives
return;
}
+ if (_openState.ParentPopup is { } parentPopup)
+ parentPopup.RemoveOpenedPopup(this);
+ else
+ _openState.TopLevel.RemoveOpenedPopup(this);
+
_openState.Dispose();
_openState = null;
@@ -1063,22 +1080,41 @@ namespace Avalonia.Controls.Primitives
}
}
+ internal void AddOpenedPopup(Popup popup) => (_openedPopups ??= new List(capacity: 2)).Add(popup);
+
+ internal void RemoveOpenedPopup(Popup popup) => _openedPopups?.Remove(popup);
+
+ private static Popup? FindParentPopup(Visual placementTarget)
+ {
+ foreach (var visual in placementTarget.GetSelfAndVisualAncestors())
+ {
+ if (visual is IPopupHost)
+ return (visual as StyledElement)?.Parent as Popup;
+ }
+
+ return null;
+ }
+
private class PopupOpenState : IDisposable
{
private readonly IDisposable _cleanup;
private IDisposable? _presenterCleanup;
private Control _placementTarget;
- public PopupOpenState(Control placementTarget, TopLevel topLevel, IPopupHost popupHost, IDisposable cleanup)
+ public PopupOpenState(Control placementTarget, TopLevel topLevel, IPopupHost popupHost, IDisposable cleanup,
+ Popup? parentPopup)
{
PlacementTarget = placementTarget;
TopLevel = topLevel;
+ ParentPopup = parentPopup;
PopupHost = popupHost;
_cleanup = cleanup;
}
public TopLevel TopLevel { get; }
+ public Popup? ParentPopup { get; }
+
public Control PlacementTarget
{
get => _placementTarget;
diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs
index 234f47258d..890bc09cb7 100644
--- a/src/Avalonia.Controls/Primitives/PopupRoot.cs
+++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
@@ -122,6 +123,8 @@ namespace Avalonia.Controls.Primitives
public TopLevel ParentTopLevel { get; }
+ public override IReadOnlyList OpenedPopups => (Parent as Popup)?.OpenedPopups ?? [];
+
///
public void Dispose()
{
diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs
index 2fd1524cef..721b20ccab 100644
--- a/src/Avalonia.Controls/TopLevel.cs
+++ b/src/Avalonia.Controls/TopLevel.cs
@@ -132,6 +132,7 @@ namespace Avalonia.Controls
private TargetWeakEventSubscriber? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
private Screens? _screens;
+ private List? _openedPopups;
private readonly PresentationSource _source;
private readonly TopLevelHost _topLevelHost;
internal TopLevelHost TopLevelHost => _topLevelHost;
@@ -559,6 +560,14 @@ namespace Avalonia.Controls
// TODO: Un-private
private IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService();
+ ///
+ /// Gets the popups that are currently open directly in this top level, in the order they were opened.
+ ///
+ ///
+ /// Use for nested popups.
+ ///
+ public virtual IReadOnlyList OpenedPopups => _openedPopups ?? (IReadOnlyList)[];
+
///
/// Gets the for which the given is hosted in.
///
@@ -708,6 +717,7 @@ namespace Avalonia.Controls
LayoutManager.Dispose();
_platformImplBindings.Clear();
+ _openedPopups = null;
}
///
@@ -724,6 +734,10 @@ namespace Avalonia.Controls
Renderer.Resized(clientSize);
}
+ internal void AddOpenedPopup(Popup popup) => (_openedPopups ??= new List(capacity: 2)).Add(popup);
+
+ internal void RemoveOpenedPopup(Popup popup) => _openedPopups?.Remove(popup);
+
///
/// Handles a window scaling change notification from
/// .
diff --git a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
index 4e14dc0f0b..2d39a9bc6b 100644
--- a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
+++ b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
@@ -121,8 +121,6 @@ namespace Avalonia.Headless
///
/// Embeds popups to the window when set to true. The default value is true.
- /// When disabled, popups are hosted in dedicated headless top-levels that are not part of
- /// the parent's visual tree; use to access them.
///
// TODO13: Change the default to false to match the other desktop platforms.
public bool OverlayPopups { get; set; } = true;
diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
index 17fe5aa199..7f7232359b 100644
--- a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
+++ b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
@@ -115,18 +115,6 @@ public static class HeadlessWindowExtensions
DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) =>
RunJobsOnImpl(topLevel, w => w.DragDrop(point, type, data, effects, modifiers));
- ///
- /// Returns the popups currently open directly above this toplevel, in z-order (bottom to top).
- /// For popups nested in another popup, call this method on that popup's toplevel.
- ///
- ///
- /// Only popups hosted in dedicated headless top-levels are returned, which requires disabling
- /// . Popups hosted in the overlay
- /// layer are part of the parent's visual tree and are not tracked by the platform.
- ///
- public static IReadOnlyList GetOpenPopups(this TopLevel topLevel) =>
- GetImpl(topLevel).GetOpenPopups();
-
///
/// Changes the render scaling (DPI) of the headless window/toplevel.
/// This simulates a DPI change, triggering scaling changed notifications and a layout pass.
diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
index 97a24d66b0..1ddaa4165e 100644
--- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
+++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
@@ -27,7 +27,6 @@ namespace Avalonia.Headless
private readonly AvaloniaHeadlessPlatformOptions _options;
private readonly HeadlessWindowImpl? _popupParent;
private readonly IPopupPositioner? _popupPositioner;
- private readonly List _openPopups = new();
public bool IsPopup { get; }
public HeadlessWindowImpl(AvaloniaHeadlessPlatformOptions options)
@@ -60,7 +59,6 @@ namespace Avalonia.Headless
public void Dispose()
{
- _popupParent?._openPopups.Remove(this);
Closed?.Invoke();
_lastRenderedFrame?.Dispose();
_lastRenderedFrame = null;
@@ -101,9 +99,6 @@ namespace Avalonia.Headless
public void Show(bool activate, bool isDialog)
{
- if (_popupParent != null && !_popupParent._openPopups.Contains(this))
- _popupParent._openPopups.Add(this);
-
if (activate)
{
ZOrder = _nextGlobalZOrder++;
@@ -113,7 +108,6 @@ namespace Avalonia.Headless
public void Hide()
{
- _popupParent?._openPopups.Remove(this);
Dispatcher.UIThread.Post(() => Deactivated?.Invoke(), DispatcherPriority.Input);
}
@@ -403,21 +397,6 @@ namespace Avalonia.Headless
public IPopupImpl? CreatePopup() => _options.OverlayPopups ? null : new HeadlessWindowImpl(this);
- public IReadOnlyList GetOpenPopups()
- {
- if (_openPopups.Count == 0)
- return Array.Empty();
-
- var result = new List(_openPopups.Count);
- foreach (var popup in _openPopups)
- {
- if (popup.InputRoot is PresentationSource { FocusRoot: TopLevel topLevel })
- result.Add(topLevel);
- }
-
- return result;
- }
-
public void SetWindowManagerAddShadowHint(bool enabled)
{
diff --git a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs
index 5e2dd6a13d..3fe06d619c 100644
--- a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs
+++ b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs
@@ -19,6 +19,5 @@ namespace Avalonia.Headless
void MouseWheel(Point point, Vector delta, RawInputModifiers modifiers = RawInputModifiers.None);
void DragDrop(Point point, RawDragEventType type, IDataTransfer data, DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None);
void SetRenderScaling(double scaling);
- IReadOnlyList GetOpenPopups();
}
}
diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
index 3cca86d3b4..3548cd614f 100644
--- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
@@ -1392,6 +1392,109 @@ namespace Avalonia.Controls.UnitTests.Primitives
}
}
+ [Fact]
+ public void Opened_Popup_Should_Be_In_OpenedPopups()
+ {
+ using (CreateServices())
+ {
+ var target = new Popup();
+ var window = PreparedWindow(target);
+
+ target.Open();
+
+ Assert.Equal(new[] { target }, window.OpenedPopups);
+
+ target.Close();
+
+ Assert.Empty(window.OpenedPopups);
+ }
+ }
+
+ [Fact]
+ public void Closing_Popup_With_IsOpen_Should_Remove_It_From_OpenedPopups()
+ {
+ using (CreateServices())
+ {
+ var target = new Popup();
+ var window = PreparedWindow(target);
+
+ target.IsOpen = true;
+
+ Assert.Equal(new[] { target }, window.OpenedPopups);
+
+ target.IsOpen = false;
+
+ Assert.Empty(window.OpenedPopups);
+ }
+ }
+
+ [Fact]
+ public void Closing_Window_Should_Clear_OpenedPopups()
+ {
+ using (CreateServices())
+ {
+ var target = new Popup();
+ var window = PreparedWindow(target);
+
+ target.Open();
+ window.Close();
+
+ Assert.Empty(window.OpenedPopups);
+ }
+ }
+
+ [Fact]
+ public void Nested_Popup_Should_Be_In_Parent_Popup_OpenedPopups()
+ {
+ using (CreateServices())
+ {
+ var nestedTarget = new Border { Width = 20, Height = 20 };
+ var nestedPopup = new Popup
+ {
+ PlacementTarget = nestedTarget,
+ Child = new Border { Width = 10, Height = 10 }
+ };
+ var target = new Border();
+ var popup = new Popup
+ {
+ PlacementTarget = target,
+ Child = new Panel { Children = { nestedTarget, nestedPopup } }
+ };
+ var window = PreparedWindow(new Panel { Children = { target, popup } });
+
+ popup.Open();
+
+ if (popup.Host is OverlayPopupHost host)
+ {
+ //Need to measure/arrange for visual children to show up
+ //in OverlayPopupHost
+ host.Measure(Size.Infinity);
+ host.Arrange(new Rect(host.DesiredSize));
+ }
+
+ nestedPopup.Open();
+
+ Assert.Equal([popup], window.OpenedPopups);
+ Assert.Equal([nestedPopup], popup.OpenedPopups);
+ Assert.Empty(nestedPopup.OpenedPopups);
+
+ if (popup.Host is PopupRoot popupRoot)
+ {
+ // A popup root exposes the popups opened by its own popup.
+ Assert.Equal([nestedPopup], popupRoot.OpenedPopups);
+ }
+
+ nestedPopup.Close();
+
+ Assert.Equal([popup], window.OpenedPopups);
+ Assert.Empty(popup.OpenedPopups);
+
+ popup.Close();
+
+ Assert.Empty(window.OpenedPopups);
+ }
+ }
+
private IDisposable CreateServices()
{
return UnitTestApplication.Start(TestServices.StyledWindow.With(
@@ -1430,13 +1533,22 @@ namespace Avalonia.Controls.UnitTests.Primitives
{
if (UsePopupHost)
return null;
- return MockWindowingPlatform.CreatePopupMock(mock.Object).Object;
+ return CreatePopupMock(mock.Object);
});
return mock.Object;
}, null);
}
+ private static IPopupImpl CreatePopupMock(IWindowBaseImpl parent)
+ {
+ var mock = MockWindowingPlatform.CreatePopupMock(parent);
+
+ mock.Setup(x => x.CreatePopup()).Returns(() => CreatePopupMock(mock.Object));
+
+ return mock.Object;
+ }
+
private static Window PreparedWindow(object? content = null)
{
var w = new Window { Content = content };
diff --git a/tests/Avalonia.Headless.UnitTests/AssertHelper.cs b/tests/Avalonia.Headless.UnitTests/AssertHelper.cs
index 1c98afcb9a..c3ba56d05f 100644
--- a/tests/Avalonia.Headless.UnitTests/AssertHelper.cs
+++ b/tests/Avalonia.Headless.UnitTests/AssertHelper.cs
@@ -1,5 +1,7 @@
#nullable enable
+using System.Diagnostics.CodeAnalysis;
+
namespace Avalonia.Headless.UnitTests;
internal static class AssertHelper
@@ -22,14 +24,26 @@ internal static class AssertHelper
#endif
}
- public static void NotNull(object? value)
+ public static void Null(object? value)
+ {
+#if NUNIT
+ Assert.That(value, Is.Null);
+#elif XUNIT
+ Assert.Null(value);
+#endif
+ }
+
+ public static void NotNull([NotNull] object? value)
{
#if NUNIT
Assert.That(value, Is.Not.Null);
#elif XUNIT
Assert.NotNull(value);
#endif
+ // NUnit doesn't suppress CS8777 warning on its own
+#pragma warning disable CS8777 // Parameter must have a non-null value when exiting.
}
+#pragma warning restore CS8777 // Parameter must have a non-null value when exiting.
public static void Equal(T expected, T actual)
{
diff --git a/tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs b/tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs
index d501635fbe..8c2fa92bb7 100644
--- a/tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs
+++ b/tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs
@@ -65,7 +65,11 @@ public class MouseDeviceTests
// Pressing captures the pointer implicitly on the window's border.
window.MouseDown(new Point(50, 50), MouseButton.Left);
- window.GetOpenPopups()[0].MouseMove(new Point(40, 15));
+
+ var popupRoot = PopupTests.GetPopupTopLevel(popup);
+ AssertHelper.NotNull(popupRoot);
+
+ popupRoot.MouseMove(new Point(40, 15));
AssertHelper.Same(TestApplication.UsesSharedMouseDevice ? target : popupChild, moveTarget);
diff --git a/tests/Avalonia.Headless.UnitTests/PopupTests.cs b/tests/Avalonia.Headless.UnitTests/PopupTests.cs
index 2a1536ce50..2eef10a0c4 100644
--- a/tests/Avalonia.Headless.UnitTests/PopupTests.cs
+++ b/tests/Avalonia.Headless.UnitTests/PopupTests.cs
@@ -32,7 +32,7 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
- public void Popup_Uses_Dedicated_TopLevel_And_Is_Discoverable()
+ public void Popup_Uses_Dedicated_TopLevel()
{
var target = new Border { Background = Brushes.Red };
var popup = new Popup
@@ -49,22 +49,22 @@ public class PopupTests
window.Show();
Dispatcher.UIThread.RunJobs();
- AssertHelper.Equal(0, window.GetOpenPopups().Count);
+ AssertHelper.False(popup.IsOpen);
+ AssertHelper.False(popup.IsUsingOverlayLayer);
+ AssertHelper.Null(GetPopupTopLevel(popup));
popup.Open();
Dispatcher.UIThread.RunJobs();
AssertHelper.True(popup.IsOpen);
AssertHelper.False(popup.IsUsingOverlayLayer);
- AssertHelper.Equal(1, window.GetOpenPopups().Count);
- AssertHelper.True(window.GetOpenPopups()[0] is PopupRoot);
-
- popup.Close();
- Dispatcher.UIThread.RunJobs();
-
- AssertHelper.Equal(0, window.GetOpenPopups().Count);
+ AssertHelper.True(GetPopupTopLevel(popup) is PopupRoot);
window.Close();
+
+ AssertHelper.False(popup.IsOpen);
+ AssertHelper.False(popup.IsUsingOverlayLayer);
+ AssertHelper.Null(GetPopupTopLevel(popup));
}
#if NUNIT
@@ -72,31 +72,40 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
- public void Can_Click_Button_Inside_Platform_Popup()
+ public void Popup_Placement_Respects_Window_Position()
{
- var clickCount = 0;
- var button = new Button { Width = 80, Height = 30 };
- button.Click += (_, _) => clickCount++;
-
- var target = new Border { Background = Brushes.Red };
- var popup = new Popup { PlacementTarget = target, Child = button };
+ var target = new Border
+ {
+ Width = 20,
+ Height = 20,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ Background = Brushes.Red
+ };
+ var popup = new Popup
+ {
+ PlacementTarget = target,
+ Placement = PlacementMode.Bottom,
+ Child = new Border { Width = 20, Height = 20 }
+ };
var window = new Window
{
Width = 100,
Height = 100,
Content = new Panel { Children = { target, popup } }
};
+ window.Position = new PixelPoint(100, 200);
window.Show();
Dispatcher.UIThread.RunJobs();
popup.Open();
Dispatcher.UIThread.RunJobs();
- var popupRoot = window.GetOpenPopups()[0];
- popupRoot.MouseDown(new Point(40, 15), MouseButton.Left);
- popupRoot.MouseUp(new Point(40, 15), MouseButton.Left);
+ var popupRoot = GetPopupTopLevel(popup);
+ AssertHelper.NotNull(popupRoot);
- AssertHelper.Equal(1, clickCount);
+ var expected = target.PointToScreen(new Point(0, target.Bounds.Height));
+ AssertHelper.Equal(expected, popupRoot.PointToScreen(default));
window.Close();
}
@@ -106,38 +115,33 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
- public void Popup_Placement_Respects_Window_Position()
+ public void Can_Click_Button_Inside_Platform_Popup()
{
- var target = new Border
- {
- Width = 20,
- Height = 20,
- HorizontalAlignment = HorizontalAlignment.Left,
- VerticalAlignment = VerticalAlignment.Top,
- Background = Brushes.Red
- };
- var popup = new Popup
- {
- PlacementTarget = target,
- Placement = PlacementMode.Bottom,
- Child = new Border { Width = 20, Height = 20 }
- };
+ var clickCount = 0;
+ var button = new Button { Width = 80, Height = 30 };
+ button.Click += (_, _) => clickCount++;
+
+ var target = new Border { Background = Brushes.Red };
+ var popup = new Popup { PlacementTarget = target, Child = button };
var window = new Window
{
Width = 100,
Height = 100,
Content = new Panel { Children = { target, popup } }
};
- window.Position = new PixelPoint(100, 200);
window.Show();
Dispatcher.UIThread.RunJobs();
popup.Open();
Dispatcher.UIThread.RunJobs();
- var popupRoot = window.GetOpenPopups()[0];
- var expected = target.PointToScreen(new Point(0, target.Bounds.Height));
- AssertHelper.Equal(expected, popupRoot.PointToScreen(default));
+ var popupRoot = GetPopupTopLevel(popup);
+ AssertHelper.NotNull(popupRoot);
+
+ popupRoot.MouseDown(new Point(40, 15), MouseButton.Left);
+ popupRoot.MouseUp(new Point(40, 15), MouseButton.Left);
+
+ AssertHelper.Equal(1, clickCount);
window.Close();
}
@@ -147,7 +151,7 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
- public void Nested_Popup_Is_Child_Of_Popup_Root()
+ public void Nested_Popup_Is_Owned_By_Parent_Popup()
{
var nestedTarget = new Border { Width = 20, Height = 20, Background = Brushes.Green };
var nestedPopup = new Popup
@@ -175,11 +179,34 @@ public class PopupTests
nestedPopup.Open();
Dispatcher.UIThread.RunJobs();
- var popupRoot = window.GetOpenPopups()[0];
- AssertHelper.Equal(1, window.GetOpenPopups().Count);
- AssertHelper.Equal(1, popupRoot.GetOpenPopups().Count);
- AssertHelper.True(popupRoot.GetOpenPopups()[0] is PopupRoot);
+ AssertHelper.Equal(1, window.OpenedPopups.Count);
+ AssertHelper.Same(popup, window.OpenedPopups[0]);
+
+ AssertHelper.Equal(1, popup.OpenedPopups.Count);
+ AssertHelper.Same(nestedPopup, popup.OpenedPopups[0]);
+ AssertHelper.Equal(0, nestedPopup.OpenedPopups.Count);
+
+ // The nested popup is hosted in the parent popup's own top level.
+ AssertHelper.Same(GetPopupTopLevel(popup), TopLevel.GetTopLevel(nestedTarget));
+
+ nestedPopup.Close();
+ Dispatcher.UIThread.RunJobs();
+
+ AssertHelper.Equal(0, popup.OpenedPopups.Count);
+ AssertHelper.Equal(1, window.OpenedPopups.Count);
+
+ popup.Close();
+ Dispatcher.UIThread.RunJobs();
+
+ AssertHelper.Equal(0, window.OpenedPopups.Count);
window.Close();
}
+
+ internal static TopLevel GetPopupTopLevel(Popup popup)
+ {
+ AssertHelper.NotNull(popup.Child);
+ var topLevel = TopLevel.GetTopLevel(popup.Child);
+ return topLevel;
+ }
}
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs
index afa6be06af..b62ffa59c1 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs
@@ -31,6 +31,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
private const string NotoSansScFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansSC-Subset.ttf";
private const string NotoSansJpFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansJP-Subset.ttf";
+ // A colour emoji font, of the kind every platform ships: it covers the emoji block and, like
+ // practically every font, U+0020 - at an advance of its own that is not the primary's.
+ private const string EmojiFont = "Avalonia.Skia.UnitTests.Assets.TwitterColorEmoji-SVGinOT.ttf";
+
+ // U+1F642 ๐ โ covered by the emoji font only.
+ private const int EmojiCodepoint = 0x1F642;
+
// U+4E2D ไธญ โ a CJK ideograph covered by neither curated font, and with no platform fallback,
// so it has no match at all.
private const int NoMatchCodepoint = 0x4E2D;
@@ -329,6 +336,215 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
+ // A fallback run must end where the primary font regains coverage, whitespace included.
+ // Practically every font maps U+0020, so a run that is extended for as long as the fallback
+ // has glyphs swallows the space that follows the fallback text and shapes it with the
+ // fallback's space glyph - which is a full em in most emoji fonts.
+ [Fact]
+ public void GetShapeableCharacters_Does_Not_Absorb_Whitespace_Into_A_Fallback_Run()
+ {
+ using (Start(PrimaryFont, FallbackFont))
+ {
+ var fontManager = FontManager.Current;
+
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default);
+ var defaultGlyphTypeface = defaultProperties.CachedGlyphTypeface;
+ var defaultFontFamily = defaultProperties.Typeface.FontFamily;
+
+ // Preconditions: the primary lacks the Hebrew letter but covers both the space and the
+ // letter after it, and the fallback that covers the Hebrew letter maps the space too -
+ // which is what lets the fallback run reach past the letter today.
+ Assert.False(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(FallbackCodepoint, out _));
+ Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
+ Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph('b', out _));
+
+ Assert.True(fontManager.TryMatchCharacter(FallbackCodepoint, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultFontFamily, null, out var fallbackTypeface));
+ Assert.True(fontManager.TryGetGlyphTypeface(fallbackTypeface, out var fallbackGlyphTypeface));
+ Assert.True(fallbackGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
+
+ var text = (char.ConvertFromUtf32(FallbackCodepoint) + " b").AsMemory();
+
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ TextRunProperties? previousProperties = null;
+
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ Assert.Equal(2, results.Count);
+
+ // The fallback run covers the Hebrew letter only. Before the fix it was 2 characters
+ // long: the space was pulled into the fallback run and rendered with its metrics.
+ Assert.Equal(1, results[0].Length);
+ Assert.Equal(fallbackTypeface, results[0].Properties!.Typeface);
+
+ // The space returns to the primary along with the rest of the text.
+ Assert.Equal(2, results[1].Length);
+ Assert.Equal(defaultProperties.Typeface, results[1].Properties!.Typeface);
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ // The user-visible half of the same defect: the absorbed space is measured with the fallback
+ // font, so a space typed after an emoji has a different advance than the same space elsewhere
+ // in the line - a full em with the platform emoji fonts, and a narrower space with the emoji
+ // font bundled here. Either way it is not the primary's.
+ // https://github.com/AvaloniaUI/Avalonia/issues/14011
+ [Fact]
+ public void FormatLine_Keeps_A_Space_After_A_Fallback_Run_At_The_Primary_Width()
+ {
+ using (Start(PrimaryFont, EmojiFont))
+ {
+ var fontManager = FontManager.Current;
+
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default);
+ var defaultGlyphTypeface = defaultProperties.CachedGlyphTypeface;
+
+ Assert.False(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(EmojiCodepoint, out _));
+
+ Assert.True(fontManager.TryMatchCharacter(EmojiCodepoint, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultProperties.Typeface.FontFamily, null, out var emojiTypeface));
+ Assert.True(fontManager.TryGetGlyphTypeface(emojiTypeface, out var emojiGlyphTypeface));
+
+ // The whole point of the test: the two fonts disagree about how wide a space is, so
+ // whichever font shapes it is directly observable in the line width.
+ Assert.NotEqual(SpaceAdvanceInEm(defaultGlyphTypeface), SpaceAdvanceInEm(emojiGlyphTypeface), 3);
+
+ var formatter = new TextFormatterImpl();
+
+ double Width(string text)
+ {
+ var textLine = formatter.FormatLine(new SingleBufferTextSource(text, defaultProperties), 0,
+ double.PositiveInfinity, new GenericTextParagraphProperties(defaultProperties));
+
+ Assert.NotNull(textLine);
+
+ return textLine.WidthIncludingTrailingWhitespace;
+ }
+
+ var emoji = char.ConvertFromUtf32(EmojiCodepoint);
+
+ // Isolate the space by differencing, so the surrounding glyphs' advances cancel out.
+ var plainSpace = Width("a b") - Width("ab");
+ var spaceAfterFallback = Width(emoji + " b") - Width(emoji + "b");
+
+ Assert.Equal(plainSpace, spaceAfterFallback, 3);
+ }
+ }
+
+ private static double SpaceAdvanceInEm(GlyphTypeface glyphTypeface)
+ {
+ Assert.True(glyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out var glyph));
+ Assert.True(glyphTypeface.TryGetHorizontalGlyphAdvance(glyph, out var advance));
+
+ return (double)advance / glyphTypeface.Metrics.DesignEmHeight;
+ }
+
+ // The previous run's font is reused as an anti-thrashing bias. A space belongs to the primary
+ // font, so it forms a run of its own between two fallback words - and that run must not become
+ // the bias, or each word re-runs the fallback search and the two can land on different fonts.
+ [Fact]
+ public void GetShapeableCharacters_Keeps_The_Previous_Fallback_Across_A_Space()
+ {
+ using (Start(PrimaryFont, NotoSansScFont, NotoSansJpFont))
+ {
+ var fontManager = FontManager.Current;
+
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default);
+
+ // The previous run resolved to the Simplified-Chinese font.
+ var scTypeface = new Typeface(new FontFamily("fonts:SystemFonts#Noto Sans SC"));
+ Assert.True(fontManager.TryGetGlyphTypeface(scTypeface, out var scGlyphTypeface));
+
+ const int han = 0x4E2D; // ไธญ, covered by both regional fonts.
+
+ // Preconditions: the primary covers the space but not the ideograph, the previous font
+ // covers the ideograph, and a fresh search for it would pick the *other* font - so the
+ // font of the second run tells us whether the bias survived the space.
+ Assert.True(defaultProperties.CachedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
+ Assert.False(defaultProperties.CachedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(han, out _));
+ Assert.True(scGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(han, out _));
+
+ Assert.True(fontManager.TryMatchCharacter(han, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultProperties.Typeface.FontFamily, null, out var freshMatch));
+ Assert.True(fontManager.TryGetGlyphTypeface(freshMatch, out var freshGlyphTypeface));
+ Assert.Equal("Noto Sans JP", freshGlyphTypeface.FamilyName);
+
+ var text = (" " + char.ConvertFromUtf32(han)).AsMemory();
+
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ TextRunProperties? previousProperties = new GenericTextRunProperties(scTypeface);
+
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ Assert.Equal(2, results.Count);
+
+ Assert.Equal(1, results[0].Length);
+ Assert.Equal(defaultProperties.Typeface, results[0].Properties!.Typeface);
+
+ Assert.True(fontManager.TryGetGlyphTypeface(results[1].Properties!.Typeface, out var runGlyphTypeface));
+ Assert.Equal("Noto Sans SC", runGlyphTypeface.FamilyName);
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ // Only spacing whitespace (Zs) returns to the default typeface. Codepoint.IsWhiteSpace also
+ // covers control and format codepoints - including the default-ignorable bidi controls, which
+ // many fonts map. A default typeface that cannot shape the script must not pull a
+ // right-to-left mark out of the fallback run just because its cmap has it: the mark renders
+ // nothing either way, and splitting there cuts the run for no reason.
+ [Fact]
+ public void TryGetShapeableLength_Does_Not_Reclaim_A_Bidi_Control_As_Whitespace()
+ {
+ using (Start(PrimaryFont, FallbackFont))
+ {
+ // DejaVu Sans plays the default: its cmap has the Arabic letter, the right-to-left
+ // mark and the space, but the test probes the tier where it cannot shape Arabic.
+ // Cascadia Code plays the probed fallback; it has the letter and needs no glyph for
+ // the default-ignorable mark.
+ var defaultGlyphTypeface = new Typeface(FontFamily.Parse(
+ "resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#DejaVu Sans")).GlyphTypeface;
+ var probedGlyphTypeface = new Typeface(FontFamily.Parse(
+ "resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code")).GlyphTypeface;
+
+ const int alef = 0x0627;
+ const int rightToLeftMark = 0x200F;
+
+ Assert.True(probedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(alef, out _));
+ Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(rightToLeftMark, out _));
+ Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
+
+ // Letter, mark, letter, then a space: the mark stays inside the fallback run, the
+ // space still returns to the default.
+ var text = "ุงโุง z";
+
+ Assert.True(TextCharacters.TryGetShapeableLength(text.AsSpan(), probedGlyphTypeface,
+ defaultGlyphTypeface, defaultCanShapeScript: false, requireFullCluster: true,
+ out var length));
+
+ Assert.Equal(3, length);
+ }
+ }
+
// A spread of combining marks (all grapheme-cluster Extend) likely present in a broad fallback
// font but absent from a minimal monospace primary. The F1 test picks the first workable one.
private static readonly int[] CombiningMarkCandidates =
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
index 182c633418..0ffa620822 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
@@ -319,7 +319,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
[Fact]
- public void Should_Produce_A_Single_Fallback_Run()
+ public void Should_Not_Absorb_Whitespace_Into_A_Fallback_Run()
{
using (Start())
{
@@ -337,7 +337,18 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.NotNull(textLine);
- Assert.Equal(1, textLine.TextRuns.Count);
+ // Four emoji in a fallback font, separated by three spaces the primary font covers:
+ // the spaces keep the primary's metrics instead of the emoji font's, so they form
+ // runs of their own.
+ Assert.Equal(7, textLine.TextRuns.Count);
+
+ for (var i = 0; i < textLine.TextRuns.Count; i++)
+ {
+ var isSpace = i % 2 == 1;
+
+ Assert.Equal(isSpace ? 1 : 2, textLine.TextRuns[i].Length);
+ Assert.Equal(isSpace, defaultProperties.Typeface == textLine.TextRuns[i].Properties!.Typeface);
+ }
}
}
@@ -410,11 +421,15 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
+ // The expectations concatenate the run texts in visual run order, so where the spaces sit
+ // in the string depends on how the line is cut into runs. Each space is a run of its own
+ // now (the primary font owns it, not the Hebrew fallback), which regroups the same
+ // characters - the right-to-left rows below show the same content, differently split.
[Theory]
[InlineData("one ืฉืชืืื three ืืจืืข", "one ืฉืชืืื thrโฆ", FlowDirection.LeftToRight, false)]
- [InlineData("one ืฉืชืืื three ืืจืืข", "โฆthrืฉืชืืื one", FlowDirection.RightToLeft, false)]
+ [InlineData("one ืฉืชืืื three ืืจืืข", "โฆthr ืฉืชืืื one", FlowDirection.RightToLeft, false)]
[InlineData("one ืฉืชืืื three ืืจืืข", "one ืฉืชืืืโฆ", FlowDirection.LeftToRight, true)]
- [InlineData("one ืฉืชืืื three ืืจืืข", "โฆืฉืชืืื one", FlowDirection.RightToLeft, true)]
+ [InlineData("one ืฉืชืืื three ืืจืืข", "โฆ ืฉืชืืื one", FlowDirection.RightToLeft, true)]
public void TextTrimming_Should_Trim_Correctly(string text, string trimmed, FlowDirection direction, bool wordEllipsis)
{
const double Width = 160.0;
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
index 1d33defa83..d8732ef8bc 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
@@ -475,7 +475,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
[Theory]
[InlineData("โ๐ฟ", new int[] { 0 })]
- [InlineData("โ๐ฟ ab", new int[] { 0, 3, 0, 1 })]
+ [InlineData("โ๐ฟ ab", new int[] { 0, 0, 1, 2 })]
[InlineData("ab โ๐ฟ", new int[] { 0, 1, 2, 0 })]
public void Should_Create_Valid_Clusters_For_Text(string text, int[] clusters)
{
@@ -985,9 +985,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textLine = layout.TextLines[0];
+ // Runs come in visual order, so TextRuns[0] is the leftmost one - the last word of
+ // this right-to-left line. Its glyph clusters are relative to its own text, so the
+ // run's start has to be added to compare them with a text source index.
var firstRun = (ShapedTextRun)textLine.TextRuns[0];
- var firstCluster = firstRun.ShapedBuffer[0].GlyphCluster;
+ var firstCluster = TextTestHelper.GetStartCharIndex(firstRun.Text)
+ + firstRun.ShapedBuffer[0].GlyphCluster;
var characterHit = textLine.GetCharacterHitFromDistance(0);
@@ -1059,13 +1063,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
return rawClusters;
}
- // Clusters can be either run-local or text-source relative depending on split history.
- if (rawClusters.Min() < runStart)
- {
- return rawClusters.Select(cluster => cluster + runStart);
- }
+ // A run's clusters are relative to the text it was shaped from, which is its
+ // own text for a freshly shaped run but the parent's for a split child. The
+ // smallest cluster is the run's first character either way, so rebasing on it
+ // maps both onto text source indices.
+ var baseCluster = rawClusters.Min();
- return rawClusters;
+ return rawClusters.Select(cluster => cluster - baseCluster + runStart);
}).ToList();
var glyphAdvances = shapedRuns.SelectMany(x => x.ShapedBuffer, (_, glyph) => glyph.GlyphAdvance).ToList();
@@ -1101,8 +1105,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
[InlineData("mgfg๐งdf f sdf", "g๐งd", 20, 40)]
[InlineData("ูู. ููุฏ ุชุนุฑุถ ูุงูุชูุงุฏุงุช", "ุฏุงุช", 5, 30)]
[InlineData("ูู. ููุฏ ุชุนุฑุถ ูุงูุชูุงุฏุงุช", "ุชุนุฑุถ", 20, 50)]
- [InlineData(" ุนูู
ูุฉ ๐ฑูู
ุถููุฉ ุ", " ุนูู
ูุฉ ๐ฑูู
ุถููุฉ ุ", 40, 100)]
- [InlineData("ูู ุนุงู
2018 ุ ุฑูุนุช ู", "ูู ุนุงู
2018 ุ ุฑูุนุช ู", 100, 120)]
+ // The spaces of an Arabic line are drawn with the primary font rather than the Arabic
+ // fallback, which is wider at this size - hence the bands sit above where they used to.
+ [InlineData(" ุนูู
ูุฉ ๐ฑูู
ุถููุฉ ุ", " ุนูู
ูุฉ ๐ฑูู
ุถููุฉ ุ", 80, 120)]
+ [InlineData("ูู ุนุงู
2018 ุ ุฑูุนุช ู", "ูู ุนุงู
2018 ุ ุฑูุนุช ู", 120, 150)]
[Theory]
public void HitTestTextRange_Range_ValidLength(string text, string textToSelect, double minWidth, double maxWidth)
{
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
index 1ec027a12c..6dc0ac4f2e 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
@@ -1413,11 +1413,14 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
foreach (var textRun in shapedTextRuns)
{
+ // Glyph clusters are relative to the run's own text, so they only line up across a
+ // multi-run line once the run's start is added - same as BuildGlyphClusters.
+ var runOffset = TextTestHelper.GetStartCharIndex(textRun.Text);
var shapedBuffer = textRun.ShapedBuffer;
for (var index = 0; index < shapedBuffer.Length; index++)
{
- var currentCluster = shapedBuffer[index].GlyphCluster;
+ var currentCluster = shapedBuffer[index].GlyphCluster + runOffset;
var advance = shapedBuffer[index].GlyphAdvance;
@@ -1427,13 +1430,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
else
{
- var rect = rects[index - 1];
-
- rects.Remove(rect);
-
- rect = rect.WithWidth(rect.Width + advance);
+ // Another glyph of the cluster that produced the last rect: widen it.
+ var rect = rects[rects.Count - 1];
- rects.Add(rect);
+ rects[rects.Count - 1] = rect.WithWidth(rect.Width + advance);
}
currentX += advance;
@@ -1571,32 +1571,36 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.NotNull(textLine);
- var textBounds = textLine.GetTextBounds(0, 4);
+ // Runs come in visual order: the Latin word sits leftmost, then the space, then the
+ // Hebrew word. The space belongs to the primary font, so it is a run of its own.
+ var latinRun = Assert.IsType(textLine.TextRuns[0]);
+ var spaceRun = Assert.IsType(textLine.TextRuns[1]);
+ var hebrewRun = Assert.IsType(textLine.TextRuns[2]);
- var secondRun = Assert.IsType(textLine.TextRuns[1]);
+ var hebrewAndSpaceWidth = hebrewRun.Size.Width + spaceRun.Size.Width;
+
+ var textBounds = textLine.GetTextBounds(0, 4);
Assert.Equal(1, textBounds.Count);
- Assert.Equal(secondRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width));
+ Assert.Equal(hebrewAndSpaceWidth, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(4, 3);
- var firstRun = Assert.IsType(textLine.TextRuns[0]);
-
Assert.Equal(1, textBounds.Count);
Assert.Equal(3, textBounds[0].TextRunBounds.Sum(x => x.Length));
- Assert.Equal(firstRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width));
+ Assert.Equal(latinRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(0, 5);
Assert.Equal(2, textBounds.Count);
Assert.Equal(5, textBounds.Sum(x => x.TextRunBounds.Sum(x => x.Length)));
- Assert.Equal(secondRun.Size.Width, textBounds[1].Rectangle.Width);
+ Assert.Equal(hebrewAndSpaceWidth, textBounds[1].Rectangle.Width);
Assert.Equal(7.201171875, textBounds[0].Rectangle.Width);
Assert.Equal(textLine.Start + 7.201171875, textBounds[0].Rectangle.Right, 2);
- Assert.Equal(textLine.Start + firstRun.Size.Width, textBounds[1].Rectangle.Left, 2);
+ Assert.Equal(textLine.Start + latinRun.Size.Width, textBounds[1].Rectangle.Left, 2);
textBounds = textLine.GetTextBounds(0, text.Length);
@@ -1737,13 +1741,16 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(1, bounds.Count);
- Assert.Equal(71.165859375, bounds[0].Rectangle.Right);
+ // The space between the Hebrew word and the digits is drawn with the primary font
+ // rather than the Hebrew fallback, which is 4.08 wider at this size, so everything
+ // laid out after it sits that much further right.
+ Assert.Equal(75.247031249999992, bounds[0].Rectangle.Right);
bounds = textLine.GetTextBounds(11, 1);
Assert.Equal(1, bounds.Count);
- Assert.Equal(71.165859375, bounds[0].Rectangle.Left);
+ Assert.Equal(75.247031249999992, bounds[0].Rectangle.Left);
bounds = textLine.GetTextBounds(0, 25);
diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs
index 298785df32..eef60186d3 100644
--- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs
+++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs
@@ -101,6 +101,7 @@ namespace Avalonia.UnitTests
popupImpl.Setup(x => x.Compositor).Returns(compositor);
popupImpl.Setup(x => x.ClientSize).Returns(() => clientSize);
popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize);
+ popupImpl.Setup(x => x.DesktopScaling).Returns(1);
popupImpl.Setup(x => x.RenderScaling).Returns(1);
popupImpl.Setup(x => x.PopupPositioner).Returns(positioner);
popupImpl.Setup(x => x.Position).Returns(()=>position);