Walkthrough: Outlining

You can implement 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 you can define your own file name extension and content type and apply the region definition to only that type, or you can apply the region definitions to an existing content type (such as "text"). This walkthrough shows how to define and display outlining regions.

Prerequisites

To follow this walkthrough, you must install the Visual Studio 2013 SDK. For more information, see Visual Studio Software Development Kit (SDK).

Creating a Managed Extensibility Framework (MEF) Project

To create a MEF project

  1. Create an Editor Classifier project. Name the solution OutlineRegionTest.

  2. Open the source.extension.vsixmanifest file in the VSIX Manifest Editor.

  3. Make sure that the Assets tab contains a MEF Component content type and that Project is set to the name of the project.

  4. Save and close source.extension.vsixmanifest.

  5. Delete the existing class files.

Implementing 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 is collapsed or a MINUS SIGN if it is 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 that are delimited by "[" and "]".

To implement an outlining tagger

  1. Add a class file and name it OutliningTagger.

  2. Import the following namespaces.

    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
    
    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;
    
  3. Create a class named OutliningTagger, and have it implement ITagger<T>:

    Friend NotInheritable Class OutliningTagger
        Implements ITagger(Of IOutliningRegionTag)
    
    internal sealed class OutliningTagger : ITagger<IOutliningRegionTag>
    
  4. 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.

    '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)
    
    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;
    
  5. Add a tagger constructor that initializes the fields, parses the buffer, and adds an event handler to the Changed event.

    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
    
    public OutliningTagger(ITextBuffer buffer)
    {
        this.buffer = buffer;
        this.snapshot = buffer.CurrentSnapshot;
        this.regions = new List<Region>();
        this.ReParse();
        this.buffer.Changed += BufferChanged;
    }
    
  6. 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 this may not always be the case. This method instantiates a new tag span for each of the outlining regions.

    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
    
    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));
            }
        }
    }
    
  7. Declare a TagsChanged event handler.

    Public Event TagsChanged As EventHandler(Of SnapshotSpanEventArgs) Implements ITagger(Of IOutliningRegionTag).TagsChanged
    
    public event EventHandler<SnapshotSpanEventArgs> TagsChanged;
    
  8. Add a BufferChanged event handler that responds to Changed events by parsing the text buffer.

    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
    
    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();
    }
    
  9. Add a method that parses the buffer. The example given here is for illustration only. It synchronously parses the buffer into nested outlining regions.

    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
    
    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))));
        }
    }
    
  10. The following helper method gets an integer that represents the level of the outlining, such that 1 is the leftmost brace pair.

    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
    
    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;
    }
    
  11. The following helper method translates a Region (defined later in this topic) into a SnapshotSpan.

    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
    
    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);
    }
    
  12. 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 also a reference to the parent region (if any). This 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.

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

Implementing a Tagger Provider

You must 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

  1. Create a class named OutliningTaggerProvider that implements ITaggerProvider, and export it with the ContentType and TagType attributes.

    <Export(GetType(ITaggerProvider))> _
    <TagType(GetType(IOutliningRegionTag))> _
    <ContentType("text")> _
    Friend NotInheritable Class OutliningTaggerProvider
        Implements ITaggerProvider
    
    [Export(typeof(ITaggerProvider))]
    [TagType(typeof(IOutliningRegionTag))]
    [ContentType("text")]
    internal sealed class OutliningTaggerProvider : ITaggerProvider
    
  2. Implement the CreateTagger<T> method by adding an OutliningTagger to the properties of the buffer.

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

Building and Testing the Code

To test this code, build the OutlineRegionTest solution and run it in the experimental instance.

To build and test the OutlineRegionTest solution

  1. Build the solution.

  2. When you run this project in the debugger, a second instance of Visual Studio is instantiated.

  3. Create a text file. Type some text that includes both the opening brace and the closing brace.

    [
       Hello
    ]
    
  4. There should be an outlining region that includes both braces. You should be able to click the MINUS SIGN to the left of the open brace 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.

See Also

Tasks

Walkthrough: Linking a Content Type to a File Name Extension