Walkthrough: Highlighting Text
You can add different visual effects to the editor by creating Managed Extensibility Framework (MEF) component parts. This walkthrough shows how to highlight every occurrence of the current word in a text file. If a word occurs more than one time in a text file, and you position the caret in one occurrence, every occurrence is highlighted.
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 MEF Project
To create a MEF project
Create an Editor Classifier project. Name the solution HighlightWordTest.
Open the source.extension.vsixmanifest file in the VSIX Manifest Editor.
Make sure that the Assets tab contains a MEF Component content type and that Project is set to the name of the project.
Save and close source.extension.vsixmanifest.
Delete the existing class files.
Defining a TextMarkerTag
The first step in highlighting text is to subclass TextMarkerTag and define its appearance.
To define a TextMarkerTag and a MarkerFormatDefinition
Add a class file and name it HighlightWordTag.
Import the following namespaces.
Imports System Imports System.Collections.Generic Imports System.ComponentModel.Composition Imports System.Linq Imports System.Threading Imports System.Windows.Media Imports Microsoft.VisualStudio.Text Imports Microsoft.VisualStudio.Text.Classification Imports Microsoft.VisualStudio.Text.Editor Imports Microsoft.VisualStudio.Text.Operations Imports Microsoft.VisualStudio.Text.Tagging Imports Microsoft.VisualStudio.Utilities
using System; using System.Collections.Generic; using System.ComponentModel.Composition; using System.Linq; using System.Threading; using System.Windows.Media; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Classification; using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Operations; using Microsoft.VisualStudio.Text.Tagging; using Microsoft.VisualStudio.Utilities;
Create a class that inherits from TextMarkerTag and name it HighlightWordTag.
Friend Class HighlightWordTag Inherits TextMarkerTag
internal class HighlightWordTag : TextMarkerTag
Create a second class that inherits from MarkerFormatDefinition, and name it HighlightWordFormatDefinition. In order to use this format definition for your tag, you must export it with the following attributes:
NameAttribute: tags use this to reference this format
UserVisibleAttribute: this causes the format to appear in the UI
<Export(GetType(EditorFormatDefinition))> <Name("MarkerFormatDefinition/HighlightWordFormatDefinition")> <UserVisible(True)> Friend Class HighlightWordFormatDefinition Inherits MarkerFormatDefinition
[Export(typeof(EditorFormatDefinition))] [Name("MarkerFormatDefinition/HighlightWordFormatDefinition")] [UserVisible(true)] internal class HighlightWordFormatDefinition : MarkerFormatDefinition
In the constructor for HighlightWordFormatDefinition, define its display name and appearance. The Background property defines the fill color, while the Foreground property defines the border color.
Public Sub New() Me.BackgroundColor = Colors.LightBlue Me.ForegroundColor = Colors.DarkBlue Me.DisplayName = "Highlight Word" Me.ZOrder = 5 End Sub
public HighlightWordFormatDefinition() { this.BackgroundColor = Colors.LightBlue; this.ForegroundColor = Colors.DarkBlue; this.DisplayName = "Highlight Word"; this.ZOrder = 5; }
In the constructor for HighlightWordTag, pass in the name of the format definition you just created.
Public Sub New() MyBase.New("MarkerFormatDefinition/HighlightWordFormatDefinition") End Sub
public HighlightWordTag() : base("MarkerFormatDefinition/HighlightWordFormatDefinition") { }
Implementing an ITagger
The next step is to implement the ITagger<T> interface. This interface assigns, to a given text buffer, tags that provide text highlighting and other visual effects.
To implement a tagger
Create a class that implements ITagger<T> of type HighlightWordTag, and name it HighlightWordTagger.
Friend Class HighlightWordTagger Implements ITagger(Of HighlightWordTag)
internal class HighlightWordTagger : ITagger<HighlightWordTag>
Add the following private fields and properties to the class:
An ITextView, which corresponds to the current text view.
An ITextBuffer, which corresponds to the text buffer that underlies the text view.
An ITextSearchService, which is used to find text.
An ITextStructureNavigator, which has methods for navigating within text spans.
A NormalizedSnapshotSpanCollection, which contains the set of words to highlight.
A SnapshotSpan, which corresponds to the current word.
A SnapshotPoint, which corresponds to the current position of the caret.
A lock object.
Private _View As ITextView Private Property View() As ITextView Get Return _View End Get Set(ByVal value As ITextView) _View = value End Set End Property Private _SourceBuffer As ITextBuffer Private Property SourceBuffer() As ITextBuffer Get Return _SourceBuffer End Get Set(ByVal value As ITextBuffer) _SourceBuffer = value End Set End Property Private _TextSearchService As ITextSearchService Private Property TextSearchService() As ITextSearchService Get Return _TextSearchService End Get Set(ByVal value As ITextSearchService) _TextSearchService = value End Set End Property Private _TextStructureNavigator As ITextStructureNavigator Private Property TextStructureNavigator() As ITextStructureNavigator Get Return _TextStructureNavigator End Get Set(ByVal value As ITextStructureNavigator) _TextStructureNavigator = value End Set End Property Private _WordSpans As NormalizedSnapshotSpanCollection Private Property WordSpans() As NormalizedSnapshotSpanCollection Get Return _WordSpans End Get Set(ByVal value As NormalizedSnapshotSpanCollection) _WordSpans = value End Set End Property Private _CurrentWord As System.Nullable(Of SnapshotSpan) Private Property CurrentWord() As System.Nullable(Of SnapshotSpan) Get Return _CurrentWord End Get Set(ByVal value As System.Nullable(Of SnapshotSpan)) _CurrentWord = value End Set End Property Private _RequestedPoint As SnapshotPoint Private Property RequestedPoint() As SnapshotPoint Get Return _RequestedPoint End Get Set(ByVal value As SnapshotPoint) _RequestedPoint = value End Set End Property Private updateLock As New Object()
ITextView View { get; set; } ITextBuffer SourceBuffer { get; set; } ITextSearchService TextSearchService { get; set; } ITextStructureNavigator TextStructureNavigator { get; set; } NormalizedSnapshotSpanCollection WordSpans { get; set; } SnapshotSpan? CurrentWord { get; set; } SnapshotPoint RequestedPoint { get; set; } object updateLock = new object();
Add a constructor that initializes the properties listed earlier and adds LayoutChanged and PositionChanged event handlers.
Public Sub New(ByVal view As ITextView, ByVal sourceBuffer As ITextBuffer, ByVal textSearchService As ITextSearchService, ByVal textStructureNavigator As ITextStructureNavigator) Me.View = view Me.SourceBuffer = sourceBuffer Me.TextSearchService = textSearchService Me.TextStructureNavigator = textStructureNavigator Me.WordSpans = New NormalizedSnapshotSpanCollection() Me.CurrentWord = Nothing AddHandler Me.View.Caret.PositionChanged, AddressOf CaretPositionChanged AddHandler Me.View.LayoutChanged, AddressOf ViewLayoutChanged End Sub
public HighlightWordTagger(ITextView view, ITextBuffer sourceBuffer, ITextSearchService textSearchService, ITextStructureNavigator textStructureNavigator) { this.View = view; this.SourceBuffer = sourceBuffer; this.TextSearchService = textSearchService; this.TextStructureNavigator = textStructureNavigator; this.WordSpans = new NormalizedSnapshotSpanCollection(); this.CurrentWord = null; this.View.Caret.PositionChanged += CaretPositionChanged; this.View.LayoutChanged += ViewLayoutChanged; }
The event handlers both call the UpdateAtCaretPosition method.
Private Sub ViewLayoutChanged(ByVal sender As Object, ByVal e As TextViewLayoutChangedEventArgs) ' If a new snapshot wasn't generated, then skip this layout If e.NewSnapshot IsNot e.OldSnapshot Then UpdateAtCaretPosition(View.Caret.Position) End If End Sub Private Sub CaretPositionChanged(ByVal sender As Object, ByVal e As CaretPositionChangedEventArgs) UpdateAtCaretPosition(e.NewPosition) End Sub
void ViewLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) { // If a new snapshot wasn't generated, then skip this layout if (e.NewSnapshot != e.OldSnapshot) { UpdateAtCaretPosition(View.Caret.Position); } } void CaretPositionChanged(object sender, CaretPositionChangedEventArgs e) { UpdateAtCaretPosition(e.NewPosition); }
You must also add a TagsChanged event that will be called by the update method.
Public Event TagsChanged(ByVal sender As Object, ByVal e As SnapshotSpanEventArgs) _ Implements ITagger(Of HighlightWordTag).TagsChanged
public event EventHandler<SnapshotSpanEventArgs> TagsChanged;
The UpdateAtCaretPosition() method finds every word in the text buffer that is identical to the word where the cursor is positioned and constructs a list of SnapshotSpan objects that correspond to the occurrences of the word. It then calls SynchronousUpdate, which raises the TagsChanged event.
Private Sub UpdateAtCaretPosition(ByVal caretPosition As CaretPosition) Dim point As System.Nullable(Of SnapshotPoint) = caretPosition.Point.GetPoint(SourceBuffer, caretPosition.Affinity) If Not point.HasValue Then Exit Sub End If ' If the new caret position is still within the current word (and on the same snapshot), we don't need to check it If CurrentWord.HasValue AndAlso CurrentWord.Value.Snapshot Is View.TextSnapshot AndAlso point.Value > CurrentWord.Value.Start AndAlso point.Value < CurrentWord.Value.[End] Then Exit Sub End If RequestedPoint = point.Value UpdateWordAdornments() End Sub Private Sub UpdateWordAdornments() Dim currentRequest As SnapshotPoint = RequestedPoint Dim wordSpans As New List(Of SnapshotSpan)() 'Find all words in the buffer like the one the caret is on Dim word As TextExtent = TextStructureNavigator.GetExtentOfWord(currentRequest) Dim foundWord As Boolean = True 'If we've selected something not worth highlighting, we might have missed a "word" by a little bit If Not WordExtentIsValid(currentRequest, word) Then 'Before we retry, make sure it is worthwhile If word.Span.Start <> currentRequest OrElse currentRequest = currentRequest.GetContainingLine().Start OrElse Char.IsWhiteSpace((currentRequest - 1).GetChar()) Then foundWord = False Else ' Try again, one character previous. 'If the caret is at the end of a word, pick up the word. word = TextStructureNavigator.GetExtentOfWord(currentRequest - 1) 'If the word still isn't valid, we're done If Not WordExtentIsValid(currentRequest, word) Then foundWord = False End If End If End If If Not foundWord Then 'If we couldn't find a word, clear out the existing markers SynchronousUpdate(currentRequest, New NormalizedSnapshotSpanCollection(), Nothing) Exit Sub End If Dim currentWord__1 As SnapshotSpan = word.Span 'If this is the current word, and the caret moved within a word, we're done. If CurrentWord.HasValue AndAlso currentWord__1 = CurrentWord Then Exit Sub End If 'Find the new spans Dim findData As New FindData(currentWord__1.GetText(), currentWord__1.Snapshot) findData.FindOptions = FindOptions.WholeWord Or FindOptions.MatchCase wordSpans.AddRange(TextSearchService.FindAll(findData)) 'If another change hasn't happened, do a real update If currentRequest = RequestedPoint Then SynchronousUpdate(currentRequest, New NormalizedSnapshotSpanCollection(wordSpans), currentWord__1) End If End Sub Private Shared Function WordExtentIsValid(ByVal currentRequest As SnapshotPoint, ByVal word As TextExtent) As Boolean Return word.IsSignificant AndAlso currentRequest.Snapshot.GetText(word.Span).Any(Function(c) Char.IsLetter(c)) End Function
void UpdateAtCaretPosition(CaretPosition caretPosition) { SnapshotPoint? point = caretPosition.Point.GetPoint(SourceBuffer, caretPosition.Affinity); if (!point.HasValue) return; // If the new caret position is still within the current word (and on the same snapshot), we don't need to check it if (CurrentWord.HasValue && CurrentWord.Value.Snapshot == View.TextSnapshot && point.Value >= CurrentWord.Value.Start && point.Value <= CurrentWord.Value.End) { return; } RequestedPoint = point.Value; UpdateWordAdornments(); } void UpdateWordAdornments() { SnapshotPoint currentRequest = RequestedPoint; List<SnapshotSpan> wordSpans = new List<SnapshotSpan>(); //Find all words in the buffer like the one the caret is on TextExtent word = TextStructureNavigator.GetExtentOfWord(currentRequest); bool foundWord = true; //If we've selected something not worth highlighting, we might have missed a "word" by a little bit if (!WordExtentIsValid(currentRequest, word)) { //Before we retry, make sure it is worthwhile if (word.Span.Start != currentRequest || currentRequest == currentRequest.GetContainingLine().Start || char.IsWhiteSpace((currentRequest - 1).GetChar())) { foundWord = false; } else { // Try again, one character previous. //If the caret is at the end of a word, pick up the word. word = TextStructureNavigator.GetExtentOfWord(currentRequest - 1); //If the word still isn't valid, we're done if (!WordExtentIsValid(currentRequest, word)) foundWord = false; } } if (!foundWord) { //If we couldn't find a word, clear out the existing markers SynchronousUpdate(currentRequest, new NormalizedSnapshotSpanCollection(), null); return; } SnapshotSpan currentWord = word.Span; //If this is the current word, and the caret moved within a word, we're done. if (CurrentWord.HasValue && currentWord == CurrentWord) return; //Find the new spans FindData findData = new FindData(currentWord.GetText(), currentWord.Snapshot); findData.FindOptions = FindOptions.WholeWord | FindOptions.MatchCase; wordSpans.AddRange(TextSearchService.FindAll(findData)); //If another change hasn't happened, do a real update if (currentRequest == RequestedPoint) SynchronousUpdate(currentRequest, new NormalizedSnapshotSpanCollection(wordSpans), currentWord); } static bool WordExtentIsValid(SnapshotPoint currentRequest, TextExtent word) { return word.IsSignificant && currentRequest.Snapshot.GetText(word.Span).Any(c => char.IsLetter(c)); }
The SynchronousUpdate performs a synchronous update on the WordSpans and CurrentWord properties, and raises the TagsChanged event.
Private Sub SynchronousUpdate(ByVal currentRequest As SnapshotPoint, ByVal newSpans As NormalizedSnapshotSpanCollection, ByVal newCurrentWord As System.Nullable(Of SnapshotSpan)) SyncLock updateLock If currentRequest <> RequestedPoint Then Exit Sub End If WordSpans = newSpans CurrentWord = newCurrentWord RaiseEvent TagsChanged(Me, New SnapshotSpanEventArgs(New SnapshotSpan(SourceBuffer.CurrentSnapshot, 0, SourceBuffer.CurrentSnapshot.Length))) End SyncLock End Sub
void SynchronousUpdate(SnapshotPoint currentRequest, NormalizedSnapshotSpanCollection newSpans, SnapshotSpan? newCurrentWord) { lock (updateLock) { if (currentRequest != RequestedPoint) return; WordSpans = newSpans; CurrentWord = newCurrentWord; var tempEvent = TagsChanged; if (tempEvent != null) tempEvent(this, new SnapshotSpanEventArgs(new SnapshotSpan(SourceBuffer.CurrentSnapshot, 0, SourceBuffer.CurrentSnapshot.Length))); } }
You must implement the GetTags method. This method takes a collection of SnapshotSpan objects and returns an enumeration of tag spans.
In C#, implement this method as a yield iterator, which enables lazy evaluation (that is, evaluation of the set only when individual items are accessed) of the tags. In Visual Basic, add the tags to a list and return the list.
Here the method returns a TagSpan<T> object that has a "blue" TextMarkerTag, which provides a blue background.
Public Function GetTags(ByVal spans As NormalizedSnapshotSpanCollection) As IEnumerable(Of ITagSpan(Of HighlightWordTag)) Implements ITagger(Of HighlightWordTag).GetTags If CurrentWord Is Nothing Then Return Nothing Exit Function End If ' Hold on to a "snapshot" of the word spans and current word, so that we maintain the same ' collection throughout Dim currentWord__1 As SnapshotSpan = CurrentWord.Value Dim wordSpans__2 As NormalizedSnapshotSpanCollection = WordSpans If spans.Count = 0 OrElse WordSpans.Count = 0 Then Return Nothing Exit Function End If ' If the requested snapshot isn't the same as the one our words are on, translate our spans to the expected snapshot If spans(0).Snapshot IsNot wordSpans__2(0).Snapshot Then wordSpans__2 = New NormalizedSnapshotSpanCollection(wordSpans__2.[Select](Function(span) span.TranslateTo(spans(0).Snapshot, SpanTrackingMode.EdgeExclusive))) currentWord__1 = currentWord__1.TranslateTo(spans(0).Snapshot, SpanTrackingMode.EdgeExclusive) End If 'in order to emulate the C# yield return functionality, 'create a list and add all the relevant spans to it, then return the list Dim list As List(Of TagSpan(Of HighlightWordTag)) list = New List(Of TagSpan(Of HighlightWordTag))() If spans.OverlapsWith(New NormalizedSnapshotSpanCollection(currentWord__1)) Then list.Add(New TagSpan(Of HighlightWordTag)(CurrentWord, New HighlightWordTag())) End If For Each span As SnapshotSpan In NormalizedSnapshotSpanCollection.Overlap(spans, wordSpans__2) list.Add(New TagSpan(Of HighlightWordTag)(span, New HighlightWordTag())) Next Return List End Function
public IEnumerable<ITagSpan<HighlightWordTag>> GetTags(NormalizedSnapshotSpanCollection spans) { if (CurrentWord == null) yield break; // Hold on to a "snapshot" of the word spans and current word, so that we maintain the same // collection throughout SnapshotSpan currentWord = CurrentWord.Value; NormalizedSnapshotSpanCollection wordSpans = WordSpans; if (spans.Count == 0 || wordSpans.Count == 0) yield break; // If the requested snapshot isn't the same as the one our words are on, translate our spans to the expected snapshot if (spans[0].Snapshot != wordSpans[0].Snapshot) { wordSpans = new NormalizedSnapshotSpanCollection( wordSpans.Select(span => span.TranslateTo(spans[0].Snapshot, SpanTrackingMode.EdgeExclusive))); currentWord = currentWord.TranslateTo(spans[0].Snapshot, SpanTrackingMode.EdgeExclusive); } // First, yield back the word the cursor is under (if it overlaps) // Note that we'll yield back the same word again in the wordspans collection; // the duplication here is expected. if (spans.OverlapsWith(new NormalizedSnapshotSpanCollection(currentWord))) yield return new TagSpan<HighlightWordTag>(currentWord, new HighlightWordTag()); // Second, yield all the other words in the file foreach (SnapshotSpan span in NormalizedSnapshotSpanCollection.Overlap(spans, wordSpans)) { yield return new TagSpan<HighlightWordTag>(span, new HighlightWordTag()); } }
Creating a Tagger Provider
To create your tagger, you must implement a IViewTaggerProvider. This class is a MEF component part, so you must set the correct attributes so that this extension is recognized.
Note
For more information about MEF, see Managed Extensibility Framework (MEF).
To create a tagger provider
Create a class named HighlightWordTaggerProvider that implements IViewTaggerProvider, and export it with a ContentTypeAttribute of "text" and a TagTypeAttribute of TextMarkerTag.
<Export(GetType(IViewTaggerProvider))> <ContentType("text")> <TagType(GetType(TextMarkerTag))> Friend Class HighlightWordTaggerProvider Implements IViewTaggerProvider
[Export(typeof(IViewTaggerProvider))] [ContentType("text")] [TagType(typeof(TextMarkerTag))] internal class HighlightWordTaggerProvider : IViewTaggerProvider
You must import two editor services, the ITextSearchService and the ITextStructureNavigatorSelectorService, to instantiate the tagger.
Private _TextSearchService As ITextSearchService <Import()> _ Friend Property TextSearchService() As ITextSearchService Get Return _TextSearchService End Get Set(ByVal value As ITextSearchService) _TextSearchService = value End Set End Property Private _TextStructureNavigatorSelector As ITextStructureNavigatorSelectorService <Import()> Friend Property TextStructureNavigatorSelector() As ITextStructureNavigatorSelectorService Get Return _TextStructureNavigatorSelector End Get Set(ByVal value As ITextStructureNavigatorSelectorService) _TextStructureNavigatorSelector = value End Set End Property
[Import] internal ITextSearchService TextSearchService { get; set; } [Import] internal ITextStructureNavigatorSelectorService TextStructureNavigatorSelector { get; set; }
Implement the CreateTagger<T> method to return an instance of HighlightWordTagger.
Public Function CreateTagger(Of T As ITag)(ByVal textView As ITextView, ByVal buffer As ITextBuffer) As ITagger(Of T) Implements IViewTaggerProvider.CreateTagger 'provide highlighting only on the top buffer If textView.TextBuffer IsNot buffer Then Return Nothing End If Dim textStructureNavigator As ITextStructureNavigator = TextStructureNavigatorSelector.GetTextStructureNavigator(buffer) Return TryCast(New HighlightWordTagger(textView, buffer, TextSearchService, textStructureNavigator), ITagger(Of T)) End Function
public ITagger<T> CreateTagger<T>(ITextView textView, ITextBuffer buffer) where T : ITag { //provide highlighting only on the top buffer if (textView.TextBuffer != buffer) return null; ITextStructureNavigator textStructureNavigator = TextStructureNavigatorSelector.GetTextStructureNavigator(buffer); return new HighlightWordTagger(textView, buffer, TextSearchService, textStructureNavigator) as ITagger<T>; }
Building and Testing the Code
To test this code, build the HighlightWordTest solution and run it in the experimental instance.
To build and test the HighlightWordTest solution
Build the solution.
When you run this project in the debugger, a second instance of Visual Studio is instantiated.
Create a text file and type some text in which the words are repeated, for example, "hello hello hello".
Position the cursor in one of the occurrences of "hello". Every occurrence should be highlighted in blue.
See Also
Tasks
Walkthrough: Linking a Content Type to a File Name Extension