From 60ee26ae21f26b1f55b83d019f6384affc65ac2e Mon Sep 17 00:00:00 2001 From: Artemis Li Date: Tue, 25 Nov 2025 06:58:19 +0800 Subject: [PATCH] Preserve run formatting when applying SelectionForegroundBrush in SelectableTextBlock (#20110) * Add failing test for preserving run formatting during selection * Fix selection styling to preserve original text formatting in SelectableTextBlock * fix: apply old logic when `_textRuns` is null --- src/Avalonia.Controls/SelectableTextBlock.cs | 50 ++++++++++++++-- .../SelectableTextBlockTests.cs | 59 +++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs diff --git a/src/Avalonia.Controls/SelectableTextBlock.cs b/src/Avalonia.Controls/SelectableTextBlock.cs index 56eade0cd4..55321655cc 100644 --- a/src/Avalonia.Controls/SelectableTextBlock.cs +++ b/src/Avalonia.Controls/SelectableTextBlock.cs @@ -197,7 +197,7 @@ namespace Avalonia.Controls LineSpacing = LineSpacing }; - IReadOnlyList>? textStyleOverrides = null; + List>? textStyleOverrides = null; var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; var start = Math.Min(selectionStart, selectionEnd); @@ -205,12 +205,52 @@ namespace Avalonia.Controls if (length > 0 && SelectionForegroundBrush != null) { - textStyleOverrides = new[] + if (_textRuns != null) { + // Apply selection foreground color without changing the original text formatting. + // The built-in SelectableTextBlock selection logic recreates TextRunProperties, + // which overwrites run-specific Typeface/FontFeatures/FontSize and breaks bold/italic. + // Here we reuse each run's existing properties and only override the foreground brush. + + var accumulatedLength = 0; + foreach (var textRun in _textRuns) + { + var runLength = textRun.Text.Length; + if (accumulatedLength + runLength <= start || + accumulatedLength >= start + length) + { + accumulatedLength += runLength; + continue; + } + + var overlapStart = Math.Max(start, accumulatedLength); + var overlapEnd = Math.Min(start + length, accumulatedLength + runLength); + var overlapLength = overlapEnd - overlapStart; + + textStyleOverrides ??= []; + + textStyleOverrides.Add( + new ValueSpan( + overlapStart, + overlapLength, + new GenericTextRunProperties( + textRun.Properties?.Typeface ?? typeface, + textRun.Properties?.FontFeatures ?? FontFeatures, + FontSize, + foregroundBrush: SelectionForegroundBrush))); + + accumulatedLength += runLength; + } + } + else + { + textStyleOverrides = + [ new ValueSpan(start, length, - new GenericTextRunProperties(typeface, FontFeatures, FontSize, - foregroundBrush: SelectionForegroundBrush)) - }; + new GenericTextRunProperties(typeface, FontFeatures, FontSize, + foregroundBrush: SelectionForegroundBrush)) + ]; + } } ITextSource textSource; diff --git a/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs new file mode 100644 index 0000000000..d7425c27d5 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/SelectableTextBlockTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class SelectableTextBlockTests : ScopedTestBase + { + [Fact] + public void SelectionForeground_Should_Not_Reset_Run_Typeface_And_Style() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var target = new SelectableTextBlock + { + SelectionForegroundBrush = Brushes.Red + }; + + var run = new Run("Hello") + { + FontWeight = FontWeight.Bold, + FontStyle = FontStyle.Italic, + FontSize = 20 + }; + + target.Inlines.Add(run); + + target.Measure(Size.Infinity); + + target.SelectionStart = 0; + target.SelectionEnd = run.Text.Length; + + target.Measure(Size.Infinity); + + var textLayout = target.TextLayout; + Assert.NotNull(textLayout); + + var textRuns = textLayout.TextLines + .SelectMany(l => l.TextRuns) + .OfType() + .ToList(); + + Assert.NotEmpty(textRuns); + + var selectedRun = textRuns[0]; + var props = selectedRun.Properties; + + Assert.Equal(FontWeight.Bold, props.Typeface.Weight); + Assert.Equal(FontStyle.Italic, props.Typeface.Style); + + Assert.Same(target.SelectionForegroundBrush, props.ForegroundBrush); + } + } + + } +}