チュートリアル: アウトライン

展開したり折りたたんだりするテキスト領域の種類を定義することで、アウトラインなどの言語ベースの機能を設定します。 言語サービスのコンテキストで領域を定義することも、独自のファイル名拡張子やコンテンツ タイプを定義して、そのタイプにのみ領域の定義を適用することもできます。または、既存のコンテンツ タイプ ("text" など) に領域の定義を適用できます。 このチュートリアルでは、アウトライン領域を定義して表示する方法について説明します。

Managed Extensibility Framework (MEF) プロジェクトを作成する

MEF プロジェクトを作成するには

  1. VSIX プロジェクトを作成する。 ソリューション OutlineRegionTestの名前を指定します。

  2. プロジェクトに、[エディター分類子] 項目テンプレートを追加します。 詳細については、「エディター項目テンプレートを使用して拡張機能を作成する」を参照してください。

  3. 既存のクラス ファイルを削除します。

アウトライン タガーを実装する

アウトライン領域は、ある種類のタグ (OutliningRegionTag) でマークされます。 このタグにより、標準のアウトライン動作が提供されます。 アウトラインの対象領域は、展開したり折りたたんだりすることができます。 アウトラインの対象領域は、折りたたまれている場合はプラス記号 (+) で、展開されている場合はマイナス記号 (-) でマークされます。そして、展開された領域は垂直線で区切られます。

以下の手順では、角かっこ ([]) で区切られたすべての領域に対してアウトライン領域を作成するタガーを定義する方法を示します。

アウトライン タガーを実装するには

  1. クラス ファイルを追加し、その名前を OutliningTaggerにします。

  2. 次の名前空間をインポートします。

    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. OutliningTagger という名前のクラスを作成し、それに ITagger<T> を実装します。

    internal sealed class OutliningTagger : ITagger<IOutliningRegionTag>
    
  4. テキスト バッファーとスナップショットを追跡し、アウトライン領域としてタグ付けする必要がある行のセットを収集するため、いくつかのフィールドを追加します。 このコードには、アウトライン領域を表す 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. フィールドを初期化し、バッファーを解析して、Changed イベントにイベント ハンドラーを追加するタガー コンストラクターを追加します。

    public OutliningTagger(ITextBuffer buffer)
    {
        this.buffer = buffer;
        this.snapshot = buffer.CurrentSnapshot;
        this.regions = new List<Region>();
        this.ReParse();
        this.buffer.Changed += BufferChanged;
    }
    
  6. タグの範囲をインスタンス化する GetTags メソッドを実装します。 この例では、メソッドに渡される NormalizedSpanCollection 内の範囲が連続していることを前提としていますが、常にそうであるとは限りません。 このメソッドでは、アウトライン領域ごとに新しいタグの範囲をインスタンス化します。

    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. TagsChanged イベント ハンドラーを宣言します。

    public event EventHandler<SnapshotSpanEventArgs> TagsChanged;
    
  8. テキスト バッファーを解析することによって Changed イベントに応答する BufferChanged イベント ハンドラーを追加します。

    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. バッファーを解析するメソッドを追加します。 ここに示した例は、例示のみを目的としています。 これによってバッファーは同期的に解析され、入れ子になったアウトライン領域に入れられます。

    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. 次のヘルパー メソッドでは、アウトラインのレベルを表す整数を取得しており、1 が最も左側の中かっこのペアとなっています。

    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. 次のヘルパー メソッドでは、(この記事の後方で定義されている) 1 つの Region を 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);
    }
    
  12. 次のコードは、例示のみを目的としています。 これは、アウトライン領域の開始の行番号およびオフセットと、親領域 (存在する場合) への参照が含まれる PartialRegion クラスを定義しています。 このコードにより、パーサーは、入れ子になったアウトライン領域を設定できるようになります。 派生した 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; }
    }
    

タガー プロバイダーを実装する

作成したタガーのタガー プロバイダーをエクスポートします。 タガー プロバイダーでは、コンテンツ タイプが "text" であるバッファーのために OutliningTagger を作成します。または、バッファーにそれが既に存在する場合は OutliningTagger を返します。

タガー プロバイダーを実装するには

  1. ITaggerProvider を実装する OutliningTaggerProvider という名前のクラスを作成し、ContentType 属性と tagtype 属性を指定してそれをエクスポートします。

    [Export(typeof(ITaggerProvider))]
    [TagType(typeof(IOutliningRegionTag))]
    [ContentType("text")]
    internal sealed class OutliningTaggerProvider : ITaggerProvider
    
  2. バッファーのプロパティに OutliningTagger を追加して CreateTagger メソッドを実装します。

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

コードのビルドとテスト

このコードをテストするには、OutlineRegionTest ソリューションをビルドし、それを実験用のインスタンスで実行します。

OutlineRegionTest ソリューションをビルドしてテストするには

  1. ソリューションをビルドします。

  2. デバッガーでこのプロジェクトを実行すると、Visual Studio の 2 つ目のインスタンスが起動されます。

  3. テキスト ファイルを作成します。 左角かっこと右角かっこの両方を含む何らかのテキストを入力します。

    [
       Hello
    ]
    
  4. 両方の角かっこを含むアウトライン領域が存在する必要があります。 左角かっこの左側にあるマイナス記号をクリックすると、アウトライン領域を折りたたむことができるはずです。 領域が折りたたまれているときには、折りたたまれた領域の左側に省略記号 (...) が表示されるはずです。また、ポインターを省略記号の上に移動すると、そのテキストのホバー テキストが含まれるポップアップが表示されるはずです。