Unityエディターそのものを拡張する　第2回公式EditorWindow拡張編

# はじめに
この記事は「Unityエディターそのものを拡張して作業を効率化する」から派生している全3回のうち、第2回目の記事です。
他の記事も気になる場合は[こちら](https://qiita.com/marv_nakazawaka/items/cf9129002420b4a014c9)からご覧ください。

この記事ではUnityの公式のEditorWindowを拡張して独自のUIを追加する方法として、具体的に**プロジェクトウィンドウを拡張する例**を紹介します。

ここで紹介しているツールはUnity2021.3.9f1を使用しています。
（検証していませんが、おそらくUIElementsがエディターで導入された2019.x以降であれば動作すると思います。）

# この拡張を行う以前の編集権限の管理の方法
元々このツールが導入される前は**コンフリクトを避けるためChatworkにリソース管理窓**という部屋を作り、そこで編集したいファイル名を書いたタスクを自分に割り当てる運用を行っていました。
**編集したいファイルがあれば他の人のタスクを確認して、だれもファイル名を書き込んでなければ自分に割り当てると**いったフローです。<br>
**イメージ画像**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118525/307d4434-6388-7a12-c19b-967e8ad8c272.png)
この運用方法では以下のような問題点がありました。
1. **編集したいファイルを他に人が触っていないかChatworkで目視して確認する必要がある。**
場合によっては十数個以上はあるタスクの１行１行を確認する必要があり大変なのと、見落とす可能性があるのでコンフリクトのリスクは残りました。
1. **編集したいファイル名を自分で書き込む必要がある。**
特に一度に沢山のファイルを編集したい場合に手作業で打ち込むのは手間になっていました。
3. **Unityでの作業中にChatworkと行ったり来たりする必要がある。**
作業自体は数十秒程度のことであっても本来の作業に集中できないため、純粋な作業時間以外にも集中力のロスも生まれます。

プロジェクトウィンドウを拡張することで、これらの問題を解決することが可能です。

:::note info
元々ファイル名をタスク化する運用をしていたので、ファイルのGUIDをファイル名に変換する処理をしていますが、一から実装する場合は直接GUIDで管理を行ってしまってもいいと思います。
（ChatworkにGUIDで表示されるので編集中のファイルがわからなくなりますが、Unityで作業が完結するのでわざわざChatworkを確認する必要が無くなりました。）
:::

# 具体的な実装の解説
## 1. 下準備
まず**リソース管理を行うためのChatworkの部屋**と、タスクの付与や解放をさせる**ボットのようなチャットワークアカウント**が必要になります。（この記事ではそれぞれ"リソース管理部屋"と"ボットアカウント"と呼ぶことにします。）
業務ではボットアカウントはビルドマシンとして使用しているものを使っていました。

次にツールでは[Chatwork API](https://developer.chatwork.com/reference)を使用するため、以下の情報が必要になります。
1. **APIトークン**
ボットアカウントのAPIトークンです。ボットアカウントでログインし、右上の名前を選ぶことで表示されるドロップダウンから「サービス連携」を選びます。開いた画面でAPIトークンというタブがあるので、それを選ぶと表示される文字列を控えておいてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118525/3ab9a45c-880b-1359-9ec8-e727b5cd8055.png)
1. **リソース管理部屋のルームID**
リソース管理部屋を開き、URLの”#!rid”に続く数字がルームIDになります。
（管理者であればグループチャットの設定から確認することも可能です。）
1. **タスクの付与先となる各ユーザーのアカウントID**
APIトークンを調べた時と同様に、Chatwork右上の名前を押して表示される「環境設定」を選びます。
表示されるダイアログの「Chatworkについて」タブを選び、アカウントIDに書かれている数字になります。
（メンションを付けるときにTo:に続く数字と同じです。）
これは各利用者ごとに控えていただく必要があります。

このうち、トークンとルームIDはプロジェクトで共通なのでProject Settingsに設定するための項目を追加して、責任者となる人が設定を行います。アカウントIDはそれぞれのメンバーで異なるため、Preferenceを拡張して各々で設定をお願いする仕組みにしました。

## 2. 選択中のファイルがChatworkのタスクにあるか確認し、なければ自分にタスク付けする。
[**チャットのタスク一覧を取得する**](https://developer.chatwork.com/reference/get-rooms-room_id-tasks)
こちらのAPIでリソース管理窓のタスク一覧を取得してくることが可能です。
レスポンスとして返ってきた内容を解析し、ユーザーと編集権を確保しているファイルのリストのテーブルを作成します。
```cs
// configはProjectSetttingsで設定したルームIDやAPIトークンを保持しています。
var url = $"https://api.chatwork.com/v2/rooms/{config.RoomId}/tasks";
using var req = UnityWebRequest.Get(url);
req.SetRequestHeader("X-ChatWorkToken", config.Token);
```

その中に自分が確保しようとしたファイルが含まれていなければ、自分の確保しているファイルに追加して、いったん既存の自分のタスクを完了させたうえで、新しいタスクを追加します。
[**タスクを完了する**](https://developer.chatwork.com/reference/put-rooms-room_id-tasks-task_id-status)
```cs
var url = $"https://api.chatwork.com/v2/rooms/{config.RoomId}/tasks/{task_id}/status";
using var req = UnityWebRequest.Put(url, "body=done");
req.SetRequestHeader("X-ChatWorkToken", config.Token);
```
[**タスクを追加する**](https://developer.chatwork.com/reference/post-rooms-room_id-tasks)
```cs
var url = $"https://api.chatwork.com/v2/rooms/{config.RoomId}/tasks";
var form = new WWWForm();

form.AddField("body", bodyText);  // bodyTextに確保するリソースの情報を書き込んでおいてください。
form.AddField("to_ids", config.AccountId);
using var req = UnityWebRequest.Post(url, form);
req.SetRequestHeader("X-ChatWorkToken", config.Token);
```

## 3. 選択中のファイルを解放する
この場合も2.2で行ったのと同様に、現在自分の確保しているファイルのリストから選択中のファイルを解放し、いったん自分のタスクを完了させたうえで、新しくタスクを追加させていました。

## 4. プロジェクトウィンドウに独自のUIを追加して、ボタンを押すことでファイルの編集権の確保や解放が出来るようにする。
まずProjectウィンドウのインスタンスを取得します。
ただこれは公開されていないクラスなのでリフレクションで取得してきます（クラス名はProjectBrowserです）。ProjectBrowserはEditorWindowを継承しており、EditorWindowのメンバーのrootVisualElementにアクセスできれば十分なのでキャストします。
このrootVisualElementの下に独自のUIを追加していきます。
```cs:ProjectBrowserExtender.cs
[InitializeOnLoad]
public static class ProjectBrowserExtender
{
    private static EditorWindow currentProjectBrowser;

    static ProjectBrowserExtender()
    {
        EditorApplication.update -= OnUpdate;
        EditorApplication.update += OnUpdate;
    }

    private static void OnUpdate()
    {
        if (currentProjectBrowser == null)
        {
            DrawResourceAuthorityCommands();
        }
    }

    public static void DrawResourceAuthorityProjectViewCommand()
    {
        var projectBrowsers =
            Resources.FindObjectsOfTypeAll(typeof(Editor).Assembly.GetType("UnityEditor.ProjectBrowser"));
        var firstProjectBrowserEditor = projectBrowsers.FirstOrDefault();

        if (firstProjectBrowserEditor == null)
            return;

        currentProjectBrowser = firstProjectBrowserEditor as EditorWindow;

        foreach (var projectBrowser in projectBrowsers.OfType<EditorWindow>())
        {
            projectBrowser.rootVisualElement.Clear();
            var resourceAuthorityCommands = new ResourceAuthorityProjectBrowserCommands();
            resourceAuthorityCommand.Initialize();
            projectBrowser.rootVisualElement.Add(resourceAuthorityCommand);
        }
    }
}
```
独自のUIには下の画像のようなものを追加しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118525/071a1511-79ba-3b85-5c34-80fba1b3f4b7.png)
これらのボタンは左から順番に以下の機能を使えます。
- ↺ボタン　 ：ファイルの編集権限の取得状況更新する（後述の項目に権限を持っている人の情報を出すのに使います。）
- ×ボタン　 ：ファイルの編集権限の取得状況破棄する
- 確保ボタン：選択中のファイルの編集権限の取得を試みる
- 解放ボタン：選択中のファイルの編集権限を解放する

（独自のUIはUI Builderで作成しましたが、これくらいであればC#で完結させてしまってもよかった気がしています。）
これを機能する用にすためのコードはこちらです。
```cs:ResourceAuthorityProjectBrowserCommands.cs
public class ResourceAuthorityProjectBrowserCommands : VisualElement
{
    // 独自のUIのUXMLファイルが置かれているパスを指定する
    private static readonly string UXML_PATH = "Assets//ResourceAuthority/ProjectBrowserCommands.uxml";
    
    public void Initialize()
    {
        var visualTreeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UXML_PATH);
        visualTreeAsset.CloneTree(this);
        // プロジェクトウィンドウの右下に表示されるようにする。
        style.flexGrow = 1.0f;
        style.position = new StyleEnum<Position>(Position.Absolute);
        style.right = 0;
        style.bottom = 20;  // ファイルアイコンの大きさを変えるスライダーと被らないようにちょっと上げる。

        commandRoot = this.Q("CommandRoot");

        // ファイルの編集権限の取得状況を更新する
        refreshButton = commandRoot.Q<Button>("RefreshButton");
        refreshButton.clickable.clicked += () => ResourceAuthorityManager.RefreshAuthorities();

        // ファイルの編集権限の取得状況を破棄する
        clearButton = commandRoot.Q<Button>("ClearButton");
        clearButton.clickable.clicked += ResourceAuthorityManager.ClearAuthorities;

        // 現在選択中のファイルの編集権限の取得を試みる
        lockButton = commandRoot.Q<Button>("LockButton");
        lockButton.clickable.clicked += ResourceAuthorityManager.LockResourcesByChatwork;

        // 現在選択中のファイルの編集権限を解放する
        unlockButton = commandRoot.Q<Button>("UnlockButton");
        unlockButton.clickable.clicked += ResourceAuthorityManager.UnlockResourcesByChatwork;

        // 以降に折りたたんだり、展開したりする処理
    }
}
```

## 5. プロジェクトウィンドウの項目に現在権限を持っている人の情報を表示する。
プロジェクトウィンドウの項目の描画時にEditorApplication.projectWindowItemOnGUIにコールバックを追加することで独自の処理を追加することが可能です。
ここにファイルの編集権限を持っている人を検索し、自分なら青丸、他の人なら黄色い丸とその人の名前が表示されるようにします。
なおこのコードではプロジェクトウィンドウ右下のスライダーで最小にしたときの行の表示の時のみ情報を表示しています。

::: note warn
サンプルコードではコンパクトにするために都度計算してメソッド内のローカル変数に代入している処理がありますが、このイベントは項目が描画されるたびに実行されるので、メンバー変数に適宜キャッシュするなどして使いまわすのを推奨します。
:::

```cs:ProjectBrowserExtender.cs
[InitializeOnLoad]
public static class ProjectBrowserExtender
{
    static ProjectBrowserExtender()
    {
        EditorApplication.projectWindowItemOnGUI -= DrawResourceAuthorityInfo; // 追加
        EditorApplication.projectWindowItemOnGUI += DrawResourceAuthorityInfo; // 追加

        EditorApplication.update -= OnUpdate;
        EditorApplication.update += OnUpdate;
    }

    private static void DrawResourceAuthorityInfo(string guid, Rect selectionRect)
    {
        var path = AssetDatabase.GUIDToAssetPath(guid);

        // パス名が空、または項目が最小の表示ではない（アイコンの場合）なら何もしない。
        if (String.IsNullOrWhiteSpace(path) || selectionRect.height > 20)
            return;

        var fileName = Path.GetFileName(path);
        var ownerName = ResourceAuthorityManager.GetOwnerName(fileName);

        // 誰も編集権限を持っていなければ何も表示させない
        if (String.IsNullOrEmpty(ownerName))
            return;

        var myResourceLabelStyle = new GUIStyle(EditorStyles.label)
        {
            fontStyle = FontStyle.Bold,
            normal = new GUIStyleState {textColor = Color.blue},
        };
        var lockedResourceLabelStyle = new GUIStyle(EditorStyles.label)
        {
            fontStyle = FontStyle.Bold,
            normal = new GUIStyleState {textColor = Color.yellow},
        };
        lockedResourceLabelStyle.CalcMinMaxWidth(new GUIContent(" "), out var whiteSpaceWidth, out _);

        // 丸を出す位置は引数で渡されるRectから少し左にずらすとファイル名と被らない
        var resourceInfoRect = selectionRect;
        resourceInfoRect.x -= 10;

        var infoTextBuilder = new StringBuilder();
        infoTextBuilder.Append("●");

        // 編集権限の所有者が自分だったら青丸を、そうでなかったら黄色い丸とその人の名前を表示させる
        if (ResourceAuthorityManager.IsMine(ownerName))
        {
            GUI.Label(resourceInfoRect, infoTextBuilder.ToString(), myResourceLabelStyle);
        }
        else
        {
            fileNameContent.text = Path.GetFileNameWithoutExtension(fileName);
            // ファイル名にアイコン等の分の長さを足しておく。
            lockedResourceLabelStyle.CalcMinMaxWidth(fileNameContent, out var _, out var fileNameWidth);
            fileNameWidth += 35.0f;
            // スペースがなぜか文字列にすると幅が狭くなるので補正を掛ける
            var whileSpace_Count = Mathf.CeilToInt(fileNameWidth / (whiteSpaceWidth / 1.6f));
            infoTextBuilder.Append(' ', whileSpaceCount).Append($"{ownerName}さん");

            GUI.Label(resourceInfoRect, infoTextBuilder.ToString(), lockedResourceLabelStyle);
        }
    }
}
```

# おまけ
## プロジェクトウィンドウ以外の公式EditorWindowを拡張する場合
他のEditorWindowもリフレクションでEditorWindowを取得することで拡張が可能です。

リフレクションで取得するためのクラスやメンバーの名前を調べるには[UnityCsReference](https://github.com/Unity-Technologies/UnityCsReference)のリポジトリが参考になります。
ちなみにクラス名だけなら後述するUI Toolkit Debuggerを見る方が早いです。
- **ツールバー：Toolbar**
  https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/GUI/Toolbar.cs
  ※ツールバーはEditorWindowを継承していません。第3回ツールバー拡張編を参考にしてください。
- **プロジェクトウィンドウ：ProjectBrowser**
  https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/ProjectBrowser.cs
- **ヒエラルキーウィンドウ：SceneHierarchyWindow**
  https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/SceneHierarchy.cs
- **インスペクターウィンドウ：InspectorWindow**
  https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/Inspector/InspectorWindow.cs
- **コンソールウィンドウ：ConsoleWindow**
  https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/ConsoleWindow.cs

リフレクションで取得したルートとなるVisualElementの下に独自のビジュアルエレメントを追加していけばいいのですが、ToolbarのようにUnity側で追加しているビジュアルエレメントがある場合は、どのビジュアルエレメントにぶら下げていけばいいか調べる必要があります。（ルートにぶらさげていくと、公式のUIが隠れたり、独自のUIが公式のUIで隠れてしまうので）
このビジュアルエレメントの名前を調べるのにはUI Toolkit Debuggerを使うといいです。
各種EditorWindowの右上にあるケバブメニューからUI Toolkit Debuggerを選び、対象のUIを選ぶドロップダウンから目的のものを選んで確認することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118525/ec7fe360-7744-d1d0-d869-97737cb26ffa.png)