Browse Source

Introduce TextOptions (#20107)

* Introduce TextOptions API

* Store BaselinePixelAlignment as byte. Move to a dedicated file.

* Update API suppressions

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/20529/head
Benedikt Stebner 2 weeks ago
committed by GitHub
parent
commit
1bce702bd4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1302
      api/Avalonia.nupkg.xml
  2. 2
      samples/TextTestApp/InteractiveLineControl.cs
  3. 26
      src/Avalonia.Base/Media/BaselinePixelAlignment.cs
  4. 20
      src/Avalonia.Base/Media/DrawingContext.cs
  5. 13
      src/Avalonia.Base/Media/DrawingGroup.cs
  6. 4
      src/Avalonia.Base/Media/PlatformDrawingContext.cs
  7. 45
      src/Avalonia.Base/Media/RenderOptions.cs
  8. 31
      src/Avalonia.Base/Media/TextHintingMode.cs
  9. 136
      src/Avalonia.Base/Media/TextOptions.cs
  10. 27
      src/Avalonia.Base/Media/TextRenderingMode.cs
  11. 11
      src/Avalonia.Base/Platform/IDrawingContextImpl.cs
  12. 15
      src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs
  13. 6
      src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs
  14. 4
      src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.PendingCommands.cs
  15. 21
      src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs
  16. 10
      src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs
  17. 1
      src/Avalonia.Base/Rendering/ImmediateRenderer.cs
  18. 3
      src/Avalonia.Base/Visual.Composition.cs
  19. 64
      src/Avalonia.Base/Visual.cs
  20. 1
      src/Avalonia.Base/composition-schema.xml
  21. 14
      src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs
  22. 45
      src/Skia/Avalonia.Skia/DrawingContextImpl.cs
  23. 92
      src/Skia/Avalonia.Skia/GlyphRunImpl.cs
  24. 157
      src/Skia/Avalonia.Skia/TwoLevelCache.cs
  25. 8
      tests/Avalonia.Controls.UnitTests/Shapes/ShapeTests.cs
  26. 38
      tests/Avalonia.RenderTests/Controls/TextBlockTests.cs
  27. 2
      tests/Avalonia.RenderTests/Media/GlyphRunTests.cs
  28. 2
      tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs
  29. 351
      tests/Avalonia.Skia.UnitTests/TwoLevelCacheTests.cs
  30. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Alias_Light.expected.png
  31. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Alias_None.expected.png
  32. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Antialias_Light.expected.png
  33. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Antialias_None.expected.png

1302
api/Avalonia.nupkg.xml

File diff suppressed because it is too large

2
samples/TextTestApp/InteractiveLineControl.cs

@ -336,7 +336,7 @@ namespace TextTestApp
_textSource = new TextSource(this); _textSource = new TextSource(this);
RenderOptions.SetEdgeMode(this, EdgeMode.Aliased); RenderOptions.SetEdgeMode(this, EdgeMode.Aliased);
RenderOptions.SetTextRenderingMode(this, TextRenderingMode.SubpixelAntialias); TextOptions.SetTextRenderingMode(this, TextRenderingMode.SubpixelAntialias);
} }
private void InvalidateTextRunProperties() private void InvalidateTextRunProperties()

26
src/Avalonia.Base/Media/BaselinePixelAlignment.cs

@ -0,0 +1,26 @@
namespace Avalonia.Media
{
/// <summary>
/// Specifies the baseline pixel alignment options for rendering text or graphics.
/// </summary>
/// <remarks>Use this enumeration to control whether the baseline of rendered content is aligned to the
/// pixel grid, which can affect visual crispness and positioning. The value may influence rendering quality,
/// especially at small font sizes or when precise alignment is required.</remarks>
public enum BaselinePixelAlignment : byte
{
/// <summary>
/// The baseline pixel alignment is unspecified.
/// </summary>
Unspecified,
/// <summary>
/// The baseline is aligned to the pixel grid.
/// </summary>
Aligned,
/// <summary>
/// The baseline is not aligned to the pixel grid.
/// </summary>
Unaligned
}
}

20
src/Avalonia.Base/Media/DrawingContext.cs

@ -284,7 +284,8 @@ namespace Avalonia.Media
Clip, Clip,
GeometryClip, GeometryClip,
OpacityMask, OpacityMask,
RenderOptions RenderOptions,
TextOptions
} }
public RestoreState(DrawingContext context, PushedStateType type) public RestoreState(DrawingContext context, PushedStateType type)
@ -311,6 +312,8 @@ namespace Avalonia.Media
_context.PopOpacityMaskCore(); _context.PopOpacityMaskCore();
else if (_type == PushedStateType.RenderOptions) else if (_type == PushedStateType.RenderOptions)
_context.PopRenderOptionsCore(); _context.PopRenderOptionsCore();
else if (_type == PushedStateType.TextOptions)
_context.PopTextOptionsCore();
} }
} }
@ -417,6 +420,20 @@ namespace Avalonia.Media
} }
protected abstract void PushRenderOptionsCore(RenderOptions renderOptions); protected abstract void PushRenderOptionsCore(RenderOptions renderOptions);
/// <summary>
/// Pushes text options for the drawing context.
/// </summary>
/// <param name="textOptions">The text options.</param>
/// <returns>A disposable to undo the text options.</returns>
public PushedState PushTextOptions(TextOptions textOptions)
{
PushTextOptionsCore(textOptions);
_states ??= StateStackPool.Get();
_states.Push(new RestoreState(this, RestoreState.PushedStateType.TextOptions));
return new PushedState(this);
}
protected abstract void PushTextOptionsCore(TextOptions textOptions);
[Obsolete("Use PushTransform"), EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("Use PushTransform"), EditorBrowsable(EditorBrowsableState.Never)]
public PushedState PushPreTransform(Matrix matrix) => PushTransform(matrix); public PushedState PushPreTransform(Matrix matrix) => PushTransform(matrix);
[Obsolete("Use PushTransform"), EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("Use PushTransform"), EditorBrowsable(EditorBrowsableState.Never)]
@ -433,6 +450,7 @@ namespace Avalonia.Media
protected abstract void PopOpacityMaskCore(); protected abstract void PopOpacityMaskCore();
protected abstract void PopTransformCore(); protected abstract void PopTransformCore();
protected abstract void PopRenderOptionsCore(); protected abstract void PopRenderOptionsCore();
protected abstract void PopTextOptionsCore();
private static bool PenIsVisible(IPen? pen) private static bool PenIsVisible(IPen? pen)
{ {

13
src/Avalonia.Base/Media/DrawingGroup.cs

@ -54,6 +54,7 @@ namespace Avalonia.Media
} }
internal RenderOptions? RenderOptions { get; set; } internal RenderOptions? RenderOptions { get; set; }
internal TextOptions? TextOptions { get; set; }
/// <summary> /// <summary>
/// Gets or sets the collection that contains the child geometries. /// Gets or sets the collection that contains the child geometries.
@ -78,6 +79,7 @@ namespace Avalonia.Media
using (ClipGeometry != null ? context.PushGeometryClip(ClipGeometry) : default) using (ClipGeometry != null ? context.PushGeometryClip(ClipGeometry) : default)
using (OpacityMask != null ? context.PushOpacityMask(OpacityMask, bounds) : default) using (OpacityMask != null ? context.PushOpacityMask(OpacityMask, bounds) : default)
using (RenderOptions != null ? context.PushRenderOptions(RenderOptions.Value) : default) using (RenderOptions != null ? context.PushRenderOptions(RenderOptions.Value) : default)
using (TextOptions != null ? context.PushTextOptions(TextOptions.Value) : default)
{ {
foreach (var drawing in Children) foreach (var drawing in Children)
{ {
@ -325,6 +327,15 @@ namespace Avalonia.Media
drawingGroup.RenderOptions = renderOptions; drawingGroup.RenderOptions = renderOptions;
} }
protected override void PushTextOptionsCore(TextOptions textOptions)
{
// Instantiate a new drawing group and set it as the _currentDrawingGroup
var drawingGroup = PushNewDrawingGroup();
// Set the text options on the new DrawingGroup
drawingGroup.TextOptions = textOptions;
}
protected override void PopClipCore() => Pop(); protected override void PopClipCore() => Pop();
protected override void PopGeometryClipCore() => Pop(); protected override void PopGeometryClipCore() => Pop();
@ -337,6 +348,8 @@ namespace Avalonia.Media
protected override void PopRenderOptionsCore() => Pop(); protected override void PopRenderOptionsCore() => Pop();
protected override void PopTextOptionsCore() => Pop();
/// <summary> /// <summary>
/// Creates a new DrawingGroup for a Push* call by setting the /// Creates a new DrawingGroup for a Push* call by setting the
/// _currentDrawingGroup to a newly instantiated DrawingGroup, /// _currentDrawingGroup to a newly instantiated DrawingGroup,

4
src/Avalonia.Base/Media/PlatformDrawingContext.cs

@ -85,6 +85,8 @@ internal sealed class PlatformDrawingContext : DrawingContext
} }
protected override void PushRenderOptionsCore(RenderOptions renderOptions) => _impl.PushRenderOptions(renderOptions); protected override void PushRenderOptionsCore(RenderOptions renderOptions) => _impl.PushRenderOptions(renderOptions);
protected override void PushTextOptionsCore(TextOptions textOptions) => _impl.PushTextOptions(textOptions);
protected override void PopClipCore() => _impl.PopClip(); protected override void PopClipCore() => _impl.PopClip();
@ -99,6 +101,8 @@ internal sealed class PlatformDrawingContext : DrawingContext
(_transforms ?? throw new ObjectDisposedException(nameof(PlatformDrawingContext))).Pop(); (_transforms ?? throw new ObjectDisposedException(nameof(PlatformDrawingContext))).Pop();
protected override void PopRenderOptionsCore() => _impl.PopRenderOptions(); protected override void PopRenderOptionsCore() => _impl.PopRenderOptions();
protected override void PopTextOptionsCore() => _impl.PopTextOptions();
protected override void DisposeCore() protected override void DisposeCore()
{ {

45
src/Avalonia.Base/Media/RenderOptions.cs

@ -1,13 +1,48 @@
using Avalonia.Media.Imaging; using System;
using Avalonia.Media.Imaging;
namespace Avalonia.Media namespace Avalonia.Media
{ {
/// <summary>
/// Provides a set of options that control rendering behavior for visuals, including text rendering, bitmap
/// interpolation, edge rendering, blending, and opacity handling.
/// </summary>
/// <remarks>Use this structure to specify rendering preferences for visual elements. Each property
/// corresponds to a specific aspect of rendering, allowing fine-grained control over how content is displayed.
/// These options can be applied to visuals to influence quality, performance, and visual effects. When merging two
/// instances, unspecified values are inherited from the other instance, enabling layered configuration.</remarks>
public readonly record struct RenderOptions public readonly record struct RenderOptions
{ {
/// <summary>
/// Gets the text rendering mode used to control how text glyphs are rendered.
/// </summary>
[Obsolete("TextRenderingMode is obsolete. Use TextOptions.TextRenderingMode instead.")]
public TextRenderingMode TextRenderingMode { get; init; }
/// <summary>
/// Gets the interpolation mode used when rendering bitmap images.
/// </summary>
/// <remarks>The interpolation mode determines how bitmap images are scaled or transformed during
/// rendering. Selecting an appropriate mode can affect image quality and performance.
/// </remarks>
public BitmapInterpolationMode BitmapInterpolationMode { get; init; } public BitmapInterpolationMode BitmapInterpolationMode { get; init; }
/// <summary>
/// Gets the edge rendering mode used for drawing operations.
/// </summary>
public EdgeMode EdgeMode { get; init; } public EdgeMode EdgeMode { get; init; }
public TextRenderingMode TextRenderingMode { get; init; }
/// <summary>
/// Gets the blending mode used when rendering bitmap images.
/// </summary>
/// <remarks>The blending mode determines how bitmap pixels are composited with the background or
/// other images. Select an appropriate mode based on the desired visual effect, such as transparency or
/// additive blending.</remarks>
public BitmapBlendingMode BitmapBlendingMode { get; init; } public BitmapBlendingMode BitmapBlendingMode { get; init; }
/// <summary>
/// Gets a value indicating whether full opacity handling is required for the associated content.
/// </summary>
public bool? RequiresFullOpacityHandling { get; init; } public bool? RequiresFullOpacityHandling { get; init; }
/// <summary> /// <summary>
@ -75,6 +110,7 @@ namespace Avalonia.Media
/// </summary> /// </summary>
/// <param name="visual">The control.</param> /// <param name="visual">The control.</param>
/// <returns>The value.</returns> /// <returns>The value.</returns>
[Obsolete("TextRenderingMode is obsolete. Use TextOptions.TextRenderingMode instead.")]
public static TextRenderingMode GetTextRenderingMode(Visual visual) public static TextRenderingMode GetTextRenderingMode(Visual visual)
{ {
return visual.RenderOptions.TextRenderingMode; return visual.RenderOptions.TextRenderingMode;
@ -85,6 +121,7 @@ namespace Avalonia.Media
/// </summary> /// </summary>
/// <param name="visual">The control.</param> /// <param name="visual">The control.</param>
/// <param name="value">The value.</param> /// <param name="value">The value.</param>
[Obsolete("TextRenderingMode is obsolete. Use TextOptions.TextRenderingMode instead.")]
public static void SetTextRenderingMode(Visual visual, TextRenderingMode value) public static void SetTextRenderingMode(Visual visual, TextRenderingMode value)
{ {
visual.RenderOptions = visual.RenderOptions with { TextRenderingMode = value }; visual.RenderOptions = visual.RenderOptions with { TextRenderingMode = value };
@ -126,11 +163,15 @@ namespace Avalonia.Media
edgeMode = other.EdgeMode; edgeMode = other.EdgeMode;
} }
#pragma warning disable CS0618
var textRenderingMode = TextRenderingMode; var textRenderingMode = TextRenderingMode;
#pragma warning restore CS0618
if (textRenderingMode == TextRenderingMode.Unspecified) if (textRenderingMode == TextRenderingMode.Unspecified)
{ {
#pragma warning disable CS0618
textRenderingMode = other.TextRenderingMode; textRenderingMode = other.TextRenderingMode;
#pragma warning disable CS0618
} }
var bitmapBlendingMode = BitmapBlendingMode; var bitmapBlendingMode = BitmapBlendingMode;

31
src/Avalonia.Base/Media/TextHintingMode.cs

@ -0,0 +1,31 @@
namespace Avalonia.Media
{
/// <summary>
/// Specifies the level of hinting applied to text glyphs during rendering.
/// Text hinting adjusts glyph outlines to improve readability and crispness,
/// especially at small font sizes or low DPI. This enum controls the amount
/// of grid-fitting and outline adjustment performed.
/// </summary>
public enum TextHintingMode : byte
{
/// <summary>
/// Hinting mode is not explicitly specified. The default will be used.
/// </summary>
Unspecified,
/// <summary>
/// No hinting, outlines are scaled only.
/// </summary>
None,
/// <summary>
/// Minimal hinting, preserves glyph shape.
/// </summary>
Light,
/// <summary>
/// Aggressive grid-fitting, maximum crispness at low DPI.
/// </summary>
Strong
}
}

136
src/Avalonia.Base/Media/TextOptions.cs

@ -0,0 +1,136 @@
namespace Avalonia.Media
{
/// <summary>
/// Provides options for controlling text rendering behavior, including rendering mode, hinting mode, and baseline
/// pixel alignment. Used to configure how text appears within visual elements.
/// </summary>
/// <remarks>TextOptions encapsulates settings that influence the clarity, sharpness, and positioning of
/// rendered text. These options can be applied to visual elements to customize text appearance for different
/// display scenarios, such as optimizing for readability at small font sizes or ensuring pixel-perfect alignment.
/// The struct supports merging with other instances to inherit unspecified values, and exposes attached properties
/// for use with visuals.</remarks>
public readonly record struct TextOptions
{
/// <summary>
/// Gets the text rendering mode used to control how text glyphs are rendered.
/// </summary>
public TextRenderingMode TextRenderingMode { get; init; }
/// <summary>
/// Gets the text rendering hinting mode used to optimize the display of text.
/// </summary>
/// <remarks>The hinting mode determines how text is rendered to improve clarity and readability,
/// especially at small font sizes. Changing this value may affect the appearance of text depending on the
/// rendering engine and display device.</remarks>
public TextHintingMode TextHintingMode { get; init; }
/// <summary>
/// Gets a value indicating whether the text baseline should be aligned to the pixel grid.
/// </summary>
/// <remarks>
/// When enabled, the vertical position of the text baseline is snapped to whole pixel boundaries.
/// This ensures consistent sharpness and reduces blurriness caused by fractional positioning,
/// particularly at small font sizes or low DPI settings.
/// </remarks>
public BaselinePixelAlignment BaselinePixelAlignment { get; init; }
/// <summary>
/// Merges this instance with <paramref name="other"/> using inheritance semantics: unspecified values on this
/// instance are taken from <paramref name="other"/>.
/// </summary>
public TextOptions MergeWith(TextOptions other)
{
var textRenderingMode = TextRenderingMode;
if (textRenderingMode == TextRenderingMode.Unspecified)
{
textRenderingMode = other.TextRenderingMode;
}
var textHintingMode = TextHintingMode;
if (textHintingMode == TextHintingMode.Unspecified)
{
textHintingMode = other.TextHintingMode;
}
var baselinePixelAlignment = BaselinePixelAlignment;
if (baselinePixelAlignment == BaselinePixelAlignment.Unspecified)
{
baselinePixelAlignment = other.BaselinePixelAlignment;
}
return new TextOptions
{
TextRenderingMode = textRenderingMode,
TextHintingMode = textHintingMode,
BaselinePixelAlignment = baselinePixelAlignment
};
}
/// <summary>
/// Gets the TextOptions attached value for a visual.
/// </summary>
public static TextOptions GetTextOptions(Visual visual)
{
return visual.TextOptions;
}
/// <summary>
/// Sets the TextOptions attached value for a visual.
/// </summary>
public static void SetTextOptions(Visual visual, TextOptions value)
{
visual.TextOptions = value;
}
/// <summary>
/// Gets the TextRenderingMode attached property for a visual.
/// </summary>
public static TextRenderingMode GetTextRenderingMode(Visual visual)
{
return visual.TextOptions.TextRenderingMode;
}
/// <summary>
/// Sets the TextRenderingMode attached property for a visual.
/// </summary>
public static void SetTextRenderingMode(Visual visual, TextRenderingMode value)
{
visual.TextOptions = visual.TextOptions with { TextRenderingMode = value };
}
/// <summary>
/// Gets the TextHintingMode attached property for a visual.
/// </summary>
public static TextHintingMode GetTextHintingMode(Visual visual)
{
return visual.TextOptions.TextHintingMode;
}
/// <summary>
/// Sets the TextHintingMode attached property for a visual.
/// </summary>
public static void SetTextHintingMode(Visual visual, TextHintingMode value)
{
visual.TextOptions = visual.TextOptions with { TextHintingMode = value };
}
/// <summary>
/// Gets the BaselinePixelAlignment attached property for a visual.
/// </summary>
public static BaselinePixelAlignment GetBaselinePixelAlignment(Visual visual)
{
return visual.TextOptions.BaselinePixelAlignment;
}
/// <summary>
/// Sets the BaselinePixelAlignment attached property for a visual.
/// </summary>
public static void SetBaselinePixelAlignment(Visual visual, BaselinePixelAlignment value)
{
visual.TextOptions = visual.TextOptions with { BaselinePixelAlignment = value };
}
}
}

27
src/Avalonia.Base/Media/TextRenderingMode.cs

@ -1,11 +1,36 @@
namespace Avalonia.Media namespace Avalonia.Media
{ {
/// <summary>
/// Specifies how text glyphs are rendered in Avalonia.
/// Controls the smoothing and antialiasing applied during text rasterization.
/// </summary>
public enum TextRenderingMode : byte public enum TextRenderingMode : byte
{ {
/// <summary>
/// Rendering mode is not explicitly specified.
/// The system or platform default will be used.
/// </summary>
Unspecified, Unspecified,
SubpixelAntialias, /// <summary>
/// Glyphs are rendered with subpixel antialiasing.
/// This provides higher apparent resolution on LCD screens
/// by using the individual red, green, and blue subpixels.
/// </summary>
SubpixelAntialias,
/// <summary>
/// Glyphs are rendered with standard grayscale antialiasing.
/// This smooths edges without using subpixel information,
/// preserving shape fidelity across different display types.
/// </summary>
Antialias, Antialias,
/// <summary>
/// Glyphs are rendered without antialiasing.
/// This produces sharp, aliased edges and may be useful
/// for pixel-art aesthetics or low-DPI environments.
/// </summary>
Alias Alias
} }
} }

11
src/Avalonia.Base/Platform/IDrawingContextImpl.cs

@ -195,6 +195,17 @@ namespace Avalonia.Platform
/// </summary> /// </summary>
void PopRenderOptions(); void PopRenderOptions();
/// <summary>
/// Pushes text options for the drawing context.
/// </summary>
/// <param name="textOptions">The text options.</param>
void PushTextOptions(TextOptions textOptions);
/// <summary>
/// Pops the latest text options.
/// </summary>
void PopTextOptions();
/// <summary> /// <summary>
/// Attempts to get an optional feature from the drawing context implementation. /// Attempts to get an optional feature from the drawing context implementation.
/// </summary> /// </summary>

15
src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs

@ -232,3 +232,18 @@ class RenderDataRenderOptionsNode : RenderDataPushNode
context.Context.PopRenderOptions(); context.Context.PopRenderOptions();
} }
} }
class RenderDataTextOptionsNode : RenderDataPushNode
{
public TextOptions TextOptions { get; set; }
public override void Push(ref RenderDataNodeRenderContext context)
{
context.Context.PushTextOptions(TextOptions);
}
public override void Pop(ref RenderDataNodeRenderContext context)
{
context.Context.PopTextOptions();
}
}

6
src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs

@ -264,6 +264,10 @@ internal class RenderDataDrawingContext : DrawingContext
RenderOptions = renderOptions RenderOptions = renderOptions
}); });
protected override void PushTextOptionsCore(TextOptions textOptions) => Push(new RenderDataTextOptionsNode()
{
TextOptions = textOptions
});
protected override void PopClipCore() => Pop<RenderDataClipNode>(); protected override void PopClipCore() => Pop<RenderDataClipNode>();
@ -277,6 +281,8 @@ internal class RenderDataDrawingContext : DrawingContext
protected override void PopRenderOptionsCore() => Pop<RenderDataRenderOptionsNode>(); protected override void PopRenderOptionsCore() => Pop<RenderDataRenderOptionsNode>();
protected override void PopTextOptionsCore() => Pop<RenderDataTextOptionsNode>();
internal override void DrawBitmap(IRef<IBitmapImpl>? source, double opacity, Rect sourceRect, Rect destRect) internal override void DrawBitmap(IRef<IBitmapImpl>? source, double opacity, Rect sourceRect, Rect destRect)
{ {
if (source == null || sourceRect.IsEmpty() || destRect.IsEmpty()) if (source == null || sourceRect.IsEmpty() || destRect.IsEmpty())

4
src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.PendingCommands.cs

@ -22,6 +22,7 @@ internal partial class CompositorDrawingContextProxy
PushOpacityMask, PushOpacityMask,
PushGeometryClip, PushGeometryClip,
PushRenderOptions, PushRenderOptions,
PushTextOptions,
PushEffect PushEffect
} }
@ -43,6 +44,7 @@ internal partial class CompositorDrawingContextProxy
[FieldOffset(0)] public Matrix Transform; [FieldOffset(0)] public Matrix Transform;
[FieldOffset(0)] public RenderOptions RenderOptions; [FieldOffset(0)] public RenderOptions RenderOptions;
[FieldOffset(0)] public TextOptions TextOptions;
// PushClip/PushOpacityMask // PushClip/PushOpacityMask
[FieldOffset(0)] public bool IsRoundRect; [FieldOffset(0)] public bool IsRoundRect;
@ -148,6 +150,8 @@ internal partial class CompositorDrawingContextProxy
} }
else if (cmd.Type == PendingCommandType.PushRenderOptions) else if (cmd.Type == PendingCommandType.PushRenderOptions)
_impl.PushRenderOptions(cmd.DataUnion.RenderOptions); _impl.PushRenderOptions(cmd.DataUnion.RenderOptions);
else if (cmd.Type == PendingCommandType.PushTextOptions)
_impl.PushTextOptions(cmd.DataUnion.TextOptions);
else else
Debug.Assert(false); Debug.Assert(false);
} }

21
src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs

@ -260,6 +260,18 @@ internal partial class CompositorDrawingContextProxy : IDrawingContextImpl,
}); });
} }
public void PushTextOptions(TextOptions textOptions)
{
AddCommand(new()
{
Type = PendingCommandType.PushTextOptions,
DataUnion =
{
TextOptions = textOptions
}
});
}
public void PopRenderOptions() public void PopRenderOptions()
{ {
if (!TryDiscardOrFlush(PendingCommandType.PushRenderOptions)) if (!TryDiscardOrFlush(PendingCommandType.PushRenderOptions))
@ -269,6 +281,15 @@ internal partial class CompositorDrawingContextProxy : IDrawingContextImpl,
} }
} }
public void PopTextOptions()
{
if (!TryDiscardOrFlush(PendingCommandType.PushTextOptions))
{
_impl.PopTextOptions();
RestoreTransform();
}
}
public object? GetFeature(Type t) public object? GetFeature(Type t)
{ {
Flush(); Flush();

10
src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs

@ -62,6 +62,12 @@ namespace Avalonia.Rendering.Composition.Server
if (applyRenderOptions) if (applyRenderOptions)
canvas.PushRenderOptions(RenderOptions); canvas.PushRenderOptions(RenderOptions);
var applyTextOptions = TextOptions != default;
if (applyTextOptions)
canvas.PushTextOptions(TextOptions);
var needPopEffect = PushEffect(canvas); var needPopEffect = PushEffect(canvas);
if (Opacity != 1) if (Opacity != 1)
@ -88,7 +94,9 @@ namespace Avalonia.Rendering.Composition.Server
if (needPopEffect) if (needPopEffect)
canvas.PopEffect(); canvas.PopEffect();
if(applyRenderOptions) if (applyTextOptions)
canvas.PopTextOptions();
if (applyRenderOptions)
canvas.PopRenderOptions(); canvas.PopRenderOptions();
} }

1
src/Avalonia.Base/Rendering/ImmediateRenderer.cs

@ -50,6 +50,7 @@ internal class ImmediateRenderer
transform = Matrix.CreateTranslation(bounds.Position); transform = Matrix.CreateTranslation(bounds.Position);
} }
using (visual.TextOptions != default ? context.PushTextOptions(visual.TextOptions) : default(DrawingContext.PushedState?))
using (visual.RenderOptions != default ? context.PushRenderOptions(visual.RenderOptions) : default(DrawingContext.PushedState?)) using (visual.RenderOptions != default ? context.PushRenderOptions(visual.RenderOptions) : default(DrawingContext.PushedState?))
using (context.PushTransform(transform)) using (context.PushTransform(transform))
using (visual.HasMirrorTransform ? context.PushTransform(new Matrix(-1.0, 0.0, 0.0, 1.0, visual.Bounds.Width, 0)) : default(DrawingContext.PushedState?)) using (visual.HasMirrorTransform ? context.PushTransform(new Matrix(-1.0, 0.0, 0.0, 1.0, visual.Bounds.Width, 0)) : default(DrawingContext.PushedState?))

3
src/Avalonia.Base/Visual.Composition.cs

@ -148,6 +148,7 @@ public partial class Visual
comp.Effect = Effect?.ToImmutable(); comp.Effect = Effect?.ToImmutable();
comp.RenderOptions = RenderOptions; comp.RenderOptions = RenderOptions;
comp.TextOptions = TextOptions;
var renderTransform = Matrix.Identity; var renderTransform = Matrix.Identity;
@ -163,4 +164,4 @@ public partial class Visual
comp.TransformMatrix = renderTransform; comp.TransformMatrix = renderTransform;
} }
} }

64
src/Avalonia.Base/Visual.cs

@ -1,5 +1,3 @@
#nullable enable #nullable enable
using System; using System;
@ -39,7 +37,7 @@ namespace Avalonia
/// </summary> /// </summary>
public static readonly DirectProperty<Visual, Rect> BoundsProperty = public static readonly DirectProperty<Visual, Rect> BoundsProperty =
AvaloniaProperty.RegisterDirect<Visual, Rect>(nameof(Bounds), o => o.Bounds); AvaloniaProperty.RegisterDirect<Visual, Rect>(nameof(Bounds), o => o.Bounds);
/// <summary> /// <summary>
/// Defines the <see cref="ClipToBounds"/> property. /// Defines the <see cref="ClipToBounds"/> property.
/// </summary> /// </summary>
@ -51,7 +49,7 @@ namespace Avalonia
/// </summary> /// </summary>
public static readonly StyledProperty<Geometry?> ClipProperty = public static readonly StyledProperty<Geometry?> ClipProperty =
AvaloniaProperty.Register<Visual, Geometry?>(nameof(Clip)); AvaloniaProperty.Register<Visual, Geometry?>(nameof(Clip));
/// <summary> /// <summary>
/// Defines the <see cref="IsVisible"/> property. /// Defines the <see cref="IsVisible"/> property.
/// </summary> /// </summary>
@ -69,7 +67,7 @@ namespace Avalonia
/// </summary> /// </summary>
public static readonly StyledProperty<IBrush?> OpacityMaskProperty = public static readonly StyledProperty<IBrush?> OpacityMaskProperty =
AvaloniaProperty.Register<Visual, IBrush?>(nameof(OpacityMask)); AvaloniaProperty.Register<Visual, IBrush?>(nameof(OpacityMask));
/// <summary> /// <summary>
/// Defines the <see cref="Effect"/> property. /// Defines the <see cref="Effect"/> property.
/// </summary> /// </summary>
@ -113,7 +111,7 @@ namespace Avalonia
/// </summary> /// </summary>
public static readonly StyledProperty<int> ZIndexProperty = public static readonly StyledProperty<int> ZIndexProperty =
AvaloniaProperty.Register<Visual, int>(nameof(ZIndex)); AvaloniaProperty.Register<Visual, int>(nameof(ZIndex));
private static readonly WeakEvent<IAffectsRender, EventArgs> InvalidatedWeakEvent = private static readonly WeakEvent<IAffectsRender, EventArgs> InvalidatedWeakEvent =
WeakEvent.Register<IAffectsRender>( WeakEvent.Register<IAffectsRender>(
(s, h) => s.Invalidated += h, (s, h) => s.Invalidated += h,
@ -124,6 +122,8 @@ namespace Avalonia
private Visual? _visualParent; private Visual? _visualParent;
private bool _hasMirrorTransform; private bool _hasMirrorTransform;
private TargetWeakEventSubscriber<Visual, EventArgs>? _affectsRenderWeakSubscriber; private TargetWeakEventSubscriber<Visual, EventArgs>? _affectsRenderWeakSubscriber;
private RenderOptions _renderOptions;
private TextOptions _textOptions;
/// <summary> /// <summary>
/// Initializes static members of the <see cref="Visual"/> class. /// Initializes static members of the <see cref="Visual"/> class.
@ -201,7 +201,7 @@ namespace Avalonia
/// Gets a value indicating whether this control and all its parents are visible. /// Gets a value indicating whether this control and all its parents are visible.
/// </summary> /// </summary>
public bool IsEffectivelyVisible { get; private set; } = true; public bool IsEffectivelyVisible { get; private set; } = true;
/// <summary> /// <summary>
/// Updates the <see cref="IsEffectivelyVisible"/> property based on the parent's /// Updates the <see cref="IsEffectivelyVisible"/> property based on the parent's
/// <see cref="IsEffectivelyVisible"/>. /// <see cref="IsEffectivelyVisible"/>.
@ -218,7 +218,7 @@ namespace Avalonia
// PERF-SENSITIVE: This is called on entire hierarchy and using foreach or LINQ // PERF-SENSITIVE: This is called on entire hierarchy and using foreach or LINQ
// will cause extra allocations and overhead. // will cause extra allocations and overhead.
var children = VisualChildren; var children = VisualChildren;
// ReSharper disable once ForCanBeConvertedToForeach // ReSharper disable once ForCanBeConvertedToForeach
@ -255,7 +255,7 @@ namespace Avalonia
get { return GetValue(OpacityMaskProperty); } get { return GetValue(OpacityMaskProperty); }
set { SetValue(OpacityMaskProperty, value); } set { SetValue(OpacityMaskProperty, value); }
} }
/// <summary> /// <summary>
/// Gets or sets the effect of the control. /// Gets or sets the effect of the control.
/// </summary> /// </summary>
@ -269,8 +269,8 @@ namespace Avalonia
/// <summary> /// <summary>
/// Gets or sets a value indicating whether to apply mirror transform on this control. /// Gets or sets a value indicating whether to apply mirror transform on this control.
/// </summary> /// </summary>
public bool HasMirrorTransform public bool HasMirrorTransform
{ {
get { return _hasMirrorTransform; } get { return _hasMirrorTransform; }
protected set { SetAndRaise(HasMirrorTransformProperty, ref _hasMirrorTransform, value); } protected set { SetAndRaise(HasMirrorTransformProperty, ref _hasMirrorTransform, value); }
} }
@ -326,7 +326,25 @@ namespace Avalonia
/// </summary> /// </summary>
protected internal IRenderRoot? VisualRoot => _visualRoot; protected internal IRenderRoot? VisualRoot => _visualRoot;
internal RenderOptions RenderOptions { get; set; } internal RenderOptions RenderOptions
{
get => _renderOptions;
set
{
_renderOptions = value;
InvalidateVisual();
}
}
internal TextOptions TextOptions
{
get => _textOptions;
set
{
_textOptions = value;
InvalidateVisual();
}
}
internal bool HasNonUniformZIndexChildren { get; private set; } internal bool HasNonUniformZIndexChildren { get; private set; }
@ -413,8 +431,8 @@ namespace Avalonia
sender.InvalidateVisual(); sender.InvalidateVisual();
} }
}); });
var invalidateAndSubscribeObserver = new AnonymousObserver<AvaloniaPropertyChangedEventArgs>( var invalidateAndSubscribeObserver = new AnonymousObserver<AvaloniaPropertyChangedEventArgs>(
static e => static e =>
{ {
@ -466,7 +484,7 @@ namespace Avalonia
if (change.Property == IsVisibleProperty) if (change.Property == IsVisibleProperty)
{ {
UpdateIsEffectivelyVisible(VisualParent?.IsEffectivelyVisible ?? true); UpdateIsEffectivelyVisible(VisualParent?.IsEffectivelyVisible ?? true);
} }
else if (change.Property == FlowDirectionProperty) else if (change.Property == FlowDirectionProperty)
{ {
InvalidateMirrorTransform(); InvalidateMirrorTransform();
@ -477,7 +495,7 @@ namespace Avalonia
} }
} }
} }
protected override void LogicalChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) protected override void LogicalChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
base.LogicalChildrenCollectionChanged(sender, e); base.LogicalChildrenCollectionChanged(sender, e);
@ -515,12 +533,12 @@ namespace Avalonia
OnAttachedToVisualTree(e); OnAttachedToVisualTree(e);
AttachedToVisualTree?.Invoke(this, e); AttachedToVisualTree?.Invoke(this, e);
InvalidateVisual(); InvalidateVisual();
_visualRoot.Renderer.RecalculateChildren(_visualParent); _visualRoot.Renderer.RecalculateChildren(_visualParent);
if (ZIndex != 0) if (ZIndex != 0)
_visualParent.HasNonUniformZIndexChildren = true; _visualParent.HasNonUniformZIndexChildren = true;
var visualChildren = VisualChildren; var visualChildren = VisualChildren;
var visualChildrenCount = visualChildren.Count; var visualChildrenCount = visualChildren.Count;
@ -617,7 +635,7 @@ namespace Avalonia
{ {
newTransform.Changed += sender.RenderTransformChanged; newTransform.Changed += sender.RenderTransformChanged;
} }
sender.InvalidateVisual(); sender.InvalidateVisual();
} }
} }
@ -651,7 +669,7 @@ namespace Avalonia
var parent = sender?.VisualParent; var parent = sender?.VisualParent;
if (sender?.ZIndex != 0 && parent is Visual parentVisual) if (sender?.ZIndex != 0 && parent is Visual parentVisual)
parentVisual.HasNonUniformZIndexChildren = true; parentVisual.HasNonUniformZIndexChildren = true;
sender?.InvalidateVisual(); sender?.InvalidateVisual();
parent?.VisualRoot?.Renderer.RecalculateChildren(parent); parent?.VisualRoot?.Renderer.RecalculateChildren(parent);
} }
@ -721,7 +739,7 @@ namespace Avalonia
break; break;
} }
} }
private static void SetVisualParent(IList children, Visual? parent) private static void SetVisualParent(IList children, Visual? parent)
{ {
var count = children.Count; var count = children.Count;
@ -729,7 +747,7 @@ namespace Avalonia
for (var i = 0; i < count; i++) for (var i = 0; i < count; i++)
{ {
var visual = (Visual) children[i]!; var visual = (Visual) children[i]!;
visual.SetVisualParent(parent); visual.SetVisualParent(parent);
} }
} }

1
src/Avalonia.Base/composition-schema.xml

@ -34,6 +34,7 @@
<Property Name="OpacityMaskBrush" ClientName="OpacityMaskBrushTransportField" Type="Avalonia.Media.IBrush?" Private="true" /> <Property Name="OpacityMaskBrush" ClientName="OpacityMaskBrushTransportField" Type="Avalonia.Media.IBrush?" Private="true" />
<Property Name="Effect" Type="Avalonia.Media.IImmutableEffect?" Internal="true" /> <Property Name="Effect" Type="Avalonia.Media.IImmutableEffect?" Internal="true" />
<Property Name="RenderOptions" Type="Avalonia.Media.RenderOptions" /> <Property Name="RenderOptions" Type="Avalonia.Media.RenderOptions" />
<Property Name="TextOptions" Type="Avalonia.Media.TextOptions" />
<Property Name="ShouldExtendDirtyRect" Type="bool" Internal="true" /> <Property Name="ShouldExtendDirtyRect" Type="bool" Internal="true" />
</Object> </Object>
<Object Name="CompositionContainerVisual" Inherits="CompositionVisual"/> <Object Name="CompositionContainerVisual" Inherits="CompositionVisual"/>

14
src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs

@ -559,12 +559,22 @@ namespace Avalonia.Headless
public void PushRenderOptions(RenderOptions renderOptions) public void PushRenderOptions(RenderOptions renderOptions)
{ {
} }
public void PopRenderOptions() public void PopRenderOptions()
{ {
}
public void PushTextOptions(TextOptions textOptions)
{
// No-op in headless stub
}
public void PopTextOptions()
{
// No-op in headless stub
} }
} }

45
src/Skia/Avalonia.Skia/DrawingContextImpl.cs

@ -27,6 +27,7 @@ namespace Avalonia.Skia
private readonly Stack<(SKMatrix matrix, PaintWrapper paint)> _maskStack = new(); private readonly Stack<(SKMatrix matrix, PaintWrapper paint)> _maskStack = new();
private readonly Stack<double> _opacityStack = new(); private readonly Stack<double> _opacityStack = new();
private readonly Stack<RenderOptions> _renderOptionsStack = new(); private readonly Stack<RenderOptions> _renderOptionsStack = new();
private readonly Stack<TextOptions> _textOptionsStack = new();
private readonly Matrix? _postTransform; private readonly Matrix? _postTransform;
private double _currentOpacity = 1.0f; private double _currentOpacity = 1.0f;
private readonly bool _disableSubpixelTextRendering; private readonly bool _disableSubpixelTextRendering;
@ -223,6 +224,7 @@ namespace Avalonia.Skia
public SKSurface? Surface { get; } public SKSurface? Surface { get; }
public RenderOptions RenderOptions { get; set; } public RenderOptions RenderOptions { get; set; }
public TextOptions TextOptions { get; set; }
private void CheckLease() private void CheckLease()
{ {
@ -604,23 +606,32 @@ namespace Avalonia.Skia
{ {
var glyphRunImpl = (GlyphRunImpl)glyphRun; var glyphRunImpl = (GlyphRunImpl)glyphRun;
var textRenderOptions = RenderOptions; // Determine effective TextOptions for text rendering. Start with current pushed TextOptions.
var effectiveTextOptions = TextOptions;
// If subpixel rendering is disabled globally, map subpixel modes to grayscale.
if (_disableSubpixelTextRendering) if (_disableSubpixelTextRendering)
{ {
switch (textRenderOptions.TextRenderingMode) var mode = effectiveTextOptions.TextRenderingMode;
if (mode == TextRenderingMode.SubpixelAntialias ||
(mode == TextRenderingMode.Unspecified && (RenderOptions.EdgeMode == EdgeMode.Antialias || RenderOptions.EdgeMode == EdgeMode.Unspecified)))
{ {
case TextRenderingMode.Unspecified effectiveTextOptions = effectiveTextOptions with { TextRenderingMode = TextRenderingMode.Antialias };
when textRenderOptions.EdgeMode == EdgeMode.Antialias || textRenderOptions.EdgeMode == EdgeMode.Unspecified:
case TextRenderingMode.SubpixelAntialias:
{
textRenderOptions = textRenderOptions with { TextRenderingMode = TextRenderingMode.Antialias };
break;
}
} }
} }
var textBlob = glyphRunImpl.GetTextBlob(textRenderOptions); var renderOptions = RenderOptions;
// If TextRenderingMode is unspecified in TextOptions, use the one from RenderOptions.
#pragma warning disable CS0618
if (effectiveTextOptions.TextRenderingMode == TextRenderingMode.Unspecified && renderOptions.TextRenderingMode != TextRenderingMode.Unspecified)
{
effectiveTextOptions = effectiveTextOptions with { TextRenderingMode = renderOptions.TextRenderingMode };
}
#pragma warning restore CS0618
var textBlob = glyphRunImpl.GetTextBlob(effectiveTextOptions, RenderOptions);
Canvas.DrawText(textBlob, (float)glyphRun.BaselineOrigin.X, Canvas.DrawText(textBlob, (float)glyphRun.BaselineOrigin.X,
(float)glyphRun.BaselineOrigin.Y, paintWrapper.Paint); (float)glyphRun.BaselineOrigin.Y, paintWrapper.Paint);
@ -755,11 +766,25 @@ namespace Avalonia.Skia
RenderOptions = RenderOptions.MergeWith(renderOptions); RenderOptions = RenderOptions.MergeWith(renderOptions);
} }
public void PushTextOptions(TextOptions textOptions)
{
CheckLease();
_textOptionsStack.Push(TextOptions);
TextOptions = TextOptions.MergeWith(textOptions);
}
public void PopRenderOptions() public void PopRenderOptions()
{ {
RenderOptions = _renderOptionsStack.Pop(); RenderOptions = _renderOptionsStack.Pop();
} }
public void PopTextOptions()
{
TextOptions = _textOptionsStack.Pop();
}
/// <inheritdoc /> /// <inheritdoc />
public virtual void Dispose() public virtual void Dispose()
{ {

92
src/Skia/Avalonia.Skia/GlyphRunImpl.cs

@ -15,13 +15,9 @@ namespace Avalonia.Skia
private readonly ushort[] _glyphIndices; private readonly ushort[] _glyphIndices;
private readonly SKPoint[] _glyphPositions; private readonly SKPoint[] _glyphPositions;
// We use an array as opposed to a ConcurrentDictionary to prevent a large amount of lock object allocations. // A two level cache optimized for single-entry read. Uses TextOptions as a key.
// This is possible because the SKFontEdging enum has consecutive integer elements 0, 1, 2, etc. and thus private readonly TwoLevelCache<TextOptions, SKTextBlob> _textBlobCache =
// can be mapped directly to array indices. new TwoLevelCache<TextOptions, SKTextBlob>(secondarySize: 3, evictionAction: b => b?.Dispose());
//
// Should Skia update the enum with more elements, then the size of this array should be updated appropriately.
private const int FontEdgingsCount = (int)SKFontEdging.SubpixelAntialias + 1;
private readonly SKTextBlob?[] _textBlobCache = new SKTextBlob?[FontEdgingsCount];
public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize, public GlyphRunImpl(GlyphTypeface glyphTypeface, double fontRenderingEmSize,
IReadOnlyList<GlyphInfo> glyphInfos, Point baselineOrigin) IReadOnlyList<GlyphInfo> glyphInfos, Point baselineOrigin)
@ -62,7 +58,14 @@ namespace Avalonia.Skia
// But the bounds depends on the edging: for now, always use SubpixelAntialias so we have consistent values. // But the bounds depends on the edging: for now, always use SubpixelAntialias so we have consistent values.
// The resulting bounds may be shifted by 1px on some fonts: // The resulting bounds may be shifted by 1px on some fonts:
// "F" text with Inter size 14 has a 0px left bound with SubpixelAntialias but 1px with Antialias. // "F" text with Inter size 14 has a 0px left bound with SubpixelAntialias but 1px with Antialias.
using var font = CreateFont(SKFontEdging.SubpixelAntialias); var defaultTextOptions = default(TextOptions) with
{
TextRenderingMode = TextRenderingMode.SubpixelAntialias,
TextHintingMode = TextHintingMode.Strong,
BaselinePixelAlignment = BaselinePixelAlignment.Unaligned
};
using var font = CreateFont(defaultTextOptions);
var runBounds = new Rect(); var runBounds = new Rect();
var glyphBounds = ArrayPool<SKRect>.Shared.Rent(count); var glyphBounds = ArrayPool<SKRect>.Shared.Rent(count);
@ -92,26 +95,19 @@ namespace Avalonia.Skia
public Rect Bounds { get; } public Rect Bounds { get; }
public SKTextBlob GetTextBlob(RenderOptions renderOptions) public SKTextBlob GetTextBlob(TextOptions textOptions, RenderOptions renderOptions)
{ {
var edging = SKFontEdging.SubpixelAntialias; if (textOptions.TextRenderingMode == TextRenderingMode.Unspecified)
switch (renderOptions.TextRenderingMode)
{ {
case TextRenderingMode.Alias: textOptions = textOptions with
edging = SKFontEdging.Alias; {
break; TextRenderingMode = renderOptions.EdgeMode == EdgeMode.Aliased ? TextRenderingMode.Alias : TextRenderingMode.SubpixelAntialias
case TextRenderingMode.Antialias: };
edging = SKFontEdging.Antialias;
break;
case TextRenderingMode.Unspecified:
edging = renderOptions.EdgeMode == EdgeMode.Aliased ? SKFontEdging.Alias : SKFontEdging.SubpixelAntialias;
break;
} }
if (_textBlobCache[(int)edging] is null) return _textBlobCache.GetOrAdd(textOptions, k =>
{ {
using var font = CreateFont(edging); using var font = CreateFont(textOptions);
var builder = SKTextBlobBuilderCache.Shared.Get(); var builder = SKTextBlobBuilderCache.Shared.Get();
@ -121,37 +117,59 @@ namespace Avalonia.Skia
runBuffer.SetGlyphs(_glyphIndices); runBuffer.SetGlyphs(_glyphIndices);
var textBlob = builder.Build()!; var textBlob = builder.Build()!;
SKTextBlobBuilderCache.Shared.Return(builder); SKTextBlobBuilderCache.Shared.Return(builder);
return textBlob;
Interlocked.CompareExchange(ref _textBlobCache[(int)edging], textBlob, null); });
}
return _textBlobCache[(int)edging]!;
} }
private SKFont CreateFont(SKFontEdging edging) private SKFont CreateFont(TextOptions textOptions)
{ {
// Determine edging from TextRenderingMode
var edging = textOptions.TextRenderingMode switch
{
TextRenderingMode.Alias => SKFontEdging.Alias,
TextRenderingMode.Antialias => SKFontEdging.Antialias,
TextRenderingMode.SubpixelAntialias => SKFontEdging.SubpixelAntialias,
_ => SKFontEdging.SubpixelAntialias
};
// Determine hinting
var hinting = textOptions.TextHintingMode switch
{
TextHintingMode.None => SKFontHinting.None,
TextHintingMode.Light => SKFontHinting.Slight,
TextHintingMode.Strong => SKFontHinting.Full,
_ => SKFontHinting.Full,
};
// Force auto-hinting for "Slight" mode (prefer autohinter over bytecode hints), otherwise default.
var forceAutoHinting = textOptions.TextHintingMode == TextHintingMode.Light;
// Subpixel rendering enabled when edging is not alias.
var subpixel = edging != SKFontEdging.Alias;
// Baseline snap defaults to true unless explicitly disabled.
var baselineSnap = textOptions.BaselinePixelAlignment != BaselinePixelAlignment.Unaligned;
var font = _glyphTypefaceImpl.CreateSKFont((float)FontRenderingEmSize); var font = _glyphTypefaceImpl.CreateSKFont((float)FontRenderingEmSize);
font.Hinting = SKFontHinting.Full; font.ForceAutoHinting = forceAutoHinting;
font.Subpixel = edging != SKFontEdging.Alias; font.Hinting = hinting;
font.Subpixel = subpixel;
font.Edging = edging; font.Edging = edging;
font.BaselineSnap = baselineSnap;
return font; return font;
} }
public void Dispose() public void Dispose()
{ {
foreach (var textBlob in _textBlobCache) _textBlobCache.ClearAndDispose();
{
textBlob?.Dispose();
}
} }
public IReadOnlyList<float> GetIntersections(float lowerLimit, float upperLimit) public IReadOnlyList<float> GetIntersections(float lowerLimit, float upperLimit)
{ {
var textBlob = GetTextBlob(default); var textBlob = GetTextBlob(default, default);
return textBlob.GetIntercepts(lowerLimit, upperLimit); return textBlob.GetIntercepts(lowerLimit, upperLimit);
} }

157
src/Skia/Avalonia.Skia/TwoLevelCache.cs

@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
namespace Avalonia.Skia
{
/// <summary>
/// Provides a lightweight two-level cache for storing key-value pairs, supporting fast retrieval and optional
/// eviction handling.
/// </summary>
/// <remarks>The cache maintains a primary entry for the most recently added item and a secondary array
/// for additional items, with a configurable capacity. When the cache exceeds its capacity, evicted values can be
/// processed using an optional eviction action. This class is intended for internal use and is not
/// thread-safe.</remarks>
/// <typeparam name="TKey">The type of keys used to identify cached values. Must be non-nullable.</typeparam>
/// <typeparam name="TValue">The type of values to be stored in the cache. Must be a reference type.</typeparam>
internal class TwoLevelCache<TKey, TValue>
where TKey : notnull
where TValue : class
{
private readonly int _secondarySize;
private TKey? _primaryKey;
private TValue? _primaryValue;
private KeyValuePair<TKey, TValue>[]? _secondary;
private int _secondaryCount;
private readonly Action<TValue?>? _evictionAction;
private readonly IEqualityComparer<TKey> _comparer;
public TwoLevelCache(int secondarySize = 3, Action<TValue?>? evictionAction = null, IEqualityComparer<TKey>? comparer = null)
{
if (secondarySize < 0)
{
throw new ArgumentOutOfRangeException(nameof(secondarySize));
}
_secondarySize = secondarySize;
_evictionAction = evictionAction;
_comparer = comparer ?? EqualityComparer<TKey>.Default;
}
public bool TryGet(TKey key, out TValue? value)
{
if (_primaryValue != null && _comparer.Equals(_primaryKey!, key))
{
value = _primaryValue;
return true;
}
var sec = _secondary;
if (sec != null)
{
for (int i = 0; i < _secondaryCount; i++)
{
if (_comparer.Equals(sec[i].Key, key))
{
value = sec[i].Value;
return true;
}
}
}
value = null;
return false;
}
public TValue GetOrAdd(TKey key, Func<TKey, TValue> factory)
{
// Check if key already exists
if (TryGet(key, out var existing) && existing != null)
{
return existing;
}
// Key doesn't exist, create new value
var value = factory(key);
// Primary is empty - store in primary
if (_primaryValue == null)
{
_primaryKey = key;
_primaryValue = value;
return value;
}
// No secondary cache configured - replace primary
if (_secondarySize == 0)
{
_evictionAction?.Invoke(_primaryValue);
_primaryKey = key;
_primaryValue = value;
return value;
}
// Secondary not yet initialized - create it
if (_secondary == null)
{
_secondary = new KeyValuePair<TKey, TValue>[_secondarySize];
_secondaryCount = 0;
}
// Shift existing entries right and insert new one at front
// This maintains insertion order and evicts the oldest (last) entry when full
TValue? evicted = default;
bool shouldEvict = _secondaryCount == _secondarySize;
if (shouldEvict)
{
// Cache is full, last entry will be evicted
evicted = _secondary[_secondarySize - 1].Value;
}
// Shift existing entries to make room at index 0
int shiftCount = shouldEvict ? _secondarySize - 1 : _secondaryCount;
for (int i = shiftCount; i > 0; i--)
{
_secondary[i] = _secondary[i - 1];
}
// Insert new entry at front
_secondary[0] = new KeyValuePair<TKey, TValue>(key, value);
// Update count (capped at size)
if (_secondaryCount < _secondarySize)
{
_secondaryCount++;
}
// Invoke eviction action if we evicted an entry
if (shouldEvict)
{
_evictionAction?.Invoke(evicted);
}
return value;
}
public void ClearAndDispose()
{
if (_primaryValue != null)
{
_evictionAction?.Invoke(_primaryValue);
_primaryValue = null;
_primaryKey = default;
}
if (_secondary != null)
{
for (int i = 0; i < _secondaryCount; i++)
{
_evictionAction?.Invoke(_secondary[i].Value);
}
_secondary = null;
_secondaryCount = 0;
}
}
}
}

8
tests/Avalonia.Controls.UnitTests/Shapes/ShapeTests.cs

@ -180,6 +180,10 @@ public class ShapeTests : ScopedTestBase
protected override void PushRenderOptionsCore(RenderOptions renderOptions) protected override void PushRenderOptionsCore(RenderOptions renderOptions)
{ {
}
protected override void PushTextOptionsCore(TextOptions textOptions)
{
} }
protected override void PushTransformCore(Matrix matrix) protected override void PushTransformCore(Matrix matrix)
@ -210,6 +214,10 @@ public class ShapeTests : ScopedTestBase
{ {
} }
protected override void PopTextOptionsCore()
{
}
protected override void DisposeCore() protected override void DisposeCore()
{ {
} }

38
tests/Avalonia.RenderTests/Controls/TextBlockTests.cs

@ -385,6 +385,44 @@ namespace Avalonia.Skia.RenderTests
FontFamily = new FontFamily(symbolsFont) FontFamily = new FontFamily(symbolsFont)
}; };
} }
[InlineData(TextRenderingMode.Antialias, TextHintingMode.None)]
[InlineData(TextRenderingMode.Alias, TextHintingMode.None)]
[InlineData(TextRenderingMode.Antialias, TextHintingMode.Light)]
[InlineData(TextRenderingMode.Alias, TextHintingMode.Light)]
[Win32Theory("Depends on the backend")]
public async Task Should_Render_TextBlock_With_TextOptions(
TextRenderingMode textRenderingMode,
TextHintingMode textHintingMode)
{
var textBlock = new TextBlock
{
FontFamily = TestFontFamily,
FontSize = 24,
Foreground = Brushes.Black,
Text = "TextOptions",
Background = Brushes.LightGray,
Padding = new Thickness(10)
};
TextOptions.SetTextOptions(textBlock, new TextOptions
{
TextRenderingMode = textRenderingMode,
TextHintingMode = textHintingMode
});
var target = new Border
{
Width = 300,
Height = 100,
Background = Brushes.White,
Child = textBlock
};
var testName = $"Should_Render_TextBlock_With_TextOptions_{textRenderingMode}_{textHintingMode}";
await RenderToFile(target, testName);
CompareImages(testName);
}
} }
} }

2
tests/Avalonia.RenderTests/Media/GlyphRunTests.cs

@ -115,7 +115,7 @@ namespace Avalonia.Skia.RenderTests
[TextElement.ForegroundProperty] = new SolidColorBrush { Color = Colors.Black } [TextElement.ForegroundProperty] = new SolidColorBrush { Color = Colors.Black }
}; };
RenderOptions.SetTextRenderingMode(control, TextRenderingMode.Alias); TextOptions.SetTextRenderingMode(control, TextRenderingMode.Alias);
Decorator target = new Decorator Decorator target = new Decorator
{ {

2
tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs

@ -169,7 +169,7 @@ namespace Avalonia.Skia.UnitTests.Media
var glyphRun1 = CreateGlyphRun(shapedBuffer); var glyphRun1 = CreateGlyphRun(shapedBuffer);
var bounds1 = glyphRun1.InkBounds; var bounds1 = glyphRun1.InkBounds;
((GlyphRunImpl)glyphRun1.PlatformImpl.Item).GetTextBlob(new RenderOptions { TextRenderingMode = TextRenderingMode.SubpixelAntialias }); ((GlyphRunImpl)glyphRun1.PlatformImpl.Item).GetTextBlob(new TextOptions { TextRenderingMode = TextRenderingMode.SubpixelAntialias }, default);
var bounds2 = CreateGlyphRun(shapedBuffer).InkBounds; var bounds2 = CreateGlyphRun(shapedBuffer).InkBounds;

351
tests/Avalonia.Skia.UnitTests/TwoLevelCacheTests.cs

@ -0,0 +1,351 @@
#nullable enable
using System;
using System.Collections.Generic;
using Avalonia.Skia;
using Xunit;
namespace Avalonia.Skia.UnitTests
{
public class TwoLevelCacheTests
{
[Fact]
public void Constructor_WithNegativeSecondarySize_ThrowsArgumentOutOfRangeException()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new TwoLevelCache<string, object>(-1));
}
[Fact]
public void Constructor_WithZeroSecondarySize_DoesNotThrow()
{
var cache = new TwoLevelCache<string, object>(0);
Assert.NotNull(cache);
}
[Fact]
public void TryGet_EmptyCache_ReturnsFalse()
{
var cache = new TwoLevelCache<string, object>();
var result = cache.TryGet("key", out var value);
Assert.False(result);
Assert.Null(value);
}
[Fact]
public void GetOrAdd_FirstItem_StoresInPrimary()
{
var cache = new TwoLevelCache<string, object>();
var value = new object();
var result = cache.GetOrAdd("key1", _ => value);
Assert.Same(value, result);
Assert.True(cache.TryGet("key1", out var retrieved));
Assert.Same(value, retrieved);
}
[Fact]
public void GetOrAdd_SameKey_ReturnsExistingValue()
{
var cache = new TwoLevelCache<string, object>();
var value1 = new object();
var value2 = new object();
cache.GetOrAdd("key", _ => value1);
var result = cache.GetOrAdd("key", _ => value2);
Assert.Same(value1, result);
}
[Fact]
public void GetOrAdd_SecondItem_StoresInSecondary()
{
var cache = new TwoLevelCache<string, object>(secondarySize: 3);
var value1 = new object();
var value2 = new object();
cache.GetOrAdd("key1", _ => value1);
cache.GetOrAdd("key2", _ => value2);
Assert.True(cache.TryGet("key1", out var retrieved1));
Assert.Same(value1, retrieved1);
Assert.True(cache.TryGet("key2", out var retrieved2));
Assert.Same(value2, retrieved2);
}
[Fact]
public void GetOrAdd_MultipleItems_StoresCorrectly()
{
var cache = new TwoLevelCache<string, object>(secondarySize: 3);
var values = new object[4];
for (int i = 0; i < 4; i++)
{
values[i] = new object();
cache.GetOrAdd($"key{i}", _ => values[i]);
}
// All should be retrievable
for (int i = 0; i < 4; i++)
{
Assert.True(cache.TryGet($"key{i}", out var retrieved));
Assert.Same(values[i], retrieved);
}
}
[Fact]
public void GetOrAdd_ExceedsCapacity_CallsEvictionAction()
{
var evictedValues = new List<object?>();
var cache = new TwoLevelCache<string, object>(
secondarySize: 2,
evictionAction: v => evictedValues.Add(v));
var value1 = new object();
var value2 = new object();
var value3 = new object();
var value4 = new object();
cache.GetOrAdd("key1", _ => value1);
cache.GetOrAdd("key2", _ => value2);
cache.GetOrAdd("key3", _ => value3);
// No evictions yet
Assert.Empty(evictedValues);
// This should cause eviction
cache.GetOrAdd("key4", _ => value4);
Assert.Single(evictedValues);
Assert.Same(value2, evictedValues[0]);
}
[Fact]
public void GetOrAdd_ZeroSecondarySize_EvictsPrimaryImmediately()
{
var evictedValues = new List<object?>();
var cache = new TwoLevelCache<string, object>(
secondarySize: 0,
evictionAction: v => evictedValues.Add(v));
var value1 = new object();
var value2 = new object();
cache.GetOrAdd("key1", _ => value1);
cache.GetOrAdd("key2", _ => value2);
Assert.Single(evictedValues);
Assert.Same(value1, evictedValues[0]);
// Only the latest value should be retrievable
Assert.False(cache.TryGet("key1", out _));
Assert.True(cache.TryGet("key2", out var retrieved));
Assert.Same(value2, retrieved);
}
[Fact]
public void GetOrAdd_DuplicateKey_ReturnsExistingWithoutCallingFactory()
{
var cache = new TwoLevelCache<string, object>();
var value1 = new object();
var factoryCalled = false;
// Add initial value
cache.GetOrAdd("key", _ => value1);
// Try to add again - factory should not be called
var result = cache.GetOrAdd("key", _ =>
{
factoryCalled = true;
return new object();
});
// Should return first value without calling factory
Assert.Same(value1, result);
Assert.False(factoryCalled);
}
[Fact]
public void GetOrAdd_DuplicateKeyInSecondary_ReturnsExistingWithoutCallingFactory()
{
var cache = new TwoLevelCache<string, object>(secondarySize: 2);
var value1 = new object();
var value2 = new object();
var factoryCalled = false;
cache.GetOrAdd("key1", _ => value1);
cache.GetOrAdd("key2", _ => value2);
// Try to add key2 again - factory should not be called
var result = cache.GetOrAdd("key2", _ =>
{
factoryCalled = true;
return new object();
});
Assert.Same(value2, result);
Assert.False(factoryCalled);
}
[Fact]
public void ClearAndDispose_EmptyCache_DoesNotThrow()
{
var cache = new TwoLevelCache<string, object>();
cache.ClearAndDispose();
}
[Fact]
public void ClearAndDispose_WithValues_CallsEvictionActionForAll()
{
var evictedValues = new List<object?>();
var cache = new TwoLevelCache<string, object>(
secondarySize: 2,
evictionAction: v => evictedValues.Add(v));
var value1 = new object();
var value2 = new object();
var value3 = new object();
cache.GetOrAdd("key1", _ => value1);
cache.GetOrAdd("key2", _ => value2);
cache.GetOrAdd("key3", _ => value3);
cache.ClearAndDispose();
Assert.Equal(3, evictedValues.Count);
Assert.Contains(value1, evictedValues);
Assert.Contains(value2, evictedValues);
Assert.Contains(value3, evictedValues);
}
[Fact]
public void ClearAndDispose_ClearsAllEntries()
{
var cache = new TwoLevelCache<string, object>(secondarySize: 2);
cache.GetOrAdd("key1", _ => new object());
cache.GetOrAdd("key2", _ => new object());
cache.ClearAndDispose();
Assert.False(cache.TryGet("key1", out _));
Assert.False(cache.TryGet("key2", out _));
}
[Fact]
public void GetOrAdd_WithCustomComparer_UsesComparer()
{
var comparer = StringComparer.OrdinalIgnoreCase;
var cache = new TwoLevelCache<string, object>(comparer: comparer);
var value = new object();
cache.GetOrAdd("KEY", _ => value);
Assert.True(cache.TryGet("key", out var retrieved));
Assert.Same(value, retrieved);
}
[Fact]
public void TryGet_WithCustomComparer_UsesComparer()
{
var comparer = StringComparer.OrdinalIgnoreCase;
var cache = new TwoLevelCache<string, object>(
secondarySize: 2,
comparer: comparer);
var value1 = new object();
var value2 = new object();
cache.GetOrAdd("PRIMARY", _ => value1);
cache.GetOrAdd("SECONDARY", _ => value2);
Assert.True(cache.TryGet("primary", out var retrieved1));
Assert.Same(value1, retrieved1);
Assert.True(cache.TryGet("secondary", out var retrieved2));
Assert.Same(value2, retrieved2);
}
[Fact]
public void GetOrAdd_IntKeys_WorksCorrectly()
{
var cache = new TwoLevelCache<int, object>(secondarySize: 2);
var value1 = new object();
var value2 = new object();
var value3 = new object();
cache.GetOrAdd(1, _ => value1);
cache.GetOrAdd(2, _ => value2);
cache.GetOrAdd(3, _ => value3);
Assert.True(cache.TryGet(1, out var retrieved1));
Assert.Same(value1, retrieved1);
Assert.True(cache.TryGet(2, out var retrieved2));
Assert.Same(value2, retrieved2);
Assert.True(cache.TryGet(3, out var retrieved3));
Assert.Same(value3, retrieved3);
}
[Fact]
public void GetOrAdd_RotatesSecondaryCorrectly()
{
var evictedValues = new List<object?>();
var cache = new TwoLevelCache<int, object>(
secondarySize: 2,
evictionAction: v => evictedValues.Add(v));
var values = new object[5];
for (int i = 0; i < 5; i++)
{
values[i] = new object();
cache.GetOrAdd(i, _ => values[i]);
}
// Primary: 0, Secondary: [1, 2]
// After adding 3: Primary: 0, Secondary: [3, 1] (evicts 2)
// After adding 4: Primary: 0, Secondary: [4, 3] (evicts 1)
Assert.Equal(2, evictedValues.Count);
Assert.Contains(values[2], evictedValues);
Assert.Contains(values[1], evictedValues);
// These should still be in cache
Assert.True(cache.TryGet(0, out _));
Assert.True(cache.TryGet(3, out _));
Assert.True(cache.TryGet(4, out _));
// These should be evicted
Assert.False(cache.TryGet(1, out _));
Assert.False(cache.TryGet(2, out _));
}
[Fact]
public void FactoryFunction_ReceivesCorrectKey()
{
var cache = new TwoLevelCache<string, string>();
string? capturedKey = null;
cache.GetOrAdd("testKey", key =>
{
capturedKey = key;
return "value";
});
Assert.Equal("testKey", capturedKey);
}
[Fact]
public void GetOrAdd_NullEvictionAction_DoesNotThrow()
{
var cache = new TwoLevelCache<string, object>(
secondarySize: 1,
evictionAction: null);
cache.GetOrAdd("key1", _ => new object());
cache.GetOrAdd("key2", _ => new object());
cache.GetOrAdd("key3", _ => new object()); // Should evict without error
cache.ClearAndDispose(); // Should also not throw
}
}
}

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Alias_Light.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Alias_None.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Antialias_Light.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Render_TextBlock_With_TextOptions_Antialias_None.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Loading…
Cancel
Save