Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/Avalonia.Controls/Documents/Inline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,22 @@ public static void SetTextDecorations(Control control, TextDecorationCollection?
control.SetValue(TextDecorationsProperty, value);
}

internal abstract void BuildTextRun(IList<TextRun> textRuns, Size blockSize);
internal abstract void BuildTextRun(IList<TextRun> textRuns);

/// <summary>
/// Measures the controls this inline embeds against the width available to the block.
/// </summary>
/// <returns>
/// True when a control came back a different size, so the caller can drop line metrics
/// that were measured against the old one.
/// </returns>
/// <remarks>
/// Text runs depend on the content alone, so they survive a constraint change. An embedded
/// control is the exception: its size answers to the available width. The run reports that
/// size live, but a formatted line snapshots its metrics, so a layout built before the
/// control resized keeps reporting the old width and height.
/// </remarks>
internal virtual bool MeasureEmbeddedControls(Size blockSize) => false;

internal abstract void AppendText(StringBuilder stringBuilder);

Expand Down
19 changes: 14 additions & 5 deletions src/Avalonia.Controls/Documents/InlineUIContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,24 @@ public Control Child
set => SetValue(ChildProperty, value);
}

internal override void BuildTextRun(IList<TextRun> textRuns, Size blockSize)
internal override void BuildTextRun(IList<TextRun> textRuns)
{
if (_measuredWidth != blockSize.Width || !Child.IsMeasureValid)
textRuns.Add(new EmbeddedControlRun(Child, CreateTextRunProperties()));
}

internal override bool MeasureEmbeddedControls(Size blockSize)
{
if (_measuredWidth == blockSize.Width && Child.IsMeasureValid)
{
Child.Measure(new Size(blockSize.Width, double.PositiveInfinity));
_measuredWidth = blockSize.Width;
return false;
}

textRuns.Add(new EmbeddedControlRun(Child, CreateTextRunProperties()));
var previousSize = Child.DesiredSize;

Child.Measure(new Size(blockSize.Width, double.PositiveInfinity));
_measuredWidth = blockSize.Width;

return Child.DesiredSize != previousSize;
}

internal override void AppendText(StringBuilder stringBuilder)
Expand Down
2 changes: 1 addition & 1 deletion src/Avalonia.Controls/Documents/LineBreak.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public LineBreak()
{
}

internal override void BuildTextRun(IList<TextRun> textRuns, Size blockSize)
internal override void BuildTextRun(IList<TextRun> textRuns)
{
var text = Environment.NewLine;

Expand Down
2 changes: 1 addition & 1 deletion src/Avalonia.Controls/Documents/Run.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public string? Text {
set => SetValue(TextProperty, value);
}

internal override void BuildTextRun(IList<TextRun> textRuns, Size blockSize)
internal override void BuildTextRun(IList<TextRun> textRuns)
{
var text = Text ?? "";

Expand Down
16 changes: 14 additions & 2 deletions src/Avalonia.Controls/Documents/Span.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,26 @@ public InlineCollection Inlines
set => SetValue(InlinesProperty, value);
}

internal override void BuildTextRun(IList<TextRun> textRuns, Size blockSize)
internal override void BuildTextRun(IList<TextRun> textRuns)
{
foreach (var inline in Inlines)
{
inline.BuildTextRun(textRuns, blockSize);
inline.BuildTextRun(textRuns);
}
}

internal override bool MeasureEmbeddedControls(Size blockSize)
{
var resized = false;

foreach (var inline in Inlines)
{
resized |= inline.MeasureEmbeddedControls(blockSize);
}

return resized;
}

internal override void AppendText(StringBuilder stringBuilder)
{
foreach (var inline in Inlines)
Expand Down
6 changes: 4 additions & 2 deletions src/Avalonia.Controls/SelectableTextBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,11 @@ protected override TextLayout CreateTextLayout(string? text)

ITextSource textSource;

if (_textRuns != null)
if (HasComplexContent)
{
textSource = new InlinesTextSource(_textRuns, textStyleOverrides);
EnsureTextRuns();

textSource = new InlinesTextSource(_textRuns!, textStyleOverrides);
}
else
{
Expand Down
108 changes: 88 additions & 20 deletions src/Avalonia.Controls/TextBlock.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text;
using Avalonia.Automation.Peers;
Expand Down Expand Up @@ -186,7 +186,18 @@ public TextBlock()
/// <summary>
/// Gets the <see cref="TextLayout"/> used to render the text.
/// </summary>
public TextLayout TextLayout => _textLayout ??= CreateTextLayout(Text);
public TextLayout TextLayout => _textLayout ??= CreateTextLayoutCore();

private TextLayout CreateTextLayoutCore()
{
// A layout can be created outside of a measure pass - a render pass that runs before a
// queued measure, or a read of this property - so it cannot rely on the measure pass
// having brought the complex content up to date.
EnsureTextRuns();
MeasureEmbeddedControls(GetMaxSizeFromConstraint());

return CreateTextLayout(Text);
}

/// <summary>
/// Gets or sets the padding to place around the <see cref="Text"/>.
Expand Down Expand Up @@ -674,9 +685,11 @@ protected virtual TextLayout CreateTextLayout(string? text)

ITextSource textSource;

if (_textRuns != null)
if (HasComplexContent)
{
textSource = new InlinesTextSource(_textRuns);
EnsureTextRuns();

textSource = new InlinesTextSource(_textRuns!);
}
else
{
Expand All @@ -701,6 +714,8 @@ protected virtual TextLayout CreateTextLayout(string? text)
protected void InvalidateTextLayout()
{
_textRunCache?.Invalidate();
_textRuns = null;
DisposeTextLayout();
InvalidateVisual();
InvalidateMeasure();
Comment thread
Gillibald marked this conversation as resolved.
}
Expand All @@ -711,19 +726,78 @@ protected void InvalidateTextLayout()
/// </summary>
private void InvalidateTextLayoutKeepCache()
{
DisposeTextLayout();
InvalidateVisual();
InvalidateMeasure();
}

protected override void OnMeasureInvalidated()
/// <remarks>
/// InvalidateMeasure only raises OnMeasureInvalidated while the measure is still
/// valid, so a second invalidation before the next measure pass would leave the
/// layout built from the content the first one replaced.
/// </remarks>
private void DisposeTextLayout()
{
_textLayout?.Dispose();
_textLayout = null;
_textRuns = null;
}

protected override void OnMeasureInvalidated()
{
DisposeTextLayout();

base.OnMeasureInvalidated();
}

/// <summary>
/// Builds the text runs for <see cref="Inlines"/> unless they are already in sync with the
/// content. The runs are derived from the content alone, so only a content change discards
/// them; the constraint does not.
/// </summary>
/// <remarks>
/// Missing runs are not the same as no inlines: shaping without them falls back to
/// <see cref="Text"/>, which is null whenever the content lives in <see cref="Inlines"/>,
/// and that empty result is what the <see cref="TextRunCache"/> stores for the rest of the
/// control's life.
/// </remarks>
private protected void EnsureTextRuns()
{
if (_textRuns != null || !HasComplexContent)
{
return;
}

var textRuns = new List<TextRun>();

foreach (var inline in Inlines!)
{
inline.BuildTextRun(textRuns);
}

_textRuns = textRuns;
}

/// <summary>
/// Measures the controls the inlines embed against the width available to the block, and
/// reports whether any of them came back a different size.
/// </summary>
private bool MeasureEmbeddedControls(Size constraint)
{
if (!HasComplexContent)
{
return false;
}

var resized = false;

foreach (var inline in Inlines!)
{
resized |= inline.MeasureEmbeddedControls(constraint);
}

return resized;
}

protected override Size MeasureOverride(Size availableSize)
{
var padding = Padding;
Expand All @@ -739,26 +813,20 @@ protected override Size MeasureOverride(Size availableSize)
if (_constraint != deflatedSize)
{
//Reset TextLayout when the constraint is not matching.
_textLayout?.Dispose();
_textLayout = null;
DisposeTextLayout();
_constraint = deflatedSize;

//Force arrange so text will be properly aligned.
InvalidateArrange();
}

var inlines = Inlines;
EnsureTextRuns();

if (HasComplexContent)
if (MeasureEmbeddedControls(deflatedSize))
{
var textRuns = new List<TextRun>();

foreach (var inline in inlines!)
{
inline.BuildTextRun(textRuns, deflatedSize);
}

_textRuns = textRuns;
// A line snapshots its metrics when it is formatted, so an existing layout still
// reports the width and height the child had before it was measured again.
DisposeTextLayout();
}

//This implicitly recreated the TextLayout with a new constraint if we previously reset it.
Expand All @@ -782,8 +850,7 @@ protected override Size ArrangeOverride(Size finalSize)
var availableSize = finalSize.Deflate(padding);

// Dispose the TextLayout but preserve the TextRunCache so shaped runs are reused.
_textLayout?.Dispose();
_textLayout = null;
DisposeTextLayout();
_constraint = availableSize;

//This implicitly recreated the TextLayout with a new constraint.
Expand Down Expand Up @@ -870,6 +937,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
case nameof(TextAlignment):
case nameof(Padding):
case nameof(LineHeight):
case nameof(LineSpacing):
case nameof(MaxLines):
{
InvalidateTextLayoutKeepCache();
Expand Down
8 changes: 4 additions & 4 deletions tests/Avalonia.Controls.UnitTests/InlineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public void Should_Inherit_FontWeight_In_Nested_Inlines()
bold.Inlines.Add(span);

var textRuns = new List<TextRun>();
bold.BuildTextRun(textRuns, default);
bold.BuildTextRun(textRuns);

var runProperties = textRuns[0].Properties;
Assert.NotNull(runProperties);
Expand All @@ -36,7 +36,7 @@ public void Should_Inherit_FontStyle_In_Nested_Inlines()
italic.Inlines.Add(span);

var textRuns = new List<TextRun>();
italic.BuildTextRun(textRuns, default);
italic.BuildTextRun(textRuns);

var runProperties = textRuns[0].Properties;
Assert.NotNull(runProperties);
Expand All @@ -54,7 +54,7 @@ public void Should_Inherit_FontStretch_In_Nested_Inlines()
span.Inlines.Add(innerSpan);

var textRuns = new List<TextRun>();
span.BuildTextRun(textRuns, default);
span.BuildTextRun(textRuns);

var runProperties = textRuns[0].Properties;
Assert.NotNull(runProperties);
Expand All @@ -74,7 +74,7 @@ public void Should_Inherit_Background_In_Nested_Inlines()
span.Inlines.Add(innerSpan);

var textRuns = new List<TextRun>();
span.BuildTextRun(textRuns, default);
span.BuildTextRun(textRuns);

var runProperties = textRuns[0].Properties;
Assert.NotNull(runProperties);
Expand Down
19 changes: 19 additions & 0 deletions tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,25 @@ private static SelectableTextBlock CreateSelectableTextBlockInTopLevel(IClipboar
return target;
}

[Fact]
public void Should_Shape_Inlines_When_TextLayout_Is_Created_Between_Content_Change_And_Measure()
{
using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);

var target = new SelectableTextBlock { Inlines = new InlineCollection() };

target.Measure(new Size(1000, 1000));
target.Arrange(new Rect(0, 0, 1000, 1000));

target.Inlines = new InlineCollection { new Run("Hello World") };

_ = target.TextLayout;

target.Measure(new Size(1000, 1000));

Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}");
}

private class TestTopLevel(ITopLevelImpl impl) : TopLevel(impl);
}
}
Loading