From e4bd01d461ec8c33f66e4da9e53df697f82d13b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 12:58:28 +0200 Subject: [PATCH 01/11] Extract the tab stop calculation into TabStop TextEditorOptions.GetIndentationString already knew how far away the next tab stop is. The tab rendering that follows needs the same answer, so give both one definition to call. Co-Authored-By: Claude Opus 5 (1M context) --- src/AvaloniaEdit/TextEditorOptions.cs | 3 +- src/AvaloniaEdit/Utils/TabStop.cs | 15 +++++++++ test/AvaloniaEdit.Tests/Utils/TabStopTests.cs | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/AvaloniaEdit/Utils/TabStop.cs create mode 100644 test/AvaloniaEdit.Tests/Utils/TabStopTests.cs diff --git a/src/AvaloniaEdit/TextEditorOptions.cs b/src/AvaloniaEdit/TextEditorOptions.cs index 76c609f4..936f2026 100644 --- a/src/AvaloniaEdit/TextEditorOptions.cs +++ b/src/AvaloniaEdit/TextEditorOptions.cs @@ -21,6 +21,7 @@ using System.ComponentModel; using System.Reflection; using AvaloniaEdit.CodeCompletion; +using AvaloniaEdit.Utils; namespace AvaloniaEdit { @@ -390,7 +391,7 @@ public virtual string GetIndentationString(int column) int indentationSize = IndentationSize; if (ConvertTabsToSpaces) { - return new string(' ', indentationSize - ((column - 1) % indentationSize)); + return new string(' ', TabStop.ColumnsUntilNext(column - 1, indentationSize)); } else { diff --git a/src/AvaloniaEdit/Utils/TabStop.cs b/src/AvaloniaEdit/Utils/TabStop.cs new file mode 100644 index 00000000..580ed8f6 --- /dev/null +++ b/src/AvaloniaEdit/Utils/TabStop.cs @@ -0,0 +1,15 @@ +namespace AvaloniaEdit.Utils +{ + internal static class TabStop + { + /// + /// Gets the number of columns between and the next tab stop. + /// + /// The zero-based column a tab starts at. + /// The number of columns between two tab stops. + public static int ColumnsUntilNext(int column, int indentationSize) + { + return indentationSize - column % indentationSize; + } + } +} diff --git a/test/AvaloniaEdit.Tests/Utils/TabStopTests.cs b/test/AvaloniaEdit.Tests/Utils/TabStopTests.cs new file mode 100644 index 00000000..670477bf --- /dev/null +++ b/test/AvaloniaEdit.Tests/Utils/TabStopTests.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace AvaloniaEdit.Utils +{ + [TestFixture] + public class TabStopTests + { + [Test] + public void A_Column_On_A_Tab_Stop_Reaches_The_Next_One() + { + Assert.AreEqual(4, TabStop.ColumnsUntilNext(0, 4)); + Assert.AreEqual(4, TabStop.ColumnsUntilNext(4, 4)); + Assert.AreEqual(4, TabStop.ColumnsUntilNext(8, 4)); + } + + [Test] + public void A_Column_Between_Tab_Stops_Reaches_The_Next_One() + { + Assert.AreEqual(3, TabStop.ColumnsUntilNext(1, 4)); + Assert.AreEqual(2, TabStop.ColumnsUntilNext(2, 4)); + Assert.AreEqual(1, TabStop.ColumnsUntilNext(3, 4)); + } + + [Test] + public void Every_Column_Is_A_Tab_Stop_When_The_Indentation_Size_Is_One() + { + Assert.AreEqual(1, TabStop.ColumnsUntilNext(0, 1)); + Assert.AreEqual(1, TabStop.ColumnsUntilNext(1, 1)); + Assert.AreEqual(1, TabStop.ColumnsUntilNext(2, 1)); + } + } +} From b8f68573a12737ce8581ad25db2acab61dcdaf79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:01:51 +0200 Subject: [PATCH 02/11] Move tab rendering into its own element generator Tab width is layout, not decoration, so it is about to stop depending on ShowTabs. Give tabs their own generator first, while the behaviour is still identical, so that the change of behaviour arrives in a file that does nothing else. SingleCharacterElementGenerator keeps the two decorations it is named for. It has to keep excluding tabs explicitly, though: a tab is a control character, and the branch that used to shadow it has moved out. Co-Authored-By: Claude Opus 5 (1M context) --- .../SingleCharacterElementGenerator.cs | 94 ++---------- .../Rendering/TabElementGenerator.cs | 136 ++++++++++++++++++ src/AvaloniaEdit/Rendering/TextView.cs | 5 +- .../Rendering/TextViewTests.cs | 31 +++- 4 files changed, 181 insertions(+), 85 deletions(-) create mode 100644 src/AvaloniaEdit/Rendering/TabElementGenerator.cs diff --git a/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs b/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs index f5d9c1ba..6a67296b 100644 --- a/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs @@ -16,7 +16,6 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -using System; using System.Diagnostics.CodeAnalysis; using Avalonia; using Avalonia.Media; @@ -30,7 +29,7 @@ namespace AvaloniaEdit.Rendering // This class is internal because it does not need to be accessed by the user - it can be configured using TextEditorOptions. /// - /// Element generator that displays · for spaces and » for tabs and a box for control characters. + /// Element generator that displays · for spaces and a box for control characters. /// /// /// This element generator is present in every TextView by default; the enabled features can be configured using the @@ -44,11 +43,6 @@ internal sealed class SingleCharacterElementGenerator : VisualLineElementGenerat /// public bool ShowSpaces { get; set; } - /// - /// Gets/Sets whether to show » for tabs. - /// - public bool ShowTabs { get; set; } - /// /// Gets/Sets whether to show a box with the hex code for control characters. /// @@ -60,17 +54,23 @@ internal sealed class SingleCharacterElementGenerator : VisualLineElementGenerat public SingleCharacterElementGenerator() { ShowSpaces = true; - ShowTabs = true; ShowBoxForControlCharacters = true; } void IBuiltinElementGenerator.FetchOptions(TextEditorOptions options) { ShowSpaces = options.ShowSpaces; - ShowTabs = options.ShowTabs; ShowBoxForControlCharacters = options.ShowBoxForControlCharacters; } + /// + /// Tabs are control characters, but they are laid out by . + /// + private bool ShowsBoxFor(char c) + { + return ShowBoxForControlCharacters && char.IsControl(c) && c != '\t'; + } + public override int GetFirstInterestedOffset(int startOffset) { var endLine = CurrentContext.VisualLine.LastDocumentLine; @@ -83,12 +83,8 @@ public override int GetFirstInterestedOffset(int startOffset) if (ShowSpaces) return startOffset + i; break; - case '\t': - if (ShowTabs) - return startOffset + i; - break; default: - if (ShowBoxForControlCharacters && char.IsControl(c)) { + if (ShowsBoxFor(c)) { return startOffset + i; } break; @@ -110,16 +106,7 @@ public override VisualLineElement ConstructElement(int offset) return new SpaceTextElement(textLine); } - if (ShowTabs && (c == '\t')) - { - var properties = new VisualLineElementTextRunProperties(CurrentContext.GlobalTextRunProperties); - properties.SetForegroundBrush(CurrentContext.TextView.NonPrintableCharacterBrush); - var textSource = new SimpleTextSource(CurrentContext.TextView.Options.ShowTabsGlyph, properties); - var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.MaxValue, new GenericTextParagraphProperties(properties)); - return new TabTextElement(textLine); - } - - if (ShowBoxForControlCharacters && char.IsControl(c)) + if (ShowsBoxFor(c)) { var properties = new VisualLineElementTextRunProperties(CurrentContext.GlobalTextRunProperties); properties.SetForegroundBrush(Brushes.White); @@ -151,65 +138,6 @@ public override bool IsWhitespace(int visualColumn) } } - private sealed class TabTextElement : VisualLineElement - { - internal readonly TextLine Text; - - public TabTextElement(TextLine text) : base(2, 1) - { - Text = text; - } - - public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) - { - // the TabTextElement consists of two TextRuns: - // first a TabGlyphRun, then TextCharacters '\t' to let WPF handle the tab indentation - if (startVisualColumn == VisualColumn) - return new TabGlyphRun(this, TextRunProperties); - else if (startVisualColumn == VisualColumn + 1) - return new TextCharacters("\t".AsMemory(), TextRunProperties); - else - throw new ArgumentOutOfRangeException(nameof(startVisualColumn)); - } - - public override int GetNextCaretPosition(int visualColumn, LogicalDirection direction, CaretPositioningMode mode) - { - if (mode == CaretPositioningMode.Normal || mode == CaretPositioningMode.EveryCodepoint) - return base.GetNextCaretPosition(visualColumn, direction, mode); - else - return -1; - } - - public override bool IsWhitespace(int visualColumn) - { - return true; - } - } - - private sealed class TabGlyphRun : DrawableTextRun - { - private readonly TabTextElement _element; - - public TabGlyphRun(TabTextElement element, TextRunProperties properties) - { - if (properties == null) - throw new ArgumentNullException(nameof(properties)); - Properties = properties; - _element = element; - } - - public override TextRunProperties Properties { get; } - - public override double Baseline => _element.Text.Baseline; - - public override Size Size => default; - - public override void Draw(DrawingContext drawingContext, Point origin) - { - _element.Text.Draw(drawingContext, origin); - } - } - private sealed class SpecialCharacterBoxElement : FormattedTextElement { public SpecialCharacterBoxElement(TextLine text) : base(text, 1) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs new file mode 100644 index 00000000..9331d71d --- /dev/null +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -0,0 +1,136 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using AvaloniaEdit.Document; +using LogicalDirection = AvaloniaEdit.Document.LogicalDirection; + +namespace AvaloniaEdit.Rendering +{ + // This class is internal because it does not need to be accessed by the user - it can be configured using TextEditorOptions. + + /// + /// Element generator for tab characters. + /// + internal sealed class TabElementGenerator : VisualLineElementGenerator, IBuiltinElementGenerator + { + /// + /// Gets/Sets whether to show » for tabs. + /// + public bool ShowTabs { get; set; } + + public TabElementGenerator() + { + ShowTabs = true; + } + + void IBuiltinElementGenerator.FetchOptions(TextEditorOptions options) + { + ShowTabs = options.ShowTabs; + } + + public override int GetFirstInterestedOffset(int startOffset) + { + if (!ShowTabs) + return -1; + + var endLine = CurrentContext.VisualLine.LastDocumentLine; + var relevantText = CurrentContext.GetText(startOffset, endLine.EndOffset - startOffset); + + for (var i = 0; i < relevantText.Count; i++) { + if (relevantText.Text[relevantText.Offset + i] == '\t') + return startOffset + i; + } + return -1; + } + + public override VisualLineElement ConstructElement(int offset) + { + if (!ShowTabs || CurrentContext.Document.GetCharAt(offset) != '\t') + return null; + + var properties = new VisualLineElementTextRunProperties(CurrentContext.GlobalTextRunProperties); + properties.SetForegroundBrush(CurrentContext.TextView.NonPrintableCharacterBrush); + var textSource = new SimpleTextSource(CurrentContext.TextView.Options.ShowTabsGlyph, properties); + var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.MaxValue, new GenericTextParagraphProperties(properties)); + return new TabTextElement(textLine); + } + + private sealed class TabTextElement : VisualLineElement + { + internal readonly TextLine Text; + + public TabTextElement(TextLine text) : base(2, 1) + { + Text = text; + } + + public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) + { + // the TabTextElement consists of two TextRuns: + // first a TabGlyphRun, then TextCharacters '\t' to let WPF handle the tab indentation + if (startVisualColumn == VisualColumn) + return new TabGlyphRun(this, TextRunProperties); + else if (startVisualColumn == VisualColumn + 1) + return new TextCharacters("\t".AsMemory(), TextRunProperties); + else + throw new ArgumentOutOfRangeException(nameof(startVisualColumn)); + } + + public override int GetNextCaretPosition(int visualColumn, LogicalDirection direction, CaretPositioningMode mode) + { + if (mode == CaretPositioningMode.Normal || mode == CaretPositioningMode.EveryCodepoint) + return base.GetNextCaretPosition(visualColumn, direction, mode); + else + return -1; + } + + public override bool IsWhitespace(int visualColumn) + { + return true; + } + } + + private sealed class TabGlyphRun : DrawableTextRun + { + private readonly TabTextElement _element; + + public TabGlyphRun(TabTextElement element, TextRunProperties properties) + { + if (properties == null) + throw new ArgumentNullException(nameof(properties)); + Properties = properties; + _element = element; + } + + public override TextRunProperties Properties { get; } + + public override double Baseline => _element.Text.Baseline; + + public override Size Size => default; + + public override void Draw(DrawingContext drawingContext, Point origin) + { + _element.Text.Draw(drawingContext, origin); + } + } + } +} diff --git a/src/AvaloniaEdit/Rendering/TextView.cs b/src/AvaloniaEdit/Rendering/TextView.cs index 122d9f36..a08a2f3f 100644 --- a/src/AvaloniaEdit/Rendering/TextView.cs +++ b/src/AvaloniaEdit/Rendering/TextView.cs @@ -288,6 +288,8 @@ private void LineTransformer_Removed(IVisualLineTransformer lineTransformer) // NewLineElementGenerator newLineElementGenerator; private SingleCharacterElementGenerator _singleCharacterElementGenerator; + private TabElementGenerator _tabElementGenerator; + private LinkElementGenerator _linkElementGenerator; private MailLinkElementGenerator _mailLinkElementGenerator; @@ -296,7 +298,8 @@ private void UpdateBuiltinElementGeneratorsFromOptions() var options = Options; // AddRemoveDefaultElementGeneratorOnDemand(ref newLineElementGenerator, options.ShowEndOfLine); - AddRemoveDefaultElementGeneratorOnDemand(ref _singleCharacterElementGenerator, options.ShowBoxForControlCharacters || options.ShowSpaces || options.ShowTabs); + AddRemoveDefaultElementGeneratorOnDemand(ref _singleCharacterElementGenerator, options.ShowBoxForControlCharacters || options.ShowSpaces); + AddRemoveDefaultElementGeneratorOnDemand(ref _tabElementGenerator, options.ShowTabs); AddRemoveDefaultElementGeneratorOnDemand(ref _linkElementGenerator, options.EnableHyperlinks); AddRemoveDefaultElementGeneratorOnDemand(ref _mailLinkElementGenerator, options.EnableEmailHyperlinks); } diff --git a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs index 991e6e63..5e186781 100644 --- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs +++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs @@ -1,6 +1,8 @@ -using Avalonia; +using System.Text; +using Avalonia; using Avalonia.Controls.Primitives; using Avalonia.Headless.NUnit; +using Avalonia.Media.TextFormatting; using AvaloniaEdit.Document; using AvaloniaEdit.Rendering; using Assert = NUnit.Framework.Legacy.ClassicAssert; @@ -52,5 +54,32 @@ public void Visual_Line_Should_Create_One_Text_Lines_When_Not_Wrapping() Assert.AreEqual(1, visualLine.TextLines.Count); Assert.AreEqual("hello world", new string(visualLine.TextLines[0].TextRuns[0].Text.Span)); } + + [AvaloniaTest] + public void Tab_Should_Not_Be_Rendered_As_A_Control_Character_Box() + { + TextView textView = new TextView + { + Document = new TextDocument("a\tb".ToCharArray()), + Width = HeadlessGlyphAdvance * 500 + }; + + ((ILogicalScrollable)textView).CanHorizontallyScroll = false; + textView.Measure(Size.Infinity); + + VisualLine visualLine = textView.GetOrConstructVisualLine(textView.Document.Lines[0]); + + Assert.AreEqual("a\tb", RenderedText(visualLine)); + } + + private static string RenderedText(VisualLine visualLine) + { + StringBuilder text = new StringBuilder(); + + foreach (TextRun textRun in visualLine.TextLines[0].TextRuns) + text.Append(textRun.Text.Span); + + return text.ToString(); + } } } From 82b007b80a67ca55120fcac8a2dfac9f98ba2ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:02:58 +0200 Subject: [PATCH 03/11] Track the display column while building a visual line A tab's width depends on the column it starts at, so that column has to be known while the elements are being built. Carry it forward as they are added, the way the text is laid out, rather than deriving it backwards from the document: elements can render text of a different length than they occupy, and a visual line can span several document lines. Nothing reads it yet, so behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- src/AvaloniaEdit/Rendering/VisualLine.cs | 18 +++++++++++++++--- .../Rendering/VisualLineElement.cs | 9 ++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/VisualLine.cs b/src/AvaloniaEdit/Rendering/VisualLine.cs index 4257a844..c8fd8273 100644 --- a/src/AvaloniaEdit/Rendering/VisualLine.cs +++ b/src/AvaloniaEdit/Rendering/VisualLine.cs @@ -106,6 +106,12 @@ public ReadOnlyCollection TextLines /// public int VisualLength { get; private set; } + /// + /// Gets the display column at which the element currently being constructed starts. + /// Only meaningful to element generators, while they are building this visual line. + /// + internal int CurrentDisplayColumn { get; private set; } + /// /// Length in visual line coordinates including the end of line marker, if TextEditorOptions.ShowEndOfLine is enabled. /// @@ -172,7 +178,7 @@ void PerformVisualElementConstruction(IReadOnlyList // for long lines, do not run the element generators for performance reasons if (lineLength > LENGTH_LIMIT) { - _elements.Add(new VisualLineText(this, lineLength)); + AddElement(new VisualLineText(this, lineLength)); return; } @@ -197,7 +203,7 @@ void PerformVisualElementConstruction(IReadOnlyList { var textPieceLength = textPieceEndOffset - offset; - _elements.Add(new VisualLineText(this, textPieceLength)); + AddElement(new VisualLineText(this, textPieceLength)); offset = textPieceEndOffset; } @@ -211,7 +217,7 @@ void PerformVisualElementConstruction(IReadOnlyList var element = g.ConstructElement(offset); if (element != null) { - _elements.Add(element); + AddElement(element); if (element.DocumentLength > 0) { // a non-zero-length element was constructed @@ -237,6 +243,12 @@ void PerformVisualElementConstruction(IReadOnlyList } } + private void AddElement(VisualLineElement element) + { + _elements.Add(element); + CurrentDisplayColumn += element.DisplayColumnLength; + } + private void CalculateOffsets() { var visualOffset = 0; diff --git a/src/AvaloniaEdit/Rendering/VisualLineElement.cs b/src/AvaloniaEdit/Rendering/VisualLineElement.cs index 336183b6..7af6d26f 100644 --- a/src/AvaloniaEdit/Rendering/VisualLineElement.cs +++ b/src/AvaloniaEdit/Rendering/VisualLineElement.cs @@ -56,7 +56,14 @@ protected VisualLineElement(int visualLength, int documentLength) /// Gets the length of this element in the text document. /// public int DocumentLength { get; private set; } - + + /// + /// Gets the number of columns this element occupies on screen, assuming a monospaced typeface. + /// Elements that render text longer or shorter than should override + /// this, so that a tab following them reaches the correct tab stop. + /// + public virtual int DisplayColumnLength => VisualLength; + /// /// Gets the visual column where this element starts. /// From 5347cb67e2f820b1fd1727db22264d982eb5a0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:11:40 +0200 Subject: [PATCH 04/11] Align tabs to the next tab stop A tab advanced by a fixed width instead of reaching the next multiple of the indentation size, so text after a tab only lined up when it happened to start on a tab stop already. Trailing comments drifted one column per line and snapped back every fourth line. The text shaper gives every '\t' glyph the same advance (TextShaperOptions.IncrementalTabWidth) and knows nothing about the column the tab starts at, which is by design there. So expand the tab here instead: the element now spans the columns up to the next tab stop, drawing the glyph in the first one and padding the rest. The glyph run is one column wide rather than as wide as the glyph, so a wide ShowTabsGlyph still cannot stretch a tab (#206, #207), and a tab occupies the same columns whether or not the glyph is shown. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Ollie Blanks --- .../Rendering/TabElementGenerator.cs | 53 ++++++++++----- .../Rendering/TextViewTests.cs | 67 ++++++++++++++++++- 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs index 9331d71d..f89ae23e 100644 --- a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -21,6 +21,7 @@ using Avalonia.Media; using Avalonia.Media.TextFormatting; using AvaloniaEdit.Document; +using AvaloniaEdit.Utils; using LogicalDirection = AvaloniaEdit.Document.LogicalDirection; namespace AvaloniaEdit.Rendering @@ -37,6 +38,8 @@ internal sealed class TabElementGenerator : VisualLineElementGenerator, IBuiltin /// public bool ShowTabs { get; set; } + private ReadOnlyMemory _padding; + public TabElementGenerator() { ShowTabs = true; @@ -71,28 +74,43 @@ public override VisualLineElement ConstructElement(int offset) properties.SetForegroundBrush(CurrentContext.TextView.NonPrintableCharacterBrush); var textSource = new SimpleTextSource(CurrentContext.TextView.Options.ShowTabsGlyph, properties); var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.MaxValue, new GenericTextParagraphProperties(properties)); - return new TabTextElement(textLine); + + var columnsUntilNextTabStop = TabStop.ColumnsUntilNext( + CurrentContext.VisualLine.CurrentDisplayColumn, + CurrentContext.TextView.Options.IndentationSize); + + return new TabTextElement(textLine, GetPadding(columnsUntilNextTabStop)); + } + + private ReadOnlyMemory GetPadding(int columnCount) + { + if (_padding.Length < columnCount) + _padding = new string(' ', columnCount).AsMemory(); + return _padding.Slice(0, columnCount); } private sealed class TabTextElement : VisualLineElement { - internal readonly TextLine Text; + private readonly TextLine _glyph; + private readonly ReadOnlyMemory _padding; - public TabTextElement(TextLine text) : base(2, 1) + public TabTextElement(TextLine glyph, ReadOnlyMemory padding) : base(padding.Length, 1) { - Text = text; + _glyph = glyph; + _padding = padding; } public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) { - // the TabTextElement consists of two TextRuns: - // first a TabGlyphRun, then TextCharacters '\t' to let WPF handle the tab indentation - if (startVisualColumn == VisualColumn) - return new TabGlyphRun(this, TextRunProperties); - else if (startVisualColumn == VisualColumn + 1) - return new TextCharacters("\t".AsMemory(), TextRunProperties); - else + // the glyph is drawn in the first column, the remaining columns are padded up to the tab stop + var column = startVisualColumn - VisualColumn; + if (column < 0 || column >= VisualLength) throw new ArgumentOutOfRangeException(nameof(startVisualColumn)); + + if (column == 0) + return new TabGlyphRun(_glyph, TextRunProperties, context.TextView.WideSpaceWidth); + + return new TextCharacters(_padding.Slice(column), TextRunProperties); } public override int GetNextCaretPosition(int visualColumn, LogicalDirection direction, CaretPositioningMode mode) @@ -111,25 +129,26 @@ public override bool IsWhitespace(int visualColumn) private sealed class TabGlyphRun : DrawableTextRun { - private readonly TabTextElement _element; + private readonly TextLine _glyph; - public TabGlyphRun(TabTextElement element, TextRunProperties properties) + public TabGlyphRun(TextLine glyph, TextRunProperties properties, double width) { if (properties == null) throw new ArgumentNullException(nameof(properties)); Properties = properties; - _element = element; + _glyph = glyph; + Size = new Size(width, 0); } public override TextRunProperties Properties { get; } - public override double Baseline => _element.Text.Baseline; + public override double Baseline => _glyph.Baseline; - public override Size Size => default; + public override Size Size { get; } public override void Draw(DrawingContext drawingContext, Point origin) { - _element.Text.Draw(drawingContext, origin); + _glyph.Draw(drawingContext, origin); } } } diff --git a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs index 5e186781..bce017fe 100644 --- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs +++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs @@ -1,7 +1,8 @@ -using System.Text; +using System.Text; using Avalonia; using Avalonia.Controls.Primitives; using Avalonia.Headless.NUnit; +using Avalonia.Media; using Avalonia.Media.TextFormatting; using AvaloniaEdit.Document; using AvaloniaEdit.Rendering; @@ -13,13 +14,13 @@ internal class TextViewTests { // https://github.com/AvaloniaUI/Avalonia/blob/master/src/Headless/Avalonia.Headless/HeadlessPlatformStubs.cs#L126 private const int HeadlessGlyphAdvance = 8; - + [AvaloniaTest] public void Visual_Line_Should_Create_Two_Text_Lines_When_Wrapping() { TextView textView = new TextView(); - TextDocument document = new TextDocument("hello world".ToCharArray()); + TextDocument document = new TextDocument("hello world".ToCharArray()); textView.Document = document; @@ -72,6 +73,66 @@ public void Tab_Should_Not_Be_Rendered_As_A_Control_Character_Box() Assert.AreEqual("a\tb", RenderedText(visualLine)); } + [AvaloniaTest] + public void Tab_Should_Reach_The_Next_Tab_Stop() + { + TextView textView = CreateTextView("a\tb\naaa\tb\nabcd\tb"); + + Assert.AreEqual(4, GetColumnAfterLastTab(textView, lineIndex: 0), + "a tab starting at column 1 should reach column 4"); + Assert.AreEqual(4, GetColumnAfterLastTab(textView, lineIndex: 1), + "a tab starting at column 3 should reach column 4"); + Assert.AreEqual(8, GetColumnAfterLastTab(textView, lineIndex: 2), + "a tab starting on a tab stop should reach the next one"); + } + + [AvaloniaTest] + public void Consecutive_Tabs_Should_Reach_Consecutive_Tab_Stops() + { + TextView textView = CreateTextView("a\t\tb"); + + Assert.AreEqual(8, GetColumnAfterLastTab(textView, lineIndex: 0)); + } + + [AvaloniaTest] + public void Tab_Should_Be_As_Wide_On_Screen_As_The_Columns_It_Spans() + { + TextView textView = CreateTextView("a\tb"); + + VisualLine visualLine = textView.GetOrConstructVisualLine(textView.Document.Lines[0]); + + Assert.AreEqual(textView.WideSpaceWidth * 4, + visualLine.TextLines[0].GetDistanceFromCharacterHit(new CharacterHit(4))); + } + + private static TextView CreateTextView(string text) + { + TextView textView = new TextView + { + Document = new TextDocument(text.ToCharArray()), + Width = HeadlessGlyphAdvance * 500 + }; + textView.Options.IndentationSize = 4; + textView.Options.ShowTabs = true; + + ((ILogicalScrollable)textView).CanHorizontallyScroll = false; + textView.Measure(Size.Infinity); + + return textView; + } + + /// + /// Gets the visual column at which the text after the last tab of the line starts. + /// + private static int GetColumnAfterLastTab(TextView textView, int lineIndex) + { + DocumentLine line = textView.Document.Lines[lineIndex]; + VisualLine visualLine = textView.GetOrConstructVisualLine(line); + int lastTab = textView.Document.GetText(line).LastIndexOf('\t'); + + return visualLine.GetVisualColumn(lastTab + 1); + } + private static string RenderedText(VisualLine visualLine) { StringBuilder text = new StringBuilder(); From e36ceca82a297e200a9bf8036effd9392b825d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:15:07 +0200 Subject: [PATCH 05/11] Report the display width of foldings and control character boxes Both render more than one column of text while occupying a single visual column, so a tab following them on the same visual line was measured from the wrong place: a collapsed folding showing "..." counted as one column, and so did a box showing "NUL". Co-Authored-By: Claude Opus 5 (1M context) --- .../Folding/FoldingElementGenerator.cs | 7 ++- .../SingleCharacterElementGenerator.cs | 10 ++- .../Rendering/TextViewTests.cs | 63 ++++++++++++++++++- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/AvaloniaEdit/Folding/FoldingElementGenerator.cs b/src/AvaloniaEdit/Folding/FoldingElementGenerator.cs index 81ada05f..0c876867 100644 --- a/src/AvaloniaEdit/Folding/FoldingElementGenerator.cs +++ b/src/AvaloniaEdit/Folding/FoldingElementGenerator.cs @@ -156,7 +156,7 @@ public override VisualLineElement ConstructElement(int offset) var properties = new VisualLineElementTextRunProperties(CurrentContext.GlobalTextRunProperties); properties.SetForegroundBrush(TextBrush); var text = TextFormatter.Current.FormatLine(new SimpleTextSource(title, properties), 0, double.MaxValue, new GenericTextParagraphProperties(properties)); - return new FoldingLineElement(foldingSection, text, foldedUntil - offset, TextBrush); + return new FoldingLineElement(foldingSection, text, foldedUntil - offset, TextBrush, title.Length); } else { return null; } @@ -167,12 +167,15 @@ private sealed class FoldingLineElement : FormattedTextElement private readonly FoldingSection _fs; private readonly IBrush _textBrush; - public FoldingLineElement(FoldingSection fs, TextLine text, int documentLength, IBrush textBrush) : base(text, documentLength) + public FoldingLineElement(FoldingSection fs, TextLine text, int documentLength, IBrush textBrush, int displayColumnLength) : base(text, documentLength) { _fs = fs; _textBrush = textBrush; + DisplayColumnLength = displayColumnLength; } + public override int DisplayColumnLength { get; } + public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) { return new FoldingLineTextRun(this, this.TextRunProperties, _textBrush); diff --git a/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs b/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs index 6a67296b..3a6e5396 100644 --- a/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/SingleCharacterElementGenerator.cs @@ -110,9 +110,10 @@ public override VisualLineElement ConstructElement(int offset) { var properties = new VisualLineElementTextRunProperties(CurrentContext.GlobalTextRunProperties); properties.SetForegroundBrush(Brushes.White); - var textSource = new SimpleTextSource(TextUtilities.GetControlCharacterName(c), properties); + var name = TextUtilities.GetControlCharacterName(c); + var textSource = new SimpleTextSource(name, properties); var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.MaxValue, new GenericTextParagraphProperties(properties)); - return new SpecialCharacterBoxElement(textLine); + return new SpecialCharacterBoxElement(textLine, name.Length); } return null; @@ -140,10 +141,13 @@ public override bool IsWhitespace(int visualColumn) private sealed class SpecialCharacterBoxElement : FormattedTextElement { - public SpecialCharacterBoxElement(TextLine text) : base(text, 1) + public SpecialCharacterBoxElement(TextLine text, int displayColumnLength) : base(text, 1) { + DisplayColumnLength = displayColumnLength; } + public override int DisplayColumnLength { get; } + public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) { return new SpecialCharacterTextRun(this, TextRunProperties); diff --git a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs index bce017fe..b98a0b72 100644 --- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs +++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs @@ -5,6 +5,8 @@ using Avalonia.Media; using Avalonia.Media.TextFormatting; using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Folding; using AvaloniaEdit.Rendering; using Assert = NUnit.Framework.Legacy.ClassicAssert; @@ -105,6 +107,36 @@ public void Tab_Should_Be_As_Wide_On_Screen_As_The_Columns_It_Spans() visualLine.TextLines[0].GetDistanceFromCharacterHit(new CharacterHit(4))); } + [AvaloniaTest] + public void Tab_After_A_Control_Character_Box_Should_Reach_The_Next_Tab_Stop() + { + // the box shows the character name, NUL, so the tab starts at column 3 + TextView textView = CreateTextView("\0\tb"); + + Assert.AreEqual(1, GetTabColumnCount(textView, lineIndex: 0)); + } + + [AvaloniaTest] + public void Tab_After_A_Collapsed_Folding_Should_Reach_The_Next_Tab_Stop() + { + TextArea textArea = new TextArea(); + TextView textView = textArea.TextView; + + textArea.Document = new TextDocument("a(\nx\n)\tb".ToCharArray()); + textView.Options.IndentationSize = 4; + textView.Options.ShowTabs = true; + textView.Width = HeadlessGlyphAdvance * 500; + + FoldingManager foldingManager = FoldingManager.Install(textArea); + // folds "(\nx\n" away, leaving "a" + "..." + ")" before the tab, so it starts at column 5 + foldingManager.CreateFolding(1, 5).IsFolded = true; + + ((ILogicalScrollable)textView).CanHorizontallyScroll = false; + textView.Measure(Size.Infinity); + + Assert.AreEqual(3, GetTabColumnCount(textView, lineIndex: 0)); + } + private static TextView CreateTextView(string text) { TextView textView = new TextView @@ -126,13 +158,38 @@ private static TextView CreateTextView(string text) /// private static int GetColumnAfterLastTab(TextView textView, int lineIndex) { - DocumentLine line = textView.Document.Lines[lineIndex]; - VisualLine visualLine = textView.GetOrConstructVisualLine(line); - int lastTab = textView.Document.GetText(line).LastIndexOf('\t'); + VisualLine visualLine = GetVisualLine(textView, lineIndex); + int lastTab = GetCoveredText(textView, visualLine).LastIndexOf('\t'); return visualLine.GetVisualColumn(lastTab + 1); } + /// + /// Gets the number of columns spanned by the first tab of the line. + /// + private static int GetTabColumnCount(TextView textView, int lineIndex) + { + VisualLine visualLine = GetVisualLine(textView, lineIndex); + int tab = GetCoveredText(textView, visualLine).IndexOf('\t'); + + return visualLine.GetVisualColumn(tab + 1) - visualLine.GetVisualColumn(tab); + } + + private static VisualLine GetVisualLine(TextView textView, int lineIndex) + { + return textView.GetOrConstructVisualLine(textView.Document.Lines[lineIndex]); + } + + /// + /// Gets the document text the visual line covers, which spans several document lines when a + /// folding between them is collapsed. Offsets into it are relative to the visual line start. + /// + private static string GetCoveredText(TextView textView, VisualLine visualLine) + { + return textView.Document.GetText(visualLine.StartOffset, + visualLine.LastDocumentLine.EndOffset - visualLine.StartOffset); + } + private static string RenderedText(VisualLine visualLine) { StringBuilder text = new StringBuilder(); From 6145f6874d99415020dda68c745fca9090483af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:16:51 +0200 Subject: [PATCH 06/11] Format the tab glyph once per redraw instead of once per tab Every tab formatted its own copy of the glyph, on every redraw. A file indented with tabs pays that several times per line. The tab generator is about to run whether or not the glyph is shown, so pay it down first. No test: the glyph is only observable through Draw, which the headless tests do not exercise. Co-Authored-By: Claude Opus 5 (1M context) --- .../Rendering/TabElementGenerator.cs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs index f89ae23e..7226e2e9 100644 --- a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -40,6 +40,10 @@ internal sealed class TabElementGenerator : VisualLineElementGenerator, IBuiltin private ReadOnlyMemory _padding; + private TextLine _glyph; + private string _glyphText; + private TextRunProperties _glyphProperties; + public TabElementGenerator() { ShowTabs = true; @@ -70,16 +74,34 @@ public override VisualLineElement ConstructElement(int offset) if (!ShowTabs || CurrentContext.Document.GetCharAt(offset) != '\t') return null; - var properties = new VisualLineElementTextRunProperties(CurrentContext.GlobalTextRunProperties); - properties.SetForegroundBrush(CurrentContext.TextView.NonPrintableCharacterBrush); - var textSource = new SimpleTextSource(CurrentContext.TextView.Options.ShowTabsGlyph, properties); - var textLine = TextFormatter.Current.FormatLine(textSource, 0, double.MaxValue, new GenericTextParagraphProperties(properties)); - var columnsUntilNextTabStop = TabStop.ColumnsUntilNext( CurrentContext.VisualLine.CurrentDisplayColumn, CurrentContext.TextView.Options.IndentationSize); - return new TabTextElement(textLine, GetPadding(columnsUntilNextTabStop)); + return new TabTextElement(GetGlyph(), GetPadding(columnsUntilNextTabStop)); + } + + /// + /// Formats the glyph, and keeps it for as long as its inputs stay the same. The global text + /// run properties are a new instance for every redraw, which is what expires it. + /// + private TextLine GetGlyph() + { + var glyphText = CurrentContext.TextView.Options.ShowTabsGlyph; + var globalProperties = CurrentContext.GlobalTextRunProperties; + + if (_glyph == null || _glyphText != glyphText || _glyphProperties != globalProperties) + { + var properties = new VisualLineElementTextRunProperties(globalProperties); + properties.SetForegroundBrush(CurrentContext.TextView.NonPrintableCharacterBrush); + var textSource = new SimpleTextSource(glyphText, properties); + + _glyph = TextFormatter.Current.FormatLine(textSource, 0, double.MaxValue, new GenericTextParagraphProperties(properties)); + _glyphText = glyphText; + _glyphProperties = globalProperties; + } + + return _glyph; } private ReadOnlyMemory GetPadding(int columnCount) From b95a3edf382f73515061a3aecbac380ee0e87c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:19:08 +0200 Subject: [PATCH 07/11] Lay tabs out whether or not the tab glyph is shown The width was wrong in both cases, and ShowTabs is about showing a glyph, not about where text lands. The generator is therefore always present, and ShowTabs now only decides whether the glyph is drawn. This changes the default rendering for every consumer, which is why it is its own commit. Two consequences worth knowing about: - visual columns after a tab change, so anything that persists TextViewPosition.VisualColumn across versions, or keys on visual columns, shifts. Toggling ShowTabs no longer shifts them, which it used to. - the generator is added on construction, so at a tab offset it now takes precedence over an element generator added later by a consumer. Co-Authored-By: Claude Opus 5 (1M context) --- .../Rendering/TabElementGenerator.cs | 18 +++++------------- src/AvaloniaEdit/Rendering/TextView.cs | 10 ++++++++-- .../Rendering/TextViewTests.cs | 13 ++++++++++--- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs index 7226e2e9..ba207dbc 100644 --- a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -31,7 +31,7 @@ namespace AvaloniaEdit.Rendering /// /// Element generator for tab characters. /// - internal sealed class TabElementGenerator : VisualLineElementGenerator, IBuiltinElementGenerator + internal sealed class TabElementGenerator : VisualLineElementGenerator { /// /// Gets/Sets whether to show » for tabs. @@ -44,21 +44,13 @@ internal sealed class TabElementGenerator : VisualLineElementGenerator, IBuiltin private string _glyphText; private TextRunProperties _glyphProperties; - public TabElementGenerator() - { - ShowTabs = true; - } - - void IBuiltinElementGenerator.FetchOptions(TextEditorOptions options) + public void FetchOptions(TextEditorOptions options) { ShowTabs = options.ShowTabs; } public override int GetFirstInterestedOffset(int startOffset) { - if (!ShowTabs) - return -1; - var endLine = CurrentContext.VisualLine.LastDocumentLine; var relevantText = CurrentContext.GetText(startOffset, endLine.EndOffset - startOffset); @@ -71,14 +63,14 @@ public override int GetFirstInterestedOffset(int startOffset) public override VisualLineElement ConstructElement(int offset) { - if (!ShowTabs || CurrentContext.Document.GetCharAt(offset) != '\t') + if (CurrentContext.Document.GetCharAt(offset) != '\t') return null; var columnsUntilNextTabStop = TabStop.ColumnsUntilNext( CurrentContext.VisualLine.CurrentDisplayColumn, CurrentContext.TextView.Options.IndentationSize); - return new TabTextElement(GetGlyph(), GetPadding(columnsUntilNextTabStop)); + return new TabTextElement(ShowTabs ? GetGlyph() : null, GetPadding(columnsUntilNextTabStop)); } /// @@ -129,7 +121,7 @@ public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructio if (column < 0 || column >= VisualLength) throw new ArgumentOutOfRangeException(nameof(startVisualColumn)); - if (column == 0) + if (column == 0 && _glyph != null) return new TabGlyphRun(_glyph, TextRunProperties, context.TextView.WideSpaceWidth); return new TextCharacters(_padding.Slice(column), TextRunProperties); diff --git a/src/AvaloniaEdit/Rendering/TextView.cs b/src/AvaloniaEdit/Rendering/TextView.cs index a08a2f3f..17484d36 100644 --- a/src/AvaloniaEdit/Rendering/TextView.cs +++ b/src/AvaloniaEdit/Rendering/TextView.cs @@ -78,6 +78,7 @@ public TextView() TextLayer = new TextLayer(this); _elementGenerators = new ObserveAddRemoveCollection(ElementGenerator_Added, ElementGenerator_Removed); + _elementGenerators.Add(_tabElementGenerator); _lineTransformers = new ObserveAddRemoveCollection(LineTransformer_Added, LineTransformer_Removed); _backgroundRenderers = new ObserveAddRemoveCollection(BackgroundRenderer_Added, BackgroundRenderer_Removed); _currentLineHighlightRenderer = new CurrentLineHighlightRenderer(this); @@ -288,7 +289,11 @@ private void LineTransformer_Removed(IVisualLineTransformer lineTransformer) // NewLineElementGenerator newLineElementGenerator; private SingleCharacterElementGenerator _singleCharacterElementGenerator; - private TabElementGenerator _tabElementGenerator; + /// + /// This one is always present: it lays tabs out, which has to happen whether or not the + /// whitespace glyphs are shown. + /// + private readonly TabElementGenerator _tabElementGenerator = new TabElementGenerator(); private LinkElementGenerator _linkElementGenerator; private MailLinkElementGenerator _mailLinkElementGenerator; @@ -297,9 +302,10 @@ private void UpdateBuiltinElementGeneratorsFromOptions() { var options = Options; + _tabElementGenerator.FetchOptions(options); + // AddRemoveDefaultElementGeneratorOnDemand(ref newLineElementGenerator, options.ShowEndOfLine); AddRemoveDefaultElementGeneratorOnDemand(ref _singleCharacterElementGenerator, options.ShowBoxForControlCharacters || options.ShowSpaces); - AddRemoveDefaultElementGeneratorOnDemand(ref _tabElementGenerator, options.ShowTabs); AddRemoveDefaultElementGeneratorOnDemand(ref _linkElementGenerator, options.EnableHyperlinks); AddRemoveDefaultElementGeneratorOnDemand(ref _mailLinkElementGenerator, options.EnableEmailHyperlinks); } diff --git a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs index b98a0b72..7835c526 100644 --- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs +++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs @@ -72,7 +72,14 @@ public void Tab_Should_Not_Be_Rendered_As_A_Control_Character_Box() VisualLine visualLine = textView.GetOrConstructVisualLine(textView.Document.Lines[0]); - Assert.AreEqual("a\tb", RenderedText(visualLine)); + Assert.AreEqual("a b", RenderedText(visualLine)); + } + + [AvaloniaTest] + public void Tab_Should_Span_The_Same_Columns_Whether_Or_Not_The_Glyph_Is_Shown() + { + Assert.AreEqual(3, GetTabColumnCount(CreateTextView("a\tb", showTabs: true), lineIndex: 0)); + Assert.AreEqual(3, GetTabColumnCount(CreateTextView("a\tb", showTabs: false), lineIndex: 0)); } [AvaloniaTest] @@ -137,7 +144,7 @@ public void Tab_After_A_Collapsed_Folding_Should_Reach_The_Next_Tab_Stop() Assert.AreEqual(3, GetTabColumnCount(textView, lineIndex: 0)); } - private static TextView CreateTextView(string text) + private static TextView CreateTextView(string text, bool showTabs = true) { TextView textView = new TextView { @@ -145,7 +152,7 @@ private static TextView CreateTextView(string text) Width = HeadlessGlyphAdvance * 500 }; textView.Options.IndentationSize = 4; - textView.Options.ShowTabs = true; + textView.Options.ShowTabs = showTabs; ((ILogicalScrollable)textView).CanHorizontallyScroll = false; textView.Measure(Size.Infinity); From ba195d0d2c3ec7fb8f2a1cafeb00f30516bb6901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:19:35 +0200 Subject: [PATCH 08/11] Find tabs with IndexOf The generator is asked for its next tab once per element it produces, and each ask scanned to the end of the line character by character, so a line of k tabs cost k passes over it. IndexOf is vectorised, which the next commit relies on: it lets these scans run on lines long enough that element generation is otherwise skipped. Co-Authored-By: Claude Opus 5 (1M context) --- src/AvaloniaEdit/Rendering/TabElementGenerator.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs index ba207dbc..10137af1 100644 --- a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -53,12 +53,9 @@ public override int GetFirstInterestedOffset(int startOffset) { var endLine = CurrentContext.VisualLine.LastDocumentLine; var relevantText = CurrentContext.GetText(startOffset, endLine.EndOffset - startOffset); + var tab = relevantText.Text.AsSpan(relevantText.Offset, relevantText.Count).IndexOf('\t'); - for (var i = 0; i < relevantText.Count; i++) { - if (relevantText.Text[relevantText.Offset + i] == '\t') - return startOffset + i; - } - return -1; + return tab < 0 ? -1 : startOffset + tab; } public override VisualLineElement ConstructElement(int offset) From 140f1ed5c20ffb3ee62c521a9afdd9bfd3a5c20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:22:25 +0200 Subject: [PATCH 09/11] Lay tabs out on lines too long for element generation Lines over LENGTH_LIMIT skip element generation and become a single text element, so their tabs still reached the shaper as raw '\t' and kept the fixed advance. Tab separated data is exactly the content that has both long lines and tabs. Generators now say whether the layout depends on them, and long lines run those instead of none. With none left to run, the loop produces the single text element the early return used to, so nothing else changes. Cost, per build of one 2999 character line: all tabs 81ms against 0.2ms plain. That is the per element cost the visual line pipeline already has, not something new here: the same line in spaces with ShowSpaces on is 46ms. A realistic viewport, 50 lines of eight tabs and code, is 2.3ms. Co-Authored-By: Claude Opus 5 (1M context) --- src/AvaloniaEdit/Rendering/TabElementGenerator.cs | 2 ++ src/AvaloniaEdit/Rendering/VisualLine.cs | 7 ++----- src/AvaloniaEdit/Rendering/VisualLineElementGenerator.cs | 9 ++++++++- test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs | 8 ++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs index 10137af1..eb515796 100644 --- a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -49,6 +49,8 @@ public void FetchOptions(TextEditorOptions options) ShowTabs = options.ShowTabs; } + public override bool RunsOnLongLines => true; + public override int GetFirstInterestedOffset(int startOffset) { var endLine = CurrentContext.VisualLine.LastDocumentLine; diff --git a/src/AvaloniaEdit/Rendering/VisualLine.cs b/src/AvaloniaEdit/Rendering/VisualLine.cs index c8fd8273..abf34ae6 100644 --- a/src/AvaloniaEdit/Rendering/VisualLine.cs +++ b/src/AvaloniaEdit/Rendering/VisualLine.cs @@ -175,12 +175,9 @@ void PerformVisualElementConstruction(IReadOnlyList LastDocumentLine = FirstDocumentLine; var askInterestOffset = 0; // 0 or 1 - // for long lines, do not run the element generators for performance reasons + // for long lines, run only the generators the layout depends on, for performance reasons if (lineLength > LENGTH_LIMIT) - { - AddElement(new VisualLineText(this, lineLength)); - return; - } + generators = generators.Where(g => g.RunsOnLongLines).ToList(); while (offset + askInterestOffset <= currentLineEnd) { diff --git a/src/AvaloniaEdit/Rendering/VisualLineElementGenerator.cs b/src/AvaloniaEdit/Rendering/VisualLineElementGenerator.cs index cdab40ba..9c196722 100644 --- a/src/AvaloniaEdit/Rendering/VisualLineElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/VisualLineElementGenerator.cs @@ -50,7 +50,14 @@ public virtual void FinishGeneration() /// Should only be used by VisualLine.ConstructVisualElements. /// internal int CachedInterest; - + + /// + /// Gets whether this generator still runs on lines longer than , + /// where generators are otherwise skipped to keep such lines cheap to build. + /// Override this only for generators the layout depends on. + /// + public virtual bool RunsOnLongLines => false; + /// /// Gets the first offset >= startOffset where the generator wants to construct an element. /// Return -1 to signal no interest. diff --git a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs index 7835c526..50a29a20 100644 --- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs +++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs @@ -114,6 +114,14 @@ public void Tab_Should_Be_As_Wide_On_Screen_As_The_Columns_It_Spans() visualLine.TextLines[0].GetDistanceFromCharacterHit(new CharacterHit(4))); } + [AvaloniaTest] + public void Tab_Should_Reach_The_Next_Tab_Stop_On_A_Line_Too_Long_For_Element_Generation() + { + TextView textView = CreateTextView("a\t" + new string('b', VisualLine.LENGTH_LIMIT)); + + Assert.AreEqual(4, GetColumnAfterLastTab(textView, lineIndex: 0)); + } + [AvaloniaTest] public void Tab_After_A_Control_Character_Box_Should_Reach_The_Next_Tab_Stop() { From 88520d3c478d938f8d40acc0b925f23d90e739c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:24:22 +0200 Subject: [PATCH 10/11] Cover caret movement across a tab A tab used to span two visual columns whatever its width. It now spans as many as it is wide, and it exists even when the glyph is hidden, so the caret has more room to get this wrong: it must step over a tab, and a column inside one must map to the tab's own offset for a click to land sensibly. This is a property of the branch as a whole rather than of one commit, which is why it is not with either of them. Co-Authored-By: Claude Opus 5 (1M context) --- .../Rendering/TextViewTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs index 50a29a20..e831778d 100644 --- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs +++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs @@ -9,6 +9,7 @@ using AvaloniaEdit.Folding; using AvaloniaEdit.Rendering; using Assert = NUnit.Framework.Legacy.ClassicAssert; +using LogicalDirection = AvaloniaEdit.Document.LogicalDirection; namespace AvaloniaEdit.Tests.Rendering { @@ -152,6 +153,29 @@ public void Tab_After_A_Collapsed_Folding_Should_Reach_The_Next_Tab_Stop() Assert.AreEqual(3, GetTabColumnCount(textView, lineIndex: 0)); } + [AvaloniaTest] + public void Caret_Should_Step_Over_A_Tab_Rather_Than_Into_It() + { + TextView textView = CreateTextView("a\tb"); + VisualLine visualLine = GetVisualLine(textView, lineIndex: 0); + + // the tab starts at column 1 and reaches column 4 + Assert.AreEqual(4, visualLine.GetNextCaretPosition(1, LogicalDirection.Forward, + CaretPositioningMode.Normal, allowVirtualSpace: false)); + Assert.AreEqual(1, visualLine.GetNextCaretPosition(4, LogicalDirection.Backward, + CaretPositioningMode.Normal, allowVirtualSpace: false)); + } + + [AvaloniaTest] + public void Columns_Inside_A_Tab_Should_Map_To_Its_Offset() + { + TextView textView = CreateTextView("a\tb"); + VisualLine visualLine = GetVisualLine(textView, lineIndex: 0); + + Assert.AreEqual(1, visualLine.GetRelativeOffset(2)); + Assert.AreEqual(1, visualLine.GetRelativeOffset(3)); + } + private static TextView CreateTextView(string text, bool showTabs = true) { TextView textView = new TextView From 796556aa10fce4e05b8b40b4e5b75d7d1dfd47c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Fri, 28 Aug 2026 13:25:15 +0200 Subject: [PATCH 11/11] Write down why tabs are expanded here and not by the formatter IncrementalTabWidth looks like the WPF property it was ported from, so the obvious conclusion is that Avalonia should align to it and this expansion is redundant. It is not: a fixed advance is what that property means there. Say so where the value is set, so the next reader does not take it upstream again, and state the monospaced assumption on the generator. Co-Authored-By: Claude Opus 5 (1M context) --- src/AvaloniaEdit/Rendering/TabElementGenerator.cs | 13 +++++++++++-- src/AvaloniaEdit/Rendering/TextView.cs | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs index eb515796..bac028ff 100644 --- a/src/AvaloniaEdit/Rendering/TabElementGenerator.cs +++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs @@ -29,8 +29,16 @@ namespace AvaloniaEdit.Rendering // This class is internal because it does not need to be accessed by the user - it can be configured using TextEditorOptions. /// - /// Element generator for tab characters. + /// Element generator for tab characters. A tab spans the columns up to the next multiple of + /// , so that text after it lines up. /// + /// + /// A column is wide, so the alignment holds for the + /// monospaced typefaces a code editor is used with, but not for proportional ones. Columns are + /// counted from the start of the visual line, so on a wrapped row they continue the line they + /// belong to rather than restarting, and + /// shifts that whole row. + /// internal sealed class TabElementGenerator : VisualLineElementGenerator { /// @@ -115,11 +123,12 @@ public TabTextElement(TextLine glyph, ReadOnlyMemory padding) : base(paddi public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) { - // the glyph is drawn in the first column, the remaining columns are padded up to the tab stop var column = startVisualColumn - VisualColumn; if (column < 0 || column >= VisualLength) throw new ArgumentOutOfRangeException(nameof(startVisualColumn)); + // one column wide rather than as wide as the glyph, so that a wide ShowTabsGlyph + // cannot stretch a tab (#206, #207) if (column == 0 && _glyph != null) return new TabGlyphRun(_glyph, TextRunProperties, context.TextView.WideSpaceWidth); diff --git a/src/AvaloniaEdit/Rendering/TextView.cs b/src/AvaloniaEdit/Rendering/TextView.cs index 17484d36..50177921 100644 --- a/src/AvaloniaEdit/Rendering/TextView.cs +++ b/src/AvaloniaEdit/Rendering/TextView.cs @@ -1046,6 +1046,11 @@ private VisualLineTextParagraphProperties CreateParagraphProperties(TextRunPrope { defaultTextRunProperties = defaultTextRunProperties, textWrapping = _canHorizontallyScroll ? TextWrapping.NoWrap : TextWrapping.Wrap, + // only applies to a '\t' that reaches the formatter, which TabElementGenerator + // normally prevents. Avalonia gives such a tab this width as a fixed advance + // instead of aligning it to a multiple of it, unlike the WPF property this is + // named after, so it does not reach a tab stop. + // See https://github.com/AvaloniaUI/AvaloniaEdit/pull/611 tabSize = Options.IndentationSize * WideSpaceWidth }; }