凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity のエディタ拡張でシンタックスハイライト対応の TextArea を作成してみた

はじめに

uRaymarching を作る上でシンタックスハイライト付きのコードエディタが欲しくて色々試してみましたので、その内容を共有します。

デモ

こんな感じのシンタックスハイライト付きテキストエリアが作れます。

f:id:hecomi:20161013204328g:plain

カラーコードを意識したりフォントを変更すると味わい深くなります。

f:id:hecomi:20161013204808p:plain

方針

はじめは以下のエントリを参考にしようと考えていました。

このコードでは、背後に透明なテキストで TextArea を描画した上に背景が透明で単語がシンタックスハイライトされた TextField を描画する形式でした。具体的には、単語の位置やキーコントロールなど内部情報を持っている TextEditor クラスのインスタンスGUIUtility.GetStateObject() で取得して、それを通じて単語の位置を取得し、該当箇所にシンタックスハイライトさせた TextField を描画する、という形です。

ただ、もっと簡単に出来るんじゃないかなと思って改造したのが、上に載せるシンタックスハイライト用の TextArea を用意して 2 枚の TextArea が重なっているような形にする方法です。下側が編集用の透明エリア(背景は不透明、文字が透明)で上記コードと同じなのですが、上側は表示用の色付きエリア(背景は透明、文字が色付き)という構成にし、シンタックスハイライトはリッチテキストで表現する、という方法です。

ちなみにリッチテキストだけした文字を表示するとキャレットの位置が変になってしまうという問題と、hoge と見えている文字の e の後ろにキャレットがあるときに一文字消すと <color=red>hoge</color という感じでシンタックスハイライト用のコードも直接見えてしまうという問題があって、2 枚重ねる必要があります。

コード

では具体的にコードを見てみます。まず、次のようなエディタ部分のコードを書いてみます。

using UnityEngine;
using UnityEditor;

public class CodeEditor
{
    public Color backgroundColor { get; set; }
    public Color textColor { get; set; }

    // ハイライト用の関数
    public System.Func<string, string> highlighter { get; set; }

    // 表示高速化の為に変更があった時だけコードを更新
    string cachedHighlightedCode { get; set; }

    public CodeEditor()
    {
        backgroundColor = Color.black;
        textColor = Color.white;
        highlighter = code => code;
    }

    public string Draw(string code, GUIStyle style, params GUILayoutOption[] options)
    {
        // 現在の色を保存
        var preBackgroundColor = GUI.backgroundColor;
        var preColor = GUI.color;

        // 文字を透明にする
        var backStyle = new GUIStyle(style);
        backStyle.normal.textColor = Color.clear;
        backStyle.hover.textColor = Color.clear;
        backStyle.active.textColor = Color.clear;
        backStyle.focused.textColor = Color.clear;

        // 背景を色付きにする
        GUI.backgroundColor = backgroundColor;

        // 編集用のテキストエリアを描画
        var editedCode = EditorGUILayout.TextArea(code, backStyle, options);

        // シンタックスハイライトさせたコードを更新
        if (string.IsNullOrEmpty(cachedHighlightedCode) || (editedCode != code)) {
            cachedHighlightedCode = highlighter(editedCode);
        }

        // 背景を透明にする
        GUI.backgroundColor = Color.clear;

        // 文字(シンタックスハイライトされない部分)を指定色にする
        var foreStyle = new GUIStyle(style);
        foreStyle.normal.textColor = textColor;
        foreStyle.hover.textColor = textColor;
        foreStyle.active.textColor = textColor;
        foreStyle.focused.textColor = textColor;

        // リッチテキストを ON にする
        foreStyle.richText = true;

        // シンタックスハイライト用のテキストエリアを表示
        EditorGUI.TextArea(GUILayoutUtility.GetLastRect(), cachedHighlightedCode, foreStyle);

        // 色を元に戻す
        GUI.backgroundColor = preBackgroundColor;
        GUI.color = preColor;

        return editedCode;
    }
}

highlighter という関数でシンタックスハイライト用のコードを生成して上に重ねて表示しています。また、毎回シンタックスハイライトしてると highlighter の処理が重いときにパフォーマンスが落ちるので、必要がある時のみ更新しているのがミソです。その為、通常の TextArea のように関数にすることはできず、状態を保存するためにクラスにする必要があります。

次に利用側を見てみます。

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(Test))]
public class TestEditor : Editor
{
    CodeEditor editor;
    Vector2 scrollPos;
    string code = "";

    void OnEnable()
    {
        editor = new CodeEditor();
        editor.backgroundColor = Color.blue;
        editor.textColor = Color.white;
        editor.highlighter = Highlight;
    }

    public override void OnInspectorGUI()
    {
        var style = new GUIStyle(GUI.skin.textArea);
        style.padding = new RectOffset(6, 6, 6, 6);
        style.fontSize = 12;
        style.wordWrap = false;

        scrollPos = EditorGUILayout.BeginScrollView(scrollPos, GUILayout.Height(200));
        code = editor.Draw(code, style, GUILayout.ExpandHeight(true));
        EditorGUILayout.EndScrollView();
    }

    string Highlight(string code) 
    {
        return code.Replace("float", "<color=red>float</color>");
    }
}

float という文字だけ赤くするようにしています。

f:id:hecomi:20161013201619g:plain

highlighter正規表現で処理すれば複雑なシンタックスも簡単に処理できるようになります。次のような Syntax.Highlight() を用意して highlighter に登録してみます。これで冒頭のデモになります。

using System.Collections.Generic;
using System.Text.RegularExpressions;

public static class Syntax
{
    static Regex regex;
    static MatchEvaluator evaluator;

    [UnityEditor.InitializeOnLoadMethod]
    private static void Init()
    {
        var type    = @"(float|int|double|void)";
        var symbol  = @"[{}()=;,+\-*/<>|]+";
        var digit   = @"(?<![a-zA-Z_])[+-]?[0-9]+\.?[0-9]?(([eE][+-]?)?[0-9]+)?";
        var comment = @"/\*[\s\S]*?\*/|//.*";

        var block = "(?<{0}>({1}))";
        var pattern = "(" + string.Join("|", new string[] { 
            string.Format(block, "comment", comment),
            string.Format(block, "type",    type),
            string.Format(block, "symbol",  symbol),
            string.Format(block, "digit",   digit),
        }) + ")";

        regex = new Regex(pattern, RegexOptions.Compiled);

        var colorTable = new Dictionary<string, string>() {
            { "type",    "#ff0000" },
            { "symbol",  "#ff00ff" },
            { "digit",   "#00ff00" },
            { "comment", "#555555" },
        };

        evaluator = new MatchEvaluator(match => { 
            foreach (var pair in colorTable) {
                if (match.Groups[pair.Key].Success) {
                    return string.Format("<color={1}>{0}</color>", match.Value, pair.Value);
                }
            }
            return match.Value;
        });
    }

    public static string Highlight(string code) {
        return regex.Replace(code, evaluator);
    }
}

パフォーマンス改善のために RegexMatchEvaluator はキャッシュしています。より複雑な例(2個めのデモ)は次のように書いています。

問題点

本当は uREPL でやったようにキーバインドつきのエディタを作成したかったのですが、ダメでした...。というのも、方針の項で述べた TextAreaGUI.TextArea で、機能が最低限しか実装されておらず、Ctrl + C、Ctrl + V によるコピペといったキー操作が出来なかったりします。GUILayout.TextArea も同じで、こちらは Tab キーが奪われてしまう分更に良くありません。しかしながら EditorGUI 系の EditorGUI.TextArea または EditorGUILayout.TextArea だと、今度はキー操作は出来る反面、TextEditor クラスが取得できない(正確には取得できるが内部に何ら情報が格納されていない)状態になってしまいます。

これらを改善するには、GUI.TextArea で自前でキー操作を実装する必要があるのですが、面倒なので未だ手を付けていないです。元気があるときにヤッてみようと思います。

おわりに

なくても困らない機能ですが、あるとミスを防げたり見た目にもかっこいいのでおすすめです。