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)); + } + } +}