WinUI3 MarkdownControlをTextMate対応した話

# WinUI3 MarkdownControl
WinUI3のCommunityToolkitには現在Labの中ですが、MarkdownControlが存在します。

https://github.com/CommunityToolkit/Labs-Windows

文字通りこれはWinUI3にMarkdownを表示するコントロールを追加するライブラリなのですが、少し気に入らないところが、、、

普通のMarkdownだと```で囲うとその中に書いたコードがSyntaxHighlightされて表示されるのですが、そのまま使用する場合だとこれが使えません。
また、ライブラリの中身を見ると`ColorCode`というライブラリでSyntaxHighlightできるようになっているのですが、これは簡易的なもので、クラス名とメソッド名が同じ色になってしまうというものでした。

どうしてもクラス名とメソッド名が別の色であってほしかったので、前回紹介した`TextMateSharp`で再構築してみました。

# CommunityToolkit.Labsをフォーク
今回はCommunityToolkitの`MarkdownTextBlock`をフォークして再構築します。
フォークして、gitCloneを行い、プロジェクトに変更を加えていくのですが、少し注意点があります。
まず、gitCloneはそのまま行うのではなく、以下のコードでgitCloneを行います。
`git clone --recurse-submodules https://github.com/{CommunityToolkit/Labs-Windows(フォークした先のアドレス)}.git`
これは上記の`Readme`に記述されていますね。

そしてgitCloneが完了したら、ソリューションに展開するのに、`GenerateAllSolution.bat`ファイルを実行する必要があります。
これを実行するために、ファイル内のすべての`.ps1`ファイルに実行するためのポリシーを設定する必要があるので、注意です。

すべての`.ps1`ファイルにポリシーを付け終わり`GenerateAllSolution.bat`を`Visual Studio`の開発者コンソールで実行するとソリューションが作られるので、やっと作業が始められます。

# TextMateをMarkdown仕様へ
前回のサンプルコードを元にMarkdownTextBlockで使えるようにします。
一応元々のColorCodeのほうでUWPとWinUIが分かれて書いてあったので、そうしていますが、WinUI3での想定の場合は`#if !WINAPPSDK`の中は無視して問題ないです。
```csharp
using TextMateSharp.Grammars;
using TextMateSharp.Registry;
using TextMateSharp.Themes;

namespace CommunityToolkit.WinUI.Controls.MarkdownTextBlockRns.TextMate;
public class TextMateFormatter
{
    // GrammerとThemeをフィールドへ
    private IGrammar grammar;
    private Theme theme;
#if !WINAPPSDK
    private FontFamily fontFamily;
#else
    private FontFamily fontFamily;
#endif

    // 構造体。文法、テーマ、フォントの設定
    public TextMateFormatter(ThemeName themeName, string extention, FontFamily font)
    {
        fontFamily = font;
        // 今回はTextMate組み込みのテーマとグラマーを使用します。
        RegistryOptions options = new RegistryOptions(themeName);
        Registry registry = new Registry(options);
        theme = registry.GetTheme();
        // 与えられた拡張子から文法を取得
        grammar = registry.LoadGrammar(options.GetScopeByExtension(extention));
    }

    // このメソッドをを呼び出して色付きのコードを吐き出す
    public void FormatRichTextBlock(Stack<string> codeText, RichTextBlock textBlock)
    {
        IStateStack? ruleStack = null;
        foreach (string line in codeText)
        {
#if !WINAPPSDK
            var paragraph = new Windows.UI.Xaml.Documents.Paragraph();
#else
            // TextBlockに表示するためにParagraphを作成。このParagraphを与えられたtextBlockに詰める
            var paragraph = new Microsoft.UI.Xaml.Documents.Paragraph();
#endif
            // １行ごとに文法によりトークン化
            ITokenizeLineResult result = grammar.TokenizeLine(line, ruleStack, TimeSpan.MaxValue);
            ruleStack = result.RuleStack;

            // トークンごとに色やフォントスタイルを設定
            foreach (IToken token in result.Tokens)
            {
                int startIndex = (token.StartIndex > line.Length) ?
                    line.Length : token.StartIndex;
                int endIndex = (token.EndIndex > line.Length) ?
                    line.Length : token.EndIndex;

                int foreground = -1;

                TextMateSharp.Themes.FontStyle fontStyle = TextMateSharp.Themes.FontStyle.NotSet;

                foreach (var themeRule in theme.Match(token.Scopes))
                {
                    // テーマに沿ってテキストの色を決める。背景は実装が難しいので今回はしない
                    if (foreground == -1 && themeRule.foreground > 0)
                        foreground = themeRule.foreground;
                    // フォントスタイルを決める
                    if (fontStyle == TextMateSharp.Themes.FontStyle.NotSet && themeRule.fontStyle > 0)
                        fontStyle = themeRule.fontStyle;
                }
                // paragraphに色を付けた行を追加していく
                paragraph.Inlines.Add(WriteToken(line.SubstringAtIndexes(startIndex, endIndex), foreground, fontStyle));
            }
            // すべての行を詰めて、最後にtextBlockにpragraphを詰める。
            // あとはWinUI側でこのtextBlockを表示すればよい
            textBlock.Blocks.Add(paragraph);
        }
    }
    // トークンに色を付ける処理
    private Run WriteToken(string text, int foreground, TextMateSharp.Themes.FontStyle fontStyle)
    {
#if !WINAPPSDK
        Windows.UI.Xaml.Documents.Run run = new Run
        {
            Text = text,
        };
        run.FontFamily = fontFamily;
#else
        // textBlockの最小単位Runにトークンの文字を詰める
        Microsoft.UI.Xaml.Documents.Run run = new Run
        {
            Text = text,
        };
        run.FontFamily = fontFamily;
#endif
        // 何も変化しないならそのままの黒で返す
        if (foreground == -1)
        {
            return run;
        }

        if (fontStyle == TextMateSharp.Themes.FontStyle.Bold)
        {
#if !WINAPPSDK
            run.FontWeight = Windows.UI.Text.FontWeights.Bold;
#else
            // fontStyleが組み込みのBoldと同じならrunをBoldにする
            run.FontWeight = Microsoft.UI.Text.FontWeights.Bold;
#endif
        }
        if (fontStyle == TextMateSharp.Themes.FontStyle.Italic)
        {
            // 同様にitalicにする
            run.FontStyle = Windows.UI.Text.FontStyle.Italic;
        }

        if (foreground != -1)
        {
            // textの色を取得し、runの文字色とする
            string hexString = theme.GetColor(foreground);
            run.Foreground = hexString.GetSolidColorBrush();
        }
        // BackGroundは単純にrunの色を変えるというのではなく、TextBlockの何文字目から何文字目が〇〇色のように指定していくため、めんどくさいのでつけてない
        // runを返す
        return run;
    }
}
// 一応別クラスにわけた
namespace CommunityToolkit.WinUI.Controls.MarkdownTextBlockRns.TextMate;
internal static class TextMateExtensions
{
    // 文字を決定する便利関数
    internal static string SubstringAtIndexes(this string str, int startIndex, int endIndex)
    {
        return str.Substring(startIndex, endIndex - startIndex);
    }
    // 色を決定する便利関数
    internal static SolidColorBrush GetSolidColorBrush(this string hex)
    {
        hex = hex.Replace("#", string.Empty);

        byte a = 255;
        int index = 0;

        if (hex.Length == 8)
        {
            a = (byte)(Convert.ToUInt32(hex.Substring(index, 2), 16));
            index += 2;
        }

        byte r = (byte)(Convert.ToUInt32(hex.Substring(index, 2), 16));
        index += 2;
        byte g = (byte)(Convert.ToUInt32(hex.Substring(index, 2), 16));
        index += 2;
        byte b = (byte)(Convert.ToUInt32(hex.Substring(index, 2), 16));
        SolidColorBrush myBrush = new SolidColorBrush(Windows.UI.Color.FromArgb(a, r, g, b));
        return myBrush;
    }
}

```

# MarkdownTextBlockの改造
MarkdownTextBlockの改造箇所は以下になります。
`components/MarkdownTextBlock/src/TextElements/CodeBlockElement.cs`
コードブロックの部分だけ直せば問題ないでしょう。
```diff_c_sharp
+using CommunityToolkit.WinUI.Controls.MarkdownTextBlockRns.TextMate;
using Markdig.Syntax;
+using TextMateSharp.Grammars;

namespace CommunityToolkit.Labs.WinUI.MarkdownTextBlock.TextElements;

internal class MyCodeBlock : IAddChild
{
    private CodeBlock _codeBlock;
    private Paragraph _paragraph;
    private MarkdownConfig _config;

    public TextElement TextElement
    {
        get => _paragraph;
    }

    public MyCodeBlock(CodeBlock codeBlock, MarkdownConfig config)
    {
        _codeBlock = codeBlock;
        _config = config;
        _paragraph = new Paragraph();
        var container = new InlineUIContainer();
        var border = new Border();
+       border.Background = new (Brush)Application.Current.Resources["ExpanderHeaderBackground"];
        border.Padding = _config.Themes.Padding;
        border.Margin = _config.Themes.InternalMargin;
        border.CornerRadius = _config.Themes.CornerRadius;
        var richTextBlock = new RichTextBlock();
+       var themeName = _config.Themes.CodeBlockThemeName; //指定したテーマを与える
+       var fontFamiry = _config.Themes.CodeBlockFontFamily; //指定したフォントファミリーを与える

        if (codeBlock is FencedCodeBlock fencedCodeBlock)
        {
//#if !WINAPPSDK
//            var formatter = new ColorCode.RichTextBlockFormatter(Extensions.GetOneDarkProStyle());
//#else
//            var formatter = new ColorCode.RichTextBlockFormatter(Extensions.GetOneDarkProStyle());
//#endif
            // コンストラクタを呼び出し、テーマ、文法、フォントファミリーを設定
+           var formatter = new TextMateFormatter(_config.Themes.CodeBlockThemeName, Extensions.ToExtension(fencedCodeBlock),fontFamiry);

-            //var stringBuilder = new StringBuilder();

            // go through all the lines backwards and only add the lines to a stack if we have encountered the first non-empty line
            var lines = fencedCodeBlock.Lines.Lines;
            var stack = new Stack<string>();
            var encounteredFirstNonEmptyLine = false;
            if (lines != null)
            {
                for (var i = lines.Length - 1; i >= 0; i--)
                {
                    var line = lines[i];
                    if (String.IsNullOrWhiteSpace(line.ToString()) && !encounteredFirstNonEmptyLine)
                    {
                        continue;
                    }

                    encounteredFirstNonEmptyLine = true;
                    stack.Push(line.ToString());
                }
                
                // 先ほど作成したTextMateの処理を呼び出す
                formatter.FormatRichTextBlock(stack, richTextBlock);
                
-               //// append all the lines in the stack to the string builder
-               //while (stack.Count > 0)
-               //{
-               //    stringBuilder.AppendLine(stack.Pop());
-               //}
            }

            //formatter.FormatRichTextBlock(stringBuilder.ToString(), fencedCodeBlock.ToLanguage(), richTextBlock);
        }
        else
        {
            foreach (var line in codeBlock.Lines.Lines)
            {
                var paragraph = new Paragraph();
                var lineString = line.ToString();
                if (!String.IsNullOrWhiteSpace(lineString))
                {
                    paragraph.Inlines.Add(new Run() { Text = lineString });
                }
                richTextBlock.Blocks.Add(paragraph);
            }
        }
        border.Child = richTextBlock;
        container.Child = border;
        _paragraph.Inlines.Add(container);
    }

    public void AddChild(ITextElement child) { }
}
```
これで`TextMateSharp`を使用してMarkdownのコードブロックにSyntaxHighlightがつけられるようになりました。

# おまけ、テーマや、文法を決める設定
上記のコードのテーマ名やフォントファミリー、文法用に必要な拡張子を以下の方法で設定しています。
`MarkdownTextBlock/src/MarkdownThemes.cs`にて
```diff_c_sharp
public sealed class MarkdownThemes : DependencyObject
{
    .
    .
    .
    public Thickness InlineCodeBorderThickness { get; set; } = new (1);

    public CornerRadius InlineCodeCornerRadius { get; set; } = new (2);

    public Thickness InlineCodePadding { get; set; } = new(0);

    public double InlineCodeFontSize { get; set; } = 10;

    public FontWeight InlineCodeFontWeight { get; set; } = FontWeights.Normal;

    public Brush CodeBlockBackground { get; set; } = (Brush) Application.Current.Resources["ExpanderHeaderBackground"];
    // Themeの指定
+   public ThemeName CodeBlockThemeName { get; set; } = ThemeName.LightPlus;
    // FontFamilyの設定
+   public FontFamily CodeBlockFontFamily { get; set; } = new FontFamily("Consolas");
}
```
文法を決めるための拡張子は
`MarkdownTextBlock/src/Extensions.cs`に以下を追加
```csharp
    public static string ToExtension(this FencedCodeBlock fencedCodeBlock)
    {
        switch (fencedCodeBlock.Info?.ToLower())
        {
            case "cs":
            case "csharp":
            case "c#":
                return ".cs";
            case "xhtml":
            case "html":
            case "hta":
            case "htm":
            case "html.hl":
            case "inc":
            case "xht":
                return ".html";
            case "java":
            case "jav":
            case "jsh":
                return ".java";
            case "js":
            case "node":
            case "_js":
            case "bones":
            case "cjs":
            case "njs":
            case "pac":
            case "sjs":
            case "ssjs":
            case "xsjs":
            case "xsjslib":
                return ".js";
            .
            . 割愛
            .
            case "python":
            case "py":
            case "cgi":
            case "gyp":
            case "gypi":
            case "lmi":
            case "py3":
            case "wsgi":
            case "xpy":
                return ".py";
            //case "matlab":
            //case "m":
            //    return Languages.MATLAB;
            default:
                return ".js";
        }
    }
```
これにより、Markdownで書いた```〇〇を読み取り、拡張子に変換し、文法を決定します。

# Local Nuget
一応作ったものは以下のgitHubリンクにあります。

https://github.com/isiskj/Labs-Windows

ここにlocalで作ったnugetも入れておきますので、作るのめんどくさい場合はこれを素直に使ってください。

[NugetPackage](https://github.com/isiskj/Labs-Windows/blob/main/CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock.0.1.241104.nupkg)