From b1d8db0d0084936fe570307bca074c1cee4c335a Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 4 Sep 2026 18:16:29 +0200 Subject: [PATCH 1/4] Keep TextBlock text runs in sync with its inlines _textRuns is built from Inlines, but OnMeasureInvalidated discarded it on any measure invalidation while Inlines still held the content. Between that point and the next measure pass, CreateTextLayout read a null _textRuns as "no inlines" and shaped Text instead, which is null whenever the content lives in Inlines. That empty result went into the TextRunCache, keyed by text source index, so every later layout reused it and the control rendered nothing until something invalidated the cache. - Discard _textRuns in InvalidateTextLayout, next to the run cache, so the runs and the cache are dropped by the same event and cannot disagree about the content. - Build the runs on demand in EnsureTextRuns, and pick the text source by HasComplexContent rather than by _textRuns being set. - Split the constraint-dependent work out of run building. Runs answer to the content alone; only an embedded control answers to the available width, so Inline.MeasureEmbeddedControls measures it and EmbeddedControlRun reports the child's DesiredSize live. Runs now survive a constraint change instead of being rebuilt every measure. SelectableTextBlock never hands the run cache to its layout, but it shared the same fallback and kept the wrong layout on _textLayout. Fixes #21902 Co-Authored-By: Claude Opus 5 --- src/Avalonia.Controls/Documents/Inline.cs | 14 +- .../Documents/InlineUIContainer.cs | 9 +- src/Avalonia.Controls/Documents/LineBreak.cs | 2 +- src/Avalonia.Controls/Documents/Run.cs | 2 +- src/Avalonia.Controls/Documents/Span.cs | 12 +- src/Avalonia.Controls/SelectableTextBlock.cs | 6 +- src/Avalonia.Controls/TextBlock.cs | 82 +++++++++--- .../InlineTests.cs | 8 +- .../SelectableTextBlockTests.cs | 19 +++ .../TextBlockTests.cs | 121 ++++++++++++++++++ 10 files changed, 243 insertions(+), 32 deletions(-) diff --git a/src/Avalonia.Controls/Documents/Inline.cs b/src/Avalonia.Controls/Documents/Inline.cs index 4338dc6d536..95cf85e47ff 100644 --- a/src/Avalonia.Controls/Documents/Inline.cs +++ b/src/Avalonia.Controls/Documents/Inline.cs @@ -65,7 +65,19 @@ public static void SetTextDecorations(Control control, TextDecorationCollection? control.SetValue(TextDecorationsProperty, value); } - internal abstract void BuildTextRun(IList textRuns, Size blockSize); + internal abstract void BuildTextRun(IList textRuns); + + /// + /// Measures the controls this inline embeds against the width available to the block. + /// + /// + /// 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, and the run reports + /// that size live, so re-measuring the control is what resizes the line. + /// + internal virtual void MeasureEmbeddedControls(Size blockSize) + { + } internal abstract void AppendText(StringBuilder stringBuilder); diff --git a/src/Avalonia.Controls/Documents/InlineUIContainer.cs b/src/Avalonia.Controls/Documents/InlineUIContainer.cs index b71c2bc2895..59dffdace50 100644 --- a/src/Avalonia.Controls/Documents/InlineUIContainer.cs +++ b/src/Avalonia.Controls/Documents/InlineUIContainer.cs @@ -53,15 +53,18 @@ public Control Child set => SetValue(ChildProperty, value); } - internal override void BuildTextRun(IList textRuns, Size blockSize) + internal override void BuildTextRun(IList textRuns) + { + textRuns.Add(new EmbeddedControlRun(Child, CreateTextRunProperties())); + } + + internal override void MeasureEmbeddedControls(Size blockSize) { if (_measuredWidth != blockSize.Width || !Child.IsMeasureValid) { Child.Measure(new Size(blockSize.Width, double.PositiveInfinity)); _measuredWidth = blockSize.Width; } - - textRuns.Add(new EmbeddedControlRun(Child, CreateTextRunProperties())); } internal override void AppendText(StringBuilder stringBuilder) diff --git a/src/Avalonia.Controls/Documents/LineBreak.cs b/src/Avalonia.Controls/Documents/LineBreak.cs index d2793661665..ee31b85be96 100644 --- a/src/Avalonia.Controls/Documents/LineBreak.cs +++ b/src/Avalonia.Controls/Documents/LineBreak.cs @@ -19,7 +19,7 @@ public LineBreak() { } - internal override void BuildTextRun(IList textRuns, Size blockSize) + internal override void BuildTextRun(IList textRuns) { var text = Environment.NewLine; diff --git a/src/Avalonia.Controls/Documents/Run.cs b/src/Avalonia.Controls/Documents/Run.cs index bdb6920570e..4133e36e99f 100644 --- a/src/Avalonia.Controls/Documents/Run.cs +++ b/src/Avalonia.Controls/Documents/Run.cs @@ -50,7 +50,7 @@ public string? Text { set => SetValue(TextProperty, value); } - internal override void BuildTextRun(IList textRuns, Size blockSize) + internal override void BuildTextRun(IList textRuns) { var text = Text ?? ""; diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index 624806f67b5..be785a275ee 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -38,11 +38,19 @@ public InlineCollection Inlines set => SetValue(InlinesProperty, value); } - internal override void BuildTextRun(IList textRuns, Size blockSize) + internal override void BuildTextRun(IList textRuns) { foreach (var inline in Inlines) { - inline.BuildTextRun(textRuns, blockSize); + inline.BuildTextRun(textRuns); + } + } + + internal override void MeasureEmbeddedControls(Size blockSize) + { + foreach (var inline in Inlines) + { + inline.MeasureEmbeddedControls(blockSize); } } diff --git a/src/Avalonia.Controls/SelectableTextBlock.cs b/src/Avalonia.Controls/SelectableTextBlock.cs index 1f84da6a875..5ba080a7e3a 100644 --- a/src/Avalonia.Controls/SelectableTextBlock.cs +++ b/src/Avalonia.Controls/SelectableTextBlock.cs @@ -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 { diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 4d1153dfa33..c69b8502377 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using Avalonia.Automation.Peers; @@ -186,7 +186,18 @@ public TextBlock() /// /// Gets the used to render the text. /// - 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); + } /// /// Gets or sets the padding to place around the . @@ -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 { @@ -701,6 +714,7 @@ protected virtual TextLayout CreateTextLayout(string? text) protected void InvalidateTextLayout() { _textRunCache?.Invalidate(); + _textRuns = null; InvalidateVisual(); InvalidateMeasure(); } @@ -719,11 +733,54 @@ protected override void OnMeasureInvalidated() { _textLayout?.Dispose(); _textLayout = null; - _textRuns = null; base.OnMeasureInvalidated(); } + /// + /// Builds the text runs for 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. + /// + /// + /// Missing runs are not the same as no inlines: shaping without them falls back to + /// , which is null whenever the content lives in , + /// and that empty result is what the stores for the rest of the + /// control's life. + /// + private protected void EnsureTextRuns() + { + if (_textRuns != null || !HasComplexContent) + { + return; + } + + var textRuns = new List(); + + foreach (var inline in Inlines!) + { + inline.BuildTextRun(textRuns); + } + + _textRuns = textRuns; + } + + /// + /// Measures the controls the inlines embed against the width available to the block. + /// + private void MeasureEmbeddedControls(Size constraint) + { + if (!HasComplexContent) + { + return; + } + + foreach (var inline in Inlines!) + { + inline.MeasureEmbeddedControls(constraint); + } + } + protected override Size MeasureOverride(Size availableSize) { var padding = Padding; @@ -747,19 +804,8 @@ protected override Size MeasureOverride(Size availableSize) InvalidateArrange(); } - var inlines = Inlines; - - if (HasComplexContent) - { - var textRuns = new List(); - - foreach (var inline in inlines!) - { - inline.BuildTextRun(textRuns, deflatedSize); - } - - _textRuns = textRuns; - } + EnsureTextRuns(); + MeasureEmbeddedControls(deflatedSize); //This implicitly recreated the TextLayout with a new constraint if we previously reset it. var textLayout = TextLayout; diff --git a/tests/Avalonia.Controls.UnitTests/InlineTests.cs b/tests/Avalonia.Controls.UnitTests/InlineTests.cs index 2ecc2cfb77a..2d012154973 100644 --- a/tests/Avalonia.Controls.UnitTests/InlineTests.cs +++ b/tests/Avalonia.Controls.UnitTests/InlineTests.cs @@ -19,7 +19,7 @@ public void Should_Inherit_FontWeight_In_Nested_Inlines() bold.Inlines.Add(span); var textRuns = new List(); - bold.BuildTextRun(textRuns, default); + bold.BuildTextRun(textRuns); var runProperties = textRuns[0].Properties; Assert.NotNull(runProperties); @@ -36,7 +36,7 @@ public void Should_Inherit_FontStyle_In_Nested_Inlines() italic.Inlines.Add(span); var textRuns = new List(); - italic.BuildTextRun(textRuns, default); + italic.BuildTextRun(textRuns); var runProperties = textRuns[0].Properties; Assert.NotNull(runProperties); @@ -54,7 +54,7 @@ public void Should_Inherit_FontStretch_In_Nested_Inlines() span.Inlines.Add(innerSpan); var textRuns = new List(); - span.BuildTextRun(textRuns, default); + span.BuildTextRun(textRuns); var runProperties = textRuns[0].Properties; Assert.NotNull(runProperties); @@ -74,7 +74,7 @@ public void Should_Inherit_Background_In_Nested_Inlines() span.Inlines.Add(innerSpan); var textRuns = new List(); - span.BuildTextRun(textRuns, default); + span.BuildTextRun(textRuns); var runProperties = textRuns[0].Properties; Assert.NotNull(runProperties); diff --git a/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs index ddf78fb7c42..11cc550aeba 100644 --- a/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs @@ -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); } } diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index df2e5c78b50..3122a54b3fe 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -614,6 +614,127 @@ public void Measure_And_Arrange_Should_Use_WidthIncludingTrailingWhitespace_For_ Assert.Equal(new Rect(default, expectedSize), target.Bounds); } + [Fact] + public void Should_Shape_Inlines_When_TextLayout_Is_Created_Before_Measure() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Inlines = new InlineCollection { new Run("Hello World") } }; + + // A render pass that runs before the queued measure reads the layout while the + // runs have not been built yet. + _ = target.TextLayout; + + target.Measure(new Size(1000, 1000)); + + Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}"); + } + + [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 TextBlock { 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}"); + } + + [Fact] + public void Should_Not_Cache_Shaped_Runs_Built_Outside_Of_Measure_From_Text() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { 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; + + // Measuring at a different constraint drops the layout, so a wrong result here can + // only come from shaped runs that were cached outside of the measure pass. + target.Measure(new Size(900, 1000)); + + Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}"); + } + + [Fact] + public void Should_Shape_Inlines_Added_To_The_Collection_Before_Measure() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Inlines = new InlineCollection() }; + + target.Measure(new Size(1000, 1000)); + target.Arrange(new Rect(0, 0, 1000, 1000)); + + target.Inlines!.Add(new Run("Hello World")); + + _ = target.TextLayout; + + target.Measure(new Size(1000, 1000)); + + Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}"); + } + + [Fact] + public void Should_Remeasure_Embedded_Controls_When_The_Constraint_Changes() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new TextBlock { Text = "Hello World Hello World", TextWrapping = TextWrapping.Wrap }; + var target = new TextBlock { Inlines = new InlineCollection { new InlineUIContainer(child) } }; + + target.Measure(new Size(1000, 1000)); + target.Arrange(new Rect(0, 0, 1000, 1000)); + + var wide = target.DesiredSize; + + target.Measure(new Size(60, 1000)); + target.Arrange(new Rect(0, 0, 60, 1000)); + + var narrow = target.DesiredSize; + + Assert.True(narrow.Width < wide.Width, $"wide {wide}, narrow {narrow}"); + Assert.True(narrow.Height > wide.Height, $"wide {wide}, narrow {narrow}"); + } + + [Fact] + public void Should_Remeasure_Embedded_Controls_When_The_Child_Invalidates_Its_Measure() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new Border { Width = 20, Height = 20 }; + var target = new TextBlock { Inlines = new InlineCollection { new InlineUIContainer(child) } }; + + target.Measure(new Size(1000, 1000)); + target.Arrange(new Rect(0, 0, 1000, 1000)); + + var before = target.DesiredSize; + + child.Width = 80; + + // Stands in for the layout manager propagating the child's invalidation to its parent. + target.InvalidateMeasure(); + + target.Measure(new Size(1000, 1000)); + target.Arrange(new Rect(0, 0, 1000, 1000)); + + Assert.True(target.DesiredSize.Width > before.Width, $"before {before}, after {target.DesiredSize}"); + } + private class TestTextBlock : TextBlock { public Size Constraint => _constraint; From 9b6e2e85aa6456ac4308543574f49ced87b51d2a Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 4 Sep 2026 18:17:26 +0200 Subject: [PATCH 2/4] Invalidate the layout when LineSpacing changes LineSpacing had no case in the property change switch and is not one of the properties registered with AffectsRender, so changing it left the measured size and the rendered text untouched even though CreateTextLayout feeds it into the paragraph properties. It changes line placement rather than shaping, so it belongs with LineHeight and the other properties that keep the run cache. Co-Authored-By: Claude Opus 5 --- src/Avalonia.Controls/TextBlock.cs | 1 + .../TextBlockTests.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index c69b8502377..8fcd93d5041 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -916,6 +916,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang case nameof(TextAlignment): case nameof(Padding): case nameof(LineHeight): + case nameof(LineSpacing): case nameof(MaxLines): { InvalidateTextLayoutKeepCache(); diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index 3122a54b3fe..3bf779d3f2b 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -735,6 +735,24 @@ public void Should_Remeasure_Embedded_Controls_When_The_Child_Invalidates_Its_Me Assert.True(target.DesiredSize.Width > before.Width, $"before {before}, after {target.DesiredSize}"); } + [Fact] + public void Changing_LineSpacing_Should_Invalidate_Measure() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Text = "Hello World\nHello World" }; + + target.Measure(new Size(1000, 1000)); + + var before = target.DesiredSize; + + target.LineSpacing = 20; + + target.Measure(new Size(1000, 1000)); + + Assert.True(target.DesiredSize.Height > before.Height, $"before {before}, after {target.DesiredSize}"); + } + private class TestTextBlock : TextBlock { public Size Constraint => _constraint; From e6d3fd3e28c6ef444e48a7c7c8d010e5d12c4b9f Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Sat, 5 Sep 2026 12:05:34 +0200 Subject: [PATCH 3/4] Drop the text layout whenever the content is invalidated InvalidateMeasure only raises OnMeasureInvalidated while the measure is still valid, so a second content change before the next measure pass left _textLayout holding the layout the first change had already replaced. MeasureOverride keeps that layout when the constraint has not moved, so the block measured and rendered the superseded content. Clear the layout in InvalidateTextLayout and InvalidateTextLayoutKeepCache rather than relying on OnMeasureInvalidated to run, which is what TextPresenter already does. Co-Authored-By: Claude Opus 5 --- src/Avalonia.Controls/TextBlock.cs | 14 +++++++++- .../TextBlockTests.cs | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 8fcd93d5041..00c45899655 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -715,6 +715,7 @@ protected void InvalidateTextLayout() { _textRunCache?.Invalidate(); _textRuns = null; + DisposeTextLayout(); InvalidateVisual(); InvalidateMeasure(); } @@ -725,14 +726,25 @@ protected void InvalidateTextLayout() /// private void InvalidateTextLayoutKeepCache() { + DisposeTextLayout(); InvalidateVisual(); InvalidateMeasure(); } - protected override void OnMeasureInvalidated() + /// + /// 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. + /// + private void DisposeTextLayout() { _textLayout?.Dispose(); _textLayout = null; + } + + protected override void OnMeasureInvalidated() + { + DisposeTextLayout(); base.OnMeasureInvalidated(); } diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index 3bf779d3f2b..8a529e19388 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -753,6 +753,32 @@ public void Changing_LineSpacing_Should_Invalidate_Measure() Assert.True(target.DesiredSize.Height > before.Height, $"before {before}, after {target.DesiredSize}"); } + [Fact] + public void Should_Shape_The_Latest_Inlines_When_Content_Changes_Twice_Before_Measure() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var target = new TextBlock { Inlines = new InlineCollection { new Run("Hello World") } }; + + target.Measure(new Size(1000, 1000)); + target.Arrange(new Rect(0, 0, 1000, 1000)); + + target.Inlines = new InlineCollection { new Run("A") }; + + // A render pass builds the layout from the first change, before the queued measure. + _ = target.TextLayout; + + target.Inlines = new InlineCollection { new Run("Hello World Hello World") }; + + target.Measure(new Size(1000, 1000)); + + var expected = new TextBlock { Inlines = new InlineCollection { new Run("Hello World Hello World") } }; + + expected.Measure(new Size(1000, 1000)); + + Assert.Equal(expected.DesiredSize, target.DesiredSize); + } + private class TestTextBlock : TextBlock { public Size Constraint => _constraint; From c06d26cfaa74f96c2093b1b7c3ca64edfefb92e5 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Sat, 5 Sep 2026 15:08:05 +0200 Subject: [PATCH 4/4] Drop the text layout when an embedded control resizes A line snapshots its metrics when it is formatted, so a layout built before a child was measured again keeps reporting the width and height that child used to have. MeasureOverride reuses the layout whenever the constraint has not moved, so a control that resizes while the block is already measure invalid never reaches the measured size. - MeasureEmbeddedControls reports whether any child came back a different size, and the layout is dropped only then rather than on every pass. - Route the remaining layout resets through DisposeTextLayout so every reset goes through one place. Co-Authored-By: Claude Opus 5 --- src/Avalonia.Controls/Documents/Inline.cs | 13 +++++---- .../Documents/InlineUIContainer.cs | 14 +++++++--- src/Avalonia.Controls/Documents/Span.cs | 8 ++++-- src/Avalonia.Controls/TextBlock.cs | 27 ++++++++++++------ .../TextBlockTests.cs | 28 +++++++++++++++++++ 5 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Controls/Documents/Inline.cs b/src/Avalonia.Controls/Documents/Inline.cs index 95cf85e47ff..db13308cdf4 100644 --- a/src/Avalonia.Controls/Documents/Inline.cs +++ b/src/Avalonia.Controls/Documents/Inline.cs @@ -70,14 +70,17 @@ public static void SetTextDecorations(Control control, TextDecorationCollection? /// /// Measures the controls this inline embeds against the width available to the block. /// + /// + /// True when a control came back a different size, so the caller can drop line metrics + /// that were measured against the old one. + /// /// /// 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, and the run reports - /// that size live, so re-measuring the control is what resizes the line. + /// 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. /// - internal virtual void MeasureEmbeddedControls(Size blockSize) - { - } + internal virtual bool MeasureEmbeddedControls(Size blockSize) => false; internal abstract void AppendText(StringBuilder stringBuilder); diff --git a/src/Avalonia.Controls/Documents/InlineUIContainer.cs b/src/Avalonia.Controls/Documents/InlineUIContainer.cs index 59dffdace50..9ab0d85972d 100644 --- a/src/Avalonia.Controls/Documents/InlineUIContainer.cs +++ b/src/Avalonia.Controls/Documents/InlineUIContainer.cs @@ -58,13 +58,19 @@ internal override void BuildTextRun(IList textRuns) textRuns.Add(new EmbeddedControlRun(Child, CreateTextRunProperties())); } - internal override void MeasureEmbeddedControls(Size blockSize) + internal override bool MeasureEmbeddedControls(Size blockSize) { - if (_measuredWidth != blockSize.Width || !Child.IsMeasureValid) + if (_measuredWidth == blockSize.Width && Child.IsMeasureValid) { - Child.Measure(new Size(blockSize.Width, double.PositiveInfinity)); - _measuredWidth = blockSize.Width; + return false; } + + 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) diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index be785a275ee..cff4d913e79 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -46,12 +46,16 @@ internal override void BuildTextRun(IList textRuns) } } - internal override void MeasureEmbeddedControls(Size blockSize) + internal override bool MeasureEmbeddedControls(Size blockSize) { + var resized = false; + foreach (var inline in Inlines) { - inline.MeasureEmbeddedControls(blockSize); + resized |= inline.MeasureEmbeddedControls(blockSize); } + + return resized; } internal override void AppendText(StringBuilder stringBuilder) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 00c45899655..a7ef214e082 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -778,19 +778,24 @@ private protected void EnsureTextRuns() } /// - /// Measures the controls the inlines embed against the width available to the block. + /// Measures the controls the inlines embed against the width available to the block, and + /// reports whether any of them came back a different size. /// - private void MeasureEmbeddedControls(Size constraint) + private bool MeasureEmbeddedControls(Size constraint) { if (!HasComplexContent) { - return; + return false; } + var resized = false; + foreach (var inline in Inlines!) { - inline.MeasureEmbeddedControls(constraint); + resized |= inline.MeasureEmbeddedControls(constraint); } + + return resized; } protected override Size MeasureOverride(Size availableSize) @@ -808,8 +813,7 @@ 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. @@ -817,7 +821,13 @@ protected override Size MeasureOverride(Size availableSize) } EnsureTextRuns(); - MeasureEmbeddedControls(deflatedSize); + + if (MeasureEmbeddedControls(deflatedSize)) + { + // 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. var textLayout = TextLayout; @@ -840,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. diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index 8a529e19388..e6373865352 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -779,6 +779,34 @@ public void Should_Shape_The_Latest_Inlines_When_Content_Changes_Twice_Before_Me Assert.Equal(expected.DesiredSize, target.DesiredSize); } + [Fact] + public void Should_Remeasure_Embedded_Controls_When_The_Child_Changes_While_Measure_Is_Invalid() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var child = new Border { Width = 20, Height = 20 }; + var target = new TextBlock { Inlines = new InlineCollection { new InlineUIContainer(child) } }; + + target.Measure(new Size(1000, 1000)); + target.Arrange(new Rect(0, 0, 1000, 1000)); + + target.InvalidateMeasure(); + + // A render pass builds the layout while the child still has its old size. + _ = target.TextLayout; + + // The block is already measure invalid, so this raises no further invalidation on it. + child.Width = 80; + + target.Measure(new Size(1000, 1000)); + + var expected = new TextBlock { Inlines = new InlineCollection { new InlineUIContainer(new Border { Width = 80, Height = 20 }) } }; + + expected.Measure(new Size(1000, 1000)); + + Assert.Equal(expected.DesiredSize, target.DesiredSize); + } + private class TestTextBlock : TextBlock { public Size Constraint => _constraint;