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 f5d9c1ba..3a6e5396 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,22 +106,14 @@ 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);
- 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;
@@ -151,71 +139,15 @@ 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)
+ 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/src/AvaloniaEdit/Rendering/TabElementGenerator.cs b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs
new file mode 100644
index 00000000..bac028ff
--- /dev/null
+++ b/src/AvaloniaEdit/Rendering/TabElementGenerator.cs
@@ -0,0 +1,177 @@
+// 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 AvaloniaEdit.Utils;
+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. 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
+ {
+ ///
+ /// Gets/Sets whether to show » for tabs.
+ ///
+ public bool ShowTabs { get; set; }
+
+ private ReadOnlyMemory _padding;
+
+ private TextLine _glyph;
+ private string _glyphText;
+ private TextRunProperties _glyphProperties;
+
+ public void FetchOptions(TextEditorOptions options)
+ {
+ ShowTabs = options.ShowTabs;
+ }
+
+ public override bool RunsOnLongLines => true;
+
+ 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');
+
+ return tab < 0 ? -1 : startOffset + tab;
+ }
+
+ public override VisualLineElement ConstructElement(int offset)
+ {
+ if (CurrentContext.Document.GetCharAt(offset) != '\t')
+ return null;
+
+ var columnsUntilNextTabStop = TabStop.ColumnsUntilNext(
+ CurrentContext.VisualLine.CurrentDisplayColumn,
+ CurrentContext.TextView.Options.IndentationSize);
+
+ return new TabTextElement(ShowTabs ? GetGlyph() : null, 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)
+ {
+ if (_padding.Length < columnCount)
+ _padding = new string(' ', columnCount).AsMemory();
+ return _padding.Slice(0, columnCount);
+ }
+
+ private sealed class TabTextElement : VisualLineElement
+ {
+ private readonly TextLine _glyph;
+ private readonly ReadOnlyMemory _padding;
+
+ public TabTextElement(TextLine glyph, ReadOnlyMemory padding) : base(padding.Length, 1)
+ {
+ _glyph = glyph;
+ _padding = padding;
+ }
+
+ public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context)
+ {
+ 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);
+
+ return new TextCharacters(_padding.Slice(column), TextRunProperties);
+ }
+
+ 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 TextLine _glyph;
+
+ public TabGlyphRun(TextLine glyph, TextRunProperties properties, double width)
+ {
+ if (properties == null)
+ throw new ArgumentNullException(nameof(properties));
+ Properties = properties;
+ _glyph = glyph;
+ Size = new Size(width, 0);
+ }
+
+ public override TextRunProperties Properties { get; }
+
+ public override double Baseline => _glyph.Baseline;
+
+ public override Size Size { get; }
+
+ public override void Draw(DrawingContext drawingContext, Point origin)
+ {
+ _glyph.Draw(drawingContext, origin);
+ }
+ }
+ }
+}
diff --git a/src/AvaloniaEdit/Rendering/TextView.cs b/src/AvaloniaEdit/Rendering/TextView.cs
index 122d9f36..50177921 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,6 +289,12 @@ private void LineTransformer_Removed(IVisualLineTransformer lineTransformer)
// NewLineElementGenerator newLineElementGenerator;
private SingleCharacterElementGenerator _singleCharacterElementGenerator;
+ ///
+ /// 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;
@@ -295,8 +302,10 @@ private void UpdateBuiltinElementGeneratorsFromOptions()
{
var options = Options;
+ _tabElementGenerator.FetchOptions(options);
+
// AddRemoveDefaultElementGeneratorOnDemand(ref newLineElementGenerator, options.ShowEndOfLine);
- AddRemoveDefaultElementGeneratorOnDemand(ref _singleCharacterElementGenerator, options.ShowBoxForControlCharacters || options.ShowSpaces || options.ShowTabs);
+ AddRemoveDefaultElementGeneratorOnDemand(ref _singleCharacterElementGenerator, options.ShowBoxForControlCharacters || options.ShowSpaces);
AddRemoveDefaultElementGeneratorOnDemand(ref _linkElementGenerator, options.EnableHyperlinks);
AddRemoveDefaultElementGeneratorOnDemand(ref _mailLinkElementGenerator, options.EnableEmailHyperlinks);
}
@@ -1037,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
};
}
diff --git a/src/AvaloniaEdit/Rendering/VisualLine.cs b/src/AvaloniaEdit/Rendering/VisualLine.cs
index 4257a844..abf34ae6 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.
///
@@ -169,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)
- {
- _elements.Add(new VisualLineText(this, lineLength));
- return;
- }
+ generators = generators.Where(g => g.RunsOnLongLines).ToList();
while (offset + askInterestOffset <= currentLineEnd)
{
@@ -197,7 +200,7 @@ void PerformVisualElementConstruction(IReadOnlyList
{
var textPieceLength = textPieceEndOffset - offset;
- _elements.Add(new VisualLineText(this, textPieceLength));
+ AddElement(new VisualLineText(this, textPieceLength));
offset = textPieceEndOffset;
}
@@ -211,7 +214,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 +240,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.
///
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/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/Rendering/TextViewTests.cs b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs
index 991e6e63..e831778d 100644
--- a/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs
+++ b/test/AvaloniaEdit.Tests/Rendering/TextViewTests.cs
@@ -1,9 +1,15 @@
-using Avalonia;
+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.Editing;
+using AvaloniaEdit.Folding;
using AvaloniaEdit.Rendering;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
+using LogicalDirection = AvaloniaEdit.Document.LogicalDirection;
namespace AvaloniaEdit.Tests.Rendering
{
@@ -11,13 +17,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;
@@ -52,5 +58,185 @@ 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 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]
+ 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)));
+ }
+
+ [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()
+ {
+ // 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));
+ }
+
+ [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
+ {
+ Document = new TextDocument(text.ToCharArray()),
+ Width = HeadlessGlyphAdvance * 500
+ };
+ textView.Options.IndentationSize = 4;
+ textView.Options.ShowTabs = showTabs;
+
+ ((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)
+ {
+ 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();
+
+ foreach (TextRun textRun in visualLine.TextLines[0].TextRuns)
+ text.Append(textRun.Text.Span);
+
+ return text.ToString();
+ }
}
}
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));
+ }
+ }
+}