Browse Source

Merge branch 'main' into fix_issue_21971

pull/21975/head
Steven Kirk 3 weeks ago
committed by GitHub
parent
commit
3d81ceb9fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      api/Avalonia.Headless.nupkg.xml
  2. 63
      src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
  3. 7
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  4. 40
      src/Avalonia.Controls/Primitives/Popup.cs
  5. 3
      src/Avalonia.Controls/Primitives/PopupRoot.cs
  6. 14
      src/Avalonia.Controls/TopLevel.cs
  7. 2
      src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
  8. 12
      src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
  9. 21
      src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
  10. 1
      src/Headless/Avalonia.Headless/IHeadlessWindow.cs
  11. 114
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
  12. 16
      tests/Avalonia.Headless.UnitTests/AssertHelper.cs
  13. 6
      tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs
  14. 115
      tests/Avalonia.Headless.UnitTests/PopupTests.cs
  15. 216
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs
  16. 23
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  17. 26
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  18. 41
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  19. 1
      tests/Avalonia.UnitTests/MockWindowingPlatform.cs

16
api/Avalonia.Headless.nupkg.xml

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Headless.HeadlessWindowExtensions.GetOpenPopups(Avalonia.Controls.TopLevel)</Target>
<Left>baseline/Avalonia.Headless/lib/net10.0/Avalonia.Headless.dll</Left>
<Right>current/Avalonia.Headless/lib/net10.0/Avalonia.Headless.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Headless.HeadlessWindowExtensions.GetOpenPopups(Avalonia.Controls.TopLevel)</Target>
<Left>baseline/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll</Left>
<Right>current/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll</Right>
</Suppression>
</Suppressions>

63
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;
}
}
}
/// <summary>
/// Returns whether every codepoint in <paramref name="text"/> is whitespace. Returns on the
/// first codepoint that isn't, so a run of text costs a single lookup.
/// </summary>
private static bool IsWhiteSpaceOnly(ReadOnlySpan<char> text)
{
var codepoints = new CodepointEnumerator(text);
while (codepoints.MoveNext(out var codepoint))
{
if (!codepoint.IsWhiteSpace)
{
return false;
}
}
return true;
}
/// <summary>
/// Creates a shapeable text run with unique properties.
/// </summary>
@ -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
/// </summary>
/// <param name="text">The characters to shape.</param>
/// <param name="glyphTypeface">The typeface that is used to find matching characters.</param>
/// <param name="defaultGlyphTypeface">The default typeface.</param>
/// <param name="defaultGlyphTypeface">The default typeface, or <c>null</c> when there is none to
/// return to (the probe for the default typeface itself).</param>
/// <param name="defaultCanShapeScript">
/// Whether the default typeface can shape this run's script. When <c>false</c> it only reclaims
/// spacing whitespace, which needs no shaping.
/// </param>
/// <param name="requireFullCluster">
/// When <c>true</c>, a grapheme cluster only counts as supported when the typeface has a glyph
/// for every scalar it contains (base plus combining marks); when <c>false</c>, only the base
@ -261,6 +295,7 @@ namespace Avalonia.Media.TextFormatting
ReadOnlySpan<char> 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;

7
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++)
{

40
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<IPopupHost?>? _popupHostChangedHandler;
private List<Popup>? _openedPopups;
/// <summary>
/// Initializes static members of the <see cref="Popup"/> class.
@ -177,6 +179,11 @@ namespace Avalonia.Controls.Primitives
internal IPopupHost? Host => _openState?.PopupHost;
/// <summary>
/// Gets the popups that are currently open directly inside this popup, in the order they were opened.
/// </summary>
public IReadOnlyList<Popup> OpenedPopups => _openedPopups ?? (IReadOnlyList<Popup>)[];
/// <summary>
/// Gets or sets a hint to the window manager that a shadow should be added to the popup.
/// </summary>
@ -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<Popup>(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;

3
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<Popup> OpenedPopups => (Parent as Popup)?.OpenedPopups ?? [];
/// <inheritdoc/>
public void Dispose()
{

14
src/Avalonia.Controls/TopLevel.cs

@ -132,6 +132,7 @@ namespace Avalonia.Controls
private TargetWeakEventSubscriber<TopLevel, ResourcesChangedEventArgs>? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
private Screens? _screens;
private List<Popup>? _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<IPlatformSettings>();
/// <summary>
/// Gets the popups that are currently open directly in this top level, in the order they were opened.
/// </summary>
/// <remarks>
/// Use <see cref="Popup.OpenedPopups"/> for nested popups.
/// </remarks>
public virtual IReadOnlyList<Popup> OpenedPopups => _openedPopups ?? (IReadOnlyList<Popup>)[];
/// <summary>
/// Gets the <see cref="TopLevel" /> for which the given <see cref="Visual"/> is hosted in.
/// </summary>
@ -708,6 +717,7 @@ namespace Avalonia.Controls
LayoutManager.Dispose();
_platformImplBindings.Clear();
_openedPopups = null;
}
/// <summary>
@ -724,6 +734,10 @@ namespace Avalonia.Controls
Renderer.Resized(clientSize);
}
internal void AddOpenedPopup(Popup popup) => (_openedPopups ??= new List<Popup>(capacity: 2)).Add(popup);
internal void RemoveOpenedPopup(Popup popup) => _openedPopups?.Remove(popup);
/// <summary>
/// Handles a window scaling change notification from
/// <see cref="ITopLevelImpl.ScalingChanged"/>.

2
src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs

@ -121,8 +121,6 @@ namespace Avalonia.Headless
/// <summary>
/// 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 <see cref="HeadlessWindowExtensions.GetOpenPopups"/> to access them.
/// </summary>
// TODO13: Change the default to false to match the other desktop platforms.
public bool OverlayPopups { get; set; } = true;

12
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));
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Only popups hosted in dedicated headless top-levels are returned, which requires disabling
/// <see cref="AvaloniaHeadlessPlatformOptions.OverlayPopups"/>. Popups hosted in the overlay
/// layer are part of the parent's visual tree and are not tracked by the platform.
/// </remarks>
public static IReadOnlyList<TopLevel> GetOpenPopups(this TopLevel topLevel) =>
GetImpl(topLevel).GetOpenPopups();
/// <summary>
/// Changes the render scaling (DPI) of the headless window/toplevel.
/// This simulates a DPI change, triggering scaling changed notifications and a layout pass.

21
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<HeadlessWindowImpl> _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<TopLevel> GetOpenPopups()
{
if (_openPopups.Count == 0)
return Array.Empty<TopLevel>();
var result = new List<TopLevel>(_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)
{

1
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<TopLevel> GetOpenPopups();
}
}

114
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 };

16
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>(T expected, T actual)
{

6
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);

115
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;
}
}

216
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<TextRun>? 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<TextRun>? 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 =

23
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;

26
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)
{

41
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<ShapedTextRun>(textLine.TextRuns[0]);
var spaceRun = Assert.IsType<ShapedTextRun>(textLine.TextRuns[1]);
var hebrewRun = Assert.IsType<ShapedTextRun>(textLine.TextRuns[2]);
var secondRun = Assert.IsType<ShapedTextRun>(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<ShapedTextRun>(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);

1
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);

Loading…
Cancel
Save