AvalonEdit の改行マークを変更したい


はじめに

今回は小ネタです。この記事は AvalonEdit のカスタマイズを試みた際の備忘録です。

自作のメモ帳アプリ で、エディタコントロールとして非常に高機能なライブラリである AvalonEdit を使用しています。標準で多様な動作を実現できますが、機能を拡張しようとすると詰まりがちです。巨大なライブラリ故に内部の実装が複雑で、私の理解力では解読が難航します。
時間が経つと経緯を忘れそうなのでメモとして残しています。

やりたいこと

AvalonEdit は改行文字を可視化することができ、標準では "\r", "\n", "¶" の記号で表示されます。これをサクラエディタのような矢印("←", "↓", "↵")に変更したいです。

株式会社かなざわネット様のサイト でこれを叶える方法を解説頂いていますが、AvalonEdit のソースに手を加えずに実現する方法を模索しました。

環境

  • Visual Studio Community 2019
  • .NET Core 3.1 (C#/WPF)
  • Avalon Edit 6.0.1

結論

以下でたぶんできてます。

WrappedTextView
using ICSharpCode.AvalonEdit.Rendering;
using System.Collections.Generic;
using System.Reflection;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.TextFormatting;

namespace TestApp
{
    public class WrappedTextView : ICSharpCode.AvalonEdit.Rendering.TextView
    {
        private const string CR_CHAR = "\u2190";
        private const string LF_CHAR = "\u2193";
        private const string CRLF_CHAR = "\u21B5";

        protected override Size MeasureOverride(Size availableSize)
        {
            if (this.Options?.ShowEndOfLine == true)
                this.RefreshNonPrintableCharacterTexts();
            return base.MeasureOverride(availableSize);
        }

        private void RefreshNonPrintableCharacterTexts()
        {
            var globalProterties = (TextRunProperties)typeof(ICSharpCode.AvalonEdit.Rendering.TextView)
                .GetMethod("CreateGlobalTextRunProperties", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.InvokeMethod)
                .Invoke(this, null);
            var formatter = (TextFormatter)typeof(ICSharpCode.AvalonEdit.Rendering.TextView).Assembly
                .GetType("ICSharpCode.AvalonEdit.Utils.TextFormatterFactory")
                .GetMethod("Create", BindingFlags.Static | BindingFlags.Public | BindingFlags.InvokeMethod)
                .Invoke(null, new[] { this });
            var cachedElements = typeof(ICSharpCode.AvalonEdit.Rendering.TextView)
                .GetField("cachedElements", BindingFlags.Instance | BindingFlags.NonPublic)
                .GetValue(this);
            var nonPrintableCharacterTexts = (Dictionary<string, TextLine>)cachedElements.GetType()
                .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic)
                .GetValue(cachedElements);

            var elementProperties = new VisualLineElementTextRunProperties(globalProterties);
            elementProperties.SetForegroundBrush(this.NonPrintableCharacterBrush);
            var cr = FormattedTextElement.PrepareText(formatter, CR_CHAR, elementProperties);
            var lf = FormattedTextElement.PrepareText(formatter, LF_CHAR, elementProperties);
            var crlf = FormattedTextElement.PrepareText(formatter, CRLF_CHAR, elementProperties);

            nonPrintableCharacterTexts ??= new Dictionary<string, TextLine>();
            nonPrintableCharacterTexts["\\r"] = cr;
            nonPrintableCharacterTexts["\\n"] = lf;
            nonPrintableCharacterTexts["¶"] = crlf;

            cachedElements.GetType()
                .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic)
                .SetValue(cachedElements, nonPrintableCharacterTexts);
        }
    }
}

詳細

改行マークは下記の VisualLineTextSource.CreateTextRunForNewLine() で指定されています。
これを取り巻く要素を調整して、本件の実現を目指しました。

VisualLineTextSource
namespace ICSharpCode.AvalonEdit.Rendering
{
    sealed class VisualLineTextSource : TextSource, ITextRunConstructionContext
    {
        TextRun CreateTextRunForNewLine()
        {
            string newlineText = "";
            DocumentLine lastDocumentLine = VisualLine.LastDocumentLine;
            if (lastDocumentLine.DelimiterLength == 2) {
                newlineText = "¶";
            } else if (lastDocumentLine.DelimiterLength == 1) {
                char newlineChar = Document.GetCharAt(lastDocumentLine.Offset + lastDocumentLine.Length);
                if (newlineChar == '\r')
                    newlineText = "\\r";
                else if (newlineChar == '\n')
                    newlineText = "\\n";
                else
                    newlineText = "?";
            }
            return new FormattedTextRun(new FormattedTextElement(TextView.cachedElements.GetTextForNonPrintableCharacter(newlineText, this), 0), GlobalTextRunProperties);
        }
    }
}

失敗1:VisualLineTextSource を置き換える

CreateTextRunForNewLine() は非公開メソッドであり、VisualLineTextSource は sealed クラスのため継承できません。
したがって、CreateTextRunForNewLine() を再定義した新しいクラスを作り、VisualLineTextSource の呼び出し元を新クラスに置き換えることを考えました。

ところが VisualLineTextSource は以下のような呼び出し階層を持ちます。
まず非公開メソッドが入るためこれは変更できません。その先は多くの参照元があるメソッドに繋がるため、影響範囲が大きく、現実的ではありません。

 VisualLineTextSource
 └ private TextView.BuildVisualLine()
  ├ public TextView.GetOrConstructVisualLine()
  │ └ 多数の呼び出し元
  └ private TextView.CreateAndMeasureVisualLines()
   └ protected TextView.MeasureOverride()
    └ 多数の呼び出し元

失敗2:TextViewCachedElements を置き換える

CreateTextRunForNewLine() 内では、改行マークを TextViewCachedElements.GetTextForNonPrintableCharacter() で TextLine クラスに変換して呼び出し元に返しています。
GetTextForNonPrintableCharacter() の書き換えを検討しましたが、これも失敗です。

TextViewCachedElements も継承できないため、やるとすればクラスごと置き換えになりますが、
呼び出し元はいずれも非公開メソッドの TextView.OnDocumentChanged(), TextView.RecreateCachedElements() であるため、これも現実的な対応を見出せません。

可能性:TextViewCachedElements.nonPrintableCharacterTexts を無理やり調整する

失敗2の TextViewCachedElements は GetTextForNonPrintableCharacter() で改行マークを受け取り、TextLine に変換します。この TextLine は、ゆくゆくは VisualLineElement に設定され、画面描画に使用されます。
一度変換した「改行マーク」と「TextLine」の組み合わせは nonPrintableCharacterTexts にキャッシュされ、次回からはこれが再利用されています。つまり、このキャッシュを調整し {"¶", TextLine("↵") } のような組み合わせを登録すれば、描画されるマークをコンバートできるということです。

TextViewCachedElements のインスタンスは TextView.cachedElements という internal なメンバ変数のため、TextView を継承してもアクセスできません。TextViewCachedElements.nonPrintableCharacterTexts も同様に非公開です。
ここではリフレクションを使いメンバ変数にアクセスして書き換えることになります。

    var cachedElements = typeof(ICSharpCode.AvalonEdit.Rendering.TextView)
        .GetField("cachedElements", BindingFlags.Instance | BindingFlags.NonPublic)
        .GetValue(this); // this は TextView
    var nonPrintableCharacterTexts = (Dictionary<string, TextLine>)cachedElements.GetType()
        .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic)
        .GetValue(cachedElements);

    // ここで nonPrintableCharacterTexts を調整

    cachedElements.GetType()
        .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic)
        .SetValue(cachedElements, nonPrintableCharacterTexts);

そのほか、TextLine を作るためにいくつかの非公開メソッドが必要になります。
これらも含めたものが、"結論" に載せたコードになります。