📁

UnityのProjectWindowに表示履歴機能を付けるエディタ拡張

2023/12/09に公開

この記事はAkatsuki Games Advent Calendar 2023 9日目の記事です。

昨日はitmさんの「スマートロック sesame を line から開閉してみた」でした。日常をハックしている感じがエンジニア心をくすぐられました。スクリーンショットやコードも多く、分かりやすかったです!

ProjectWindowに表示履歴機能を付けたい

UnityのProjectWindowはフォルダ構造でアセットを管理する、簡単に言えばMacのFinder(Windowsのエクスプローラー)のようなウィンドウです。

このProjectWindowを使っている最中に、 「さっき開いていたフォルダに戻りたい」 と思う場面はないでしょうか。

MacのFinderでは、前回開いていたフォルダに戻りたい場合は左上の「<」「>」ボタンで戻れます。

しかし、UnityのProjectWindowにはこの機能が用意されていません。

Finderライクな表示履歴の操作ができれば、より便利にUnityのProjectWindowを使えるだろうということで、エディタ拡張で作ってみました。

成果物

  • ボタン左クリックでProjectWindowの表示フォルダ検索の履歴を辿れる
  • ボタン右クリックで履歴一覧が確認でき、項目をクリックでその履歴にジャンプできる
  • ProjectWindowを複数開いている場合は、各ProjectWindowごとに履歴を保持する

こちらで公開しています。UPM対応してます。
https://github.com/Yusuke57/ProjectWindowHistory

実装詳細はリポジトリの方を見てもらえればと思いますが、要点だけざっくりと紹介していきます。

ボタンの作成

ProjectWindow上にボタンを生成しています。

ツールバーなどはUIToolkitで作られており特定の場所にボタンを追加したりしやすいのですが、ProjectWindowは残念ながら外枠しかUIToolkitで作られていません。

↓UIToolkitDebuggerで確認してみるとこれだけしかない

そのため、 座標を指定してrootVisualElementに無理やり配置 しています。

private void CreateButton()
{
    // 無理やり座標指定
    // 検索フィールドとその右のボタン群の幅が大体430ぐらいなので、440空けた座標にボタン生成
    const float buttonWidth = 20f;
    const float buttonMarginRight = 440f;

    _undoButton = new Button(Undo)
    {
        text = "<",
        focusable = false,
        style =
        {
            width = buttonWidth,
            position = new StyleEnum<Position>(Position.Absolute),
            right = buttonMarginRight + buttonWidth
        }
    };
    _redoButton = new Button(Redo)
    {
        text = ">",
        focusable = false,
        style =
        {
            width = buttonWidth,
            position = new StyleEnum<Position>(Position.Absolute),
            right = buttonMarginRight
        }
    };

    // 自身のEditorWindowのrootにボタンを追加
    _projectWindow.rootVisualElement.Add(_undoButton);
    _projectWindow.rootVisualElement.Add(_redoButton);
}

履歴クラスの定義

今回履歴として残したいのは主に 「選択中のフォルダ」「検索文字列」 です。(実際には「検索範囲」もですが、説明を簡単にするため省きます)

[Serializable]
public class ProjectWindowHistoryRecord
{
    [SerializeField] private int[] _selectedFolderInstanceIds;
    [SerializeField] private string _searchedText;

    public int[] SelectedFolderInstanceIDs => _selectedFolderInstanceIds;
    public string SearchedText => _searchedText;
}

このような履歴レコードのクラスを作成しました。
ProjectWindowごとにProjectWindowHistoryRecordのリストを作り、履歴レコードを管理します。

選択フォルダを履歴に追加

ここからは主に リフレクション を利用します。
Unityエディタのソースコードは一部公開されているので、その中のProjectBrowser.csを中心に参考にしながら実装していきました。

選択中のフォルダは m_LastFolders というフィールドから取得できるので、

// ProjectBrowserクラスのTypeを取得
var projectBrowserType = Type.GetType("UnityEditor.ProjectBrowser,UnityEditor");

// ProjectBrowserクラスのm_LastFoldersのFieldInfoを取得
var lastFoldersField = projectBrowserType?.GetField("m_LastFolders", BindingFlags.NonPublic | BindingFlags.Instance);

// targetProjectWindow(自身のEditorWindowインスタンス)のm_LastFoldersの値を取得
// 複数選択している場合もあるので、配列で返ってくる
var lastFolderPaths = (string[]) (lastFoldersField?.GetValue(targetProjectWindow) ?? Array.Empty<string>());

のようにすることで、選択中のフォルダパスを取得できます。

これが前フレームの値と異なっていれば履歴に追加するようにしました。

検索文字列/検索範囲を履歴に追加

検索文字列も同様に m_SearchFieldText というフィールドに値が入っているので、リフレクションで取得します。

// ProjectBrowserクラスのm_SearchFieldTextのFieldInfoを取得
var searchFieldTextField = projectBrowserType?.GetField("m_SearchFieldText", BindingFlags.NonPublic | BindingFlags.Instance);

// targetProjectWindow(自身のEditorWindowインスタンス)のm_SearchFieldTextの値を取得
var searchedText = searchFieldTextField?.GetValue(targetProjectWindow);

タイピング中の文字列変化を全て履歴に追加するのは意図に反しているので、こちらは一定時間文字列に変化がなかった場合に、入力完了とみなして履歴に追加するようにしました。
ここら辺は結構雑な実装になっているので、まだまだ改善の余地がありそうです。

履歴の復元

履歴ボタンが押された場合、対応するProjectWindowHistoryRecordをProjectWindowに適用します。

選択中フォルダの適用は

internal void SetFolderSelection(int[] selectedInstanceIDs, bool revealSelectionAndFrameLastSelected)

というメソッドがあるので、これをリフレクションで呼び出して適用します。

// SetFolderSelectionメソッドはオーバーロードが用意されているので、GetMethod("SetFolderSelection")ではエラーになる
// 一度ProjectBrowserクラスのメソッドListを取得して、メソッド名が「SetFolderSelection」かつ引数が2つの方を取ってくる
var setFolderSelectionMethod = ProjectBrowserType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)
    .FirstOrDefault(method => method.Name == "SetFolderSelection" && method.GetParameters().Length == 2);

// targetProjectWindowのSetFolderSelectionメソッドに引数「selectedFolderInstanceIds」「false」を渡して実行
setFolderSelectionMethod?.Invoke(targetProjectWindow, new object[] { selectedFolderInstanceIds, false });

検索文字列の方は一度SearchFilterクラスに変換してからSetSearchメソッドを実行する、など若干手間がかかりますが、リフレクションを用いて内部処理を実行するのは同様です。
この記事では説明省略するので、詳細はGitHubのソースコードを読んでいただければと思います。

履歴一覧表示

右クリックで履歴一覧を表示 できるようにします。

_undoButton.RegisterCallback<MouseDownEvent>(evt =>
{
    if (evt.button == 1) // 右クリック
    {
        // Undo履歴一覧を表示
    }
});

のような形でボタンを右クリックしたときの処理を書けます。

履歴一覧表示はGenericMenuを使うと簡単に作れます。

var menu = new GenericMenu(); // メニュー作成
for (var i = 0; i < undoRecords.Count; i++)
{
    var labelText = undoRecords[i].ToLabelText();
    var operationCount = i + 1;
    menu.AddItem(new GUIContent(labelText), false, () =>
    {
        // 項目が選択されたときの処理
        // operationCount回、Undoを実行する
    });
}
menu.ShowAsContext(); // メニュー表示

成果物再掲

かなりざっくりとだけ説明しましたが、こんな感じでProjectWindowの表示履歴機能が実装できました。
UPM経由でサクッと導入できるので、よければ使ってください!

https://github.com/Yusuke57/ProjectWindowHistory

最後に

明日はtomotaka-yamasakiさんの自動テストに関する記事です。自動テストは年々アツくなっている分野だと感じていて、とても楽しみです!

最後に、アカツキゲームスでは一緒に働くエンジニアを募集しています。
まずはカジュアルに美味しいご飯を食べに行きましょう!
https://herp.careers/v1/aktskgames/requisition-groups/47f46396-e08a-4b2f-8f9b-b2fc79e63b83

Discussion