Walkthrough: Outlining
Applies to: Visual Studio Visual Studio for Mac
Note
This article applies to Visual Studio 2017. If you're looking for the latest Visual Studio documentation, see Visual Studio documentation. We recommend upgrading to the latest version of Visual Studio. Download it here
Set up language-based features such as outlining by defining the kinds of text regions you want to expand or collapse. You can define regions in the context of a language service, or define your own file name extension and content type and apply the region definition to only that type, or apply the region definitions to an existing content type (such as "text"). This walkthrough shows how to define and display outlining regions.
Prerequisites
Starting in Visual Studio 2015, you don't install the Visual Studio SDK from the download center. It's included as an optional feature in Visual Studio setup. You can also install the VS SDK later on. For more information, see Install the Visual Studio SDK.
Create a Managed Extensibility Framework (MEF) project
To create a MEF project
Create a VSIX project. Name the solution
OutlineRegionTest
.Add an Editor Classifier item template to the project. For more information, see Create an extension with an editor item template.
Delete the existing class files.
Implement an outlining tagger
Outlining regions are marked by a kind of tag (OutliningRegionTag). This tag provides the standard outlining behavior. The outlined region can be expanded or collapsed. The outlined region is marked by a Plus sign (+) if it's collapsed or a Minus sign (-) if it's expanded, and the expanded region is demarcated by a vertical line.
The following steps show how to define a tagger that creates outlining regions for all the regions delimited by the brackets ([,]).
To implement an outlining tagger
Add a class file and name it
OutliningTagger
.Import the following namespaces.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.ComponentModel.Composition; using Microsoft.VisualStudio.Text.Outlining; using Microsoft.VisualStudio.Text.Tagging; using Microsoft.VisualStudio.Utilities; using Microsoft.VisualStudio.Text;
Imports System Imports System.Collections.Generic Imports System.Linq Imports System.Text Imports System.ComponentModel.Composition Imports Microsoft.VisualStudio.Text.Outlining Imports Microsoft.VisualStudio.Text.Tagging Imports Microsoft.VisualStudio.Utilities Imports Microsoft.VisualStudio.Text
Create a class named
OutliningTagger
, and have it implement ITagger<T>:internal sealed class OutliningTagger : ITagger<IOutliningRegionTag>
Friend NotInheritable Class OutliningTagger Implements ITagger(Of IOutliningRegionTag)
Add some fields to track the text buffer and snapshot and to accumulate the sets of lines that should be tagged as outlining regions. This code includes a list of Region objects (to be defined later) that represent the outlining regions.
string startHide = "["; //the characters that start the outlining region string endHide = "]"; //the characters that end the outlining region string ellipsis = "..."; //the characters that are displayed when the region is collapsed string hoverText = "hover text"; //the contents of the tooltip for the collapsed span ITextBuffer buffer; ITextSnapshot snapshot; List<Region> regions;
'the characters that start the outlining region Private startHide As String = "[" 'the characters that end the outlining region Private endHide As String = "]" 'the characters that are displayed when the region is collapsed Private ellipsis As String = "..." 'the contents of the tooltip for the collapsed span Private hoverText As String = "hover text" Private buffer As ITextBuffer Private snapshot As ITextSnapshot Private regions As List(Of Region)
Add a tagger constructor that initializes the fields, parses the buffer, and adds an event handler to the Changed event.
public OutliningTagger(ITextBuffer buffer) { this.buffer = buffer; this.snapshot = buffer.CurrentSnapshot; this.regions = new List<Region>(); this.ReParse(); this.buffer.Changed += BufferChanged; }
Public Sub New(ByVal buffer As ITextBuffer) Me.buffer = buffer Me.snapshot = buffer.CurrentSnapshot Me.regions = New List(Of Region)() Me.ReParse() AddHandler Me.buffer.Changed, AddressOf BufferChanged End Sub
Implement the GetTags method, which instantiates the tag spans. This example assumes that the spans in the NormalizedSpanCollection passed in to the method are contiguous, although it may not always be the case. This method instantiates a new tag span for each of the outlining regions.
public IEnumerable<ITagSpan<IOutliningRegionTag>> GetTags(NormalizedSnapshotSpanCollection spans) { if (spans.Count == 0) yield break; List<Region> currentRegions = this.regions; ITextSnapshot currentSnapshot = this.snapshot; SnapshotSpan entire = new SnapshotSpan(spans[0].Start, spans[spans.Count - 1].End).TranslateTo(currentSnapshot, SpanTrackingMode.EdgeExclusive); int startLineNumber = entire.Start.GetContainingLine().LineNumber; int endLineNumber = entire.End.GetContainingLine().LineNumber; foreach (var region in currentRegions) { if (region.StartLine <= endLineNumber && region.EndLine >= startLineNumber) { var startLine = currentSnapshot.GetLineFromLineNumber(region.StartLine); var endLine = currentSnapshot.GetLineFromLineNumber(region.EndLine); //the region starts at the beginning of the "[", and goes until the *end* of the line that contains the "]". yield return new TagSpan<IOutliningRegionTag>( new SnapshotSpan(startLine.Start + region.StartOffset, endLine.End), new OutliningRegionTag(false, false, ellipsis, hoverText)); } } }
Public Function GetTags(ByVal spans As NormalizedSnapshotSpanCollection) As IEnumerable(Of ITagSpan(Of IOutliningRegionTag)) Implements ITagger(Of Microsoft.VisualStudio.Text.Tagging.IOutliningRegionTag).GetTags If spans.Count = 0 Then Return Nothing Exit Function End If Dim currentRegions As List(Of Region) = Me.regions Dim currentSnapshot As ITextSnapshot = Me.snapshot Dim entire As SnapshotSpan = New SnapshotSpan(spans(0).Start, spans(spans.Count - 1).[End]).TranslateTo(currentSnapshot, SpanTrackingMode.EdgeExclusive) Dim startLineNumber As Integer = entire.Start.GetContainingLine().LineNumber Dim endLineNumber As Integer = entire.[End].GetContainingLine().LineNumber Dim list As List(Of ITagSpan(Of IOutliningRegionTag)) list = New List(Of ITagSpan(Of IOutliningRegionTag))() For Each region In currentRegions If region.StartLine <= endLineNumber AndAlso region.EndLine >= startLineNumber Then Dim startLine = currentSnapshot.GetLineFromLineNumber(region.StartLine) Dim endLine = currentSnapshot.GetLineFromLineNumber(region.EndLine) 'the region starts at the beginning of the "[", and goes until the *end* of the line that contains the "]". list.Add(New TagSpan(Of IOutliningRegionTag)(New SnapshotSpan(startLine.Start + region.StartOffset, endLine.End), New OutliningRegionTag(False, False, ellipsis, hoverText))) End If Next Return list End Function
Declare a
TagsChanged
event handler.public event EventHandler<SnapshotSpanEventArgs> TagsChanged;
Public Event TagsChanged As EventHandler(Of SnapshotSpanEventArgs) Implements ITagger(Of IOutliningRegionTag).TagsChanged
Add a
BufferChanged
event handler that responds to Changed events by parsing the text buffer.void BufferChanged(object sender, TextContentChangedEventArgs e) { // If this isn't the most up-to-date version of the buffer, then ignore it for now (we'll eventually get another change event). if (e.After != buffer.CurrentSnapshot) return; this.ReParse(); }
Private Sub BufferChanged(ByVal sender As Object, ByVal e As TextContentChangedEventArgs) ' If this isn't the most up-to-date version of the buffer, then ignore it for now (we'll eventually get another change event). If e.After IsNot buffer.CurrentSnapshot Then Exit Sub End If Me.ReParse() End Sub
Add a method that parses the buffer. The example given here is for illustration only. It synchronously parses the buffer into nested outlining regions.
void ReParse() { ITextSnapshot newSnapshot = buffer.CurrentSnapshot; List<Region> newRegions = new List<Region>(); //keep the current (deepest) partial region, which will have // references to any parent partial regions. PartialRegion currentRegion = null; foreach (var line in newSnapshot.Lines) { int regionStart = -1; string text = line.GetText(); //lines that contain a "[" denote the start of a new region. if ((regionStart = text.IndexOf(startHide, StringComparison.Ordinal)) != -1) { int currentLevel = (currentRegion != null) ? currentRegion.Level : 1; int newLevel; if (!TryGetLevel(text, regionStart, out newLevel)) newLevel = currentLevel + 1; //levels are the same and we have an existing region; //end the current region and start the next if (currentLevel == newLevel && currentRegion != null) { newRegions.Add(new Region() { Level = currentRegion.Level, StartLine = currentRegion.StartLine, StartOffset = currentRegion.StartOffset, EndLine = line.LineNumber }); currentRegion = new PartialRegion() { Level = newLevel, StartLine = line.LineNumber, StartOffset = regionStart, PartialParent = currentRegion.PartialParent }; } //this is a new (sub)region else { currentRegion = new PartialRegion() { Level = newLevel, StartLine = line.LineNumber, StartOffset = regionStart, PartialParent = currentRegion }; } } //lines that contain "]" denote the end of a region else if ((regionStart = text.IndexOf(endHide, StringComparison.Ordinal)) != -1) { int currentLevel = (currentRegion != null) ? currentRegion.Level : 1; int closingLevel; if (!TryGetLevel(text, regionStart, out closingLevel)) closingLevel = currentLevel; //the regions match if (currentRegion != null && currentLevel == closingLevel) { newRegions.Add(new Region() { Level = currentLevel, StartLine = currentRegion.StartLine, StartOffset = currentRegion.StartOffset, EndLine = line.LineNumber }); currentRegion = currentRegion.PartialParent; } } } //determine the changed span, and send a changed event with the new spans List<Span> oldSpans = new List<Span>(this.regions.Select(r => AsSnapshotSpan(r, this.snapshot) .TranslateTo(newSnapshot, SpanTrackingMode.EdgeExclusive) .Span)); List<Span> newSpans = new List<Span>(newRegions.Select(r => AsSnapshotSpan(r, newSnapshot).Span)); NormalizedSpanCollection oldSpanCollection = new NormalizedSpanCollection(oldSpans); NormalizedSpanCollection newSpanCollection = new NormalizedSpanCollection(newSpans); //the changed regions are regions that appear in one set or the other, but not both. NormalizedSpanCollection removed = NormalizedSpanCollection.Difference(oldSpanCollection, newSpanCollection); int changeStart = int.MaxValue; int changeEnd = -1; if (removed.Count > 0) { changeStart = removed[0].Start; changeEnd = removed[removed.Count - 1].End; } if (newSpans.Count > 0) { changeStart = Math.Min(changeStart, newSpans[0].Start); changeEnd = Math.Max(changeEnd, newSpans[newSpans.Count - 1].End); } this.snapshot = newSnapshot; this.regions = newRegions; if (changeStart <= changeEnd) { ITextSnapshot snap = this.snapshot; if (this.TagsChanged != null) this.TagsChanged(this, new SnapshotSpanEventArgs( new SnapshotSpan(this.snapshot, Span.FromBounds(changeStart, changeEnd)))); } }
Private Sub ReParse() Dim newSnapshot As ITextSnapshot = buffer.CurrentSnapshot Dim newRegions As New List(Of Region)() 'keep the current (deepest) partial region, which will have ' references to any parent partial regions. Dim currentRegion As PartialRegion = Nothing For Each line In newSnapshot.Lines Dim regionStart As Integer = -1 Dim text As String = line.GetText() 'lines that contain a "[" denote the start of a new region. If text.IndexOf(startHide, StringComparison.Ordinal) <> -1 Then regionStart = text.IndexOf(startHide, StringComparison.Ordinal) Dim currentLevel As Integer = If((currentRegion IsNot Nothing), currentRegion.Level, 1) Dim newLevel As Integer If Not TryGetLevel(text, regionStart, newLevel) Then newLevel = currentLevel + 1 End If 'levels are the same and we have an existing region; 'end the current region and start the next If currentLevel = newLevel AndAlso currentRegion IsNot Nothing Then Dim newRegion = New Region() newRegion.Level = currentRegion.Level newRegion.StartLine = currentRegion.StartLine newRegion.StartOffset = currentRegion.StartOffset newRegion.EndLine = line.LineNumber newRegions.Add(newRegion) currentRegion = New PartialRegion() currentRegion.Level = newLevel currentRegion.StartLine = line.LineNumber currentRegion.StartOffset = regionStart currentRegion.PartialParent = currentRegion.PartialParent Else 'this is a new (sub)region currentRegion = New PartialRegion() currentRegion.Level = newLevel currentRegion.StartLine = line.LineNumber currentRegion.StartOffset = regionStart currentRegion.PartialParent = currentRegion End If 'lines that contain "]" denote the end of a region ElseIf (text.IndexOf(endHide, StringComparison.Ordinal)) <> -1 Then regionStart = text.IndexOf(endHide, StringComparison.Ordinal) Dim currentLevel As Integer = If((currentRegion IsNot Nothing), currentRegion.Level, 1) Dim closingLevel As Integer If Not TryGetLevel(text, regionStart, closingLevel) Then closingLevel = currentLevel End If 'the regions match If currentRegion IsNot Nothing AndAlso currentLevel = closingLevel Then Dim newRegion As Region newRegion = New Region() newRegion.Level = currentLevel newRegion.StartLine = currentRegion.StartLine newRegion.StartOffset = currentRegion.StartOffset newRegion.EndLine = line.LineNumber newRegions.Add(newRegion) currentRegion = currentRegion.PartialParent End If End If Next 'determine the changed span, and send a changed event with the new spans Dim oldSpans As New List(Of Span)(Me.regions.[Select](Function(r) AsSnapshotSpan(r, Me.snapshot).TranslateTo(newSnapshot, SpanTrackingMode.EdgeExclusive).Span)) Dim newSpans As New List(Of Span)(newRegions.[Select](Function(r) AsSnapshotSpan(r, newSnapshot).Span)) Dim oldSpanCollection As New NormalizedSpanCollection(oldSpans) Dim newSpanCollection As New NormalizedSpanCollection(newSpans) 'the changed regions are regions that appear in one set or the other, but not both. Dim removed As NormalizedSpanCollection = NormalizedSpanCollection.Difference(oldSpanCollection, newSpanCollection) Dim changeStart As Integer = Integer.MaxValue Dim changeEnd As Integer = -1 If removed.Count > 0 Then changeStart = removed(0).Start changeEnd = removed(removed.Count - 1).[End] End If If newSpans.Count > 0 Then changeStart = Math.Min(changeStart, newSpans(0).Start) changeEnd = Math.Max(changeEnd, newSpans(newSpans.Count - 1).[End]) End If Me.snapshot = newSnapshot Me.regions = newRegions If changeStart <= changeEnd Then Dim snap As ITextSnapshot = Me.snapshot RaiseEvent TagsChanged(Me, New SnapshotSpanEventArgs(New SnapshotSpan(Me.snapshot, Span.FromBounds(changeStart, changeEnd)))) End If End Sub
The following helper method gets an integer that represents the level of the outlining, such that 1 is the leftmost brace pair.
static bool TryGetLevel(string text, int startIndex, out int level) { level = -1; if (text.Length > startIndex + 3) { if (int.TryParse(text.Substring(startIndex + 1), out level)) return true; } return false; }
Private Shared Function TryGetLevel(ByVal text As String, ByVal startIndex As Integer, ByRef level As Integer) As Boolean level = -1 If text.Length > startIndex + 3 Then If Integer.TryParse(text.Substring(startIndex + 1), level) Then Return True End If End If Return False End Function
The following helper method translates a Region (defined later in this article) into a SnapshotSpan.
static SnapshotSpan AsSnapshotSpan(Region region, ITextSnapshot snapshot) { var startLine = snapshot.GetLineFromLineNumber(region.StartLine); var endLine = (region.StartLine == region.EndLine) ? startLine : snapshot.GetLineFromLineNumber(region.EndLine); return new SnapshotSpan(startLine.Start + region.StartOffset, endLine.End); }
Private Shared Function AsSnapshotSpan(ByVal region As Region, ByVal snapshot As ITextSnapshot) As SnapshotSpan Dim startLine = snapshot.GetLineFromLineNumber(region.StartLine) Dim endLine = If((region.StartLine = region.EndLine), startLine, snapshot.GetLineFromLineNumber(region.EndLine)) Return New SnapshotSpan(startLine.Start + region.StartOffset, endLine.[End]) End Function
The following code is for illustration only. It defines a PartialRegion class that contains the line number and offset of the start of an outlining region, and a reference to the parent region (if any). This code enables the parser to set up nested outlining regions. A derived Region class contains a reference to the line number of the end of an outlining region.
class PartialRegion { public int StartLine { get; set; } public int StartOffset { get; set; } public int Level { get; set; } public PartialRegion PartialParent { get; set; } } class Region : PartialRegion { public int EndLine { get; set; } }
Private Class PartialRegion Private _StartLine As Integer Public Property StartLine() As Integer Get Return _StartLine End Get Set(ByVal value As Integer) _StartLine = value End Set End Property Private _StartOffset As Integer Public Property StartOffset() As Integer Get Return _StartOffset End Get Set(ByVal value As Integer) _StartOffset = value End Set End Property Private _Level As Integer Public Property Level() As Integer Get Return _Level End Get Set(ByVal value As Integer) _Level = value End Set End Property Private _PartialParent As PartialRegion Public Property PartialParent() As PartialRegion Get Return _PartialParent End Get Set(ByVal value As PartialRegion) _PartialParent = value End Set End Property End Class Private Class Region Inherits PartialRegion Private _EndLine As Integer Public Property EndLine() As Integer Get Return _EndLine End Get Set(ByVal value As Integer) _EndLine = value End Set End Property End Class
Implement a tagger provider
Export a tagger provider for your tagger. The tagger provider creates an OutliningTagger
for a buffer of the "text" content type, or else returns an OutliningTagger
if the buffer already has one.
To implement a tagger provider
Create a class named
OutliningTaggerProvider
that implements ITaggerProvider, and export it with the ContentType and TagType attributes.[Export(typeof(ITaggerProvider))] [TagType(typeof(IOutliningRegionTag))] [ContentType("text")] internal sealed class OutliningTaggerProvider : ITaggerProvider
<Export(GetType(ITaggerProvider))> _ <TagType(GetType(IOutliningRegionTag))> _ <ContentType("text")> _ Friend NotInheritable Class OutliningTaggerProvider Implements ITaggerProvider
Implement the CreateTagger method by adding an
OutliningTagger
to the properties of the buffer.public ITagger<T> CreateTagger<T>(ITextBuffer buffer) where T : ITag { //create a single tagger for each buffer. Func<ITagger<T>> sc = delegate() { return new OutliningTagger(buffer) as ITagger<T>; }; return buffer.Properties.GetOrCreateSingletonProperty<ITagger<T>>(sc); }
Public Function CreateTagger(Of T As ITag)(ByVal buffer As ITextBuffer) As ITagger(Of T) Implements ITaggerProvider.CreateTagger 'create a single tagger for each buffer. Dim sc As Func(Of ITagger(Of T)) = Function() TryCast(New OutliningTagger(buffer), ITagger(Of T)) Return buffer.Properties.GetOrCreateSingletonProperty(Of ITagger(Of T))(sc) End Function
Build and test the code
To test this code, build the OutlineRegionTest solution and run it in the experimental instance.
To build and test the OutlineRegionTest solution
Build the solution.
When you run this project in the debugger, a second instance of Visual Studio is started.
Create a text file. Type some text that includes both the opening brackets and the closing brackets.
[ Hello ]
There should be an outlining region that includes both brackets. You should be able to click the Minus Sign to the left of the open bracket to collapse the outlining region. When the region is collapsed, the ellipsis symbol (...) should appear to the left of the collapsed region, and a popup containing the text hover text should appear when you move the pointer over the ellipsis.