Skip to content

Tool window ja

Tom_XV edited this page Sep 23, 2026 · 7 revisions

English | 日本語

Tool window ライブラリ(DragNWash.ModFramework.ToolWindow.dll、名前空間 DragNWash.ModFramework.ToolWindow)は、開発・デバッグ用のツールを置くための、ゲーム内の共通ウィンドウです。F1 で開き(キーはプレイヤーが設定で変えられます)、Mod ごとに自分のタブを足せます。中核 1.2.0 からは Developer tools(Options → Mods → Drag'n Wash ModFramework)がオンのときしか開かないので、Mod を入れただけの人の目に触れることはありません。Open を呼んでも断られて、ログに 1 行出るだけです。

[BepInDependency(ToolWindow.Guid, BepInDependency.DependencyFlags.HardDependency)]

ウィンドウは Unity の IMGUI で描いていて、このゲームで IMGUI を使うときの厄介なところはライブラリが引き受けます。ゲームパッドがあるとゲームがカーソルをロックするのでそれを外し、ウィンドウの上のクリックやドラッグがゲームに届かないようにし、ゲームパッドや Steam Deck のトラックパッドで押したらクリックになるようにし、スティックでスクロールできるようにし、日本語や中国語の文字を持つフォントがシステムにあればそれで描きます。Steam Deck やゲームパッドでは、ウィンドウの上で押したのを本物の左ボタンとして、スティックを本物のホイールとして、十字キーを本物の矢印キーとしてシステムに送り返しています。なので、入力欄やドラッグ、スライダー、スクロールバーも、矢印キーでたどる一覧も、そこでちゃんと使えます。

タブを足す

private IDisposable _tab;
private Vector2 _scroll;

private void Awake()
{
    _tab = ToolWindow.AddTab(MyMod.Guid, "My Mod", DrawTab, order: 200);
}

private void DrawTab(Rect area)
{
    var styles = ToolWindow.Styles;
    GUI.Label(new Rect(area.x, area.y, area.width, ToolWindow.RowHeight), "Scrub speed", styles.Label);

    if (GUI.Button(new Rect(area.x, area.y + ToolWindow.RowHeight + 8, 160, ToolWindow.RowHeight), "Reset", styles.Button))
    {
        ResetSpeed();
        ToolWindow.ShowNotice("Speed reset.");
    }
}

private void OnDestroy() => _tab?.Dispose();
  • draw は OnGUI から呼ばれ、ウィンドウ座標でのタブの内容領域が渡されます
  • タブは order の順に並び、同じなら追加した順です。タイトルは短く、ASCII にしてください
  • draw で例外が起きても、GUID つきでログに出てタブにエラーが表示されるだけで、ウィンドウは壊れません
  • GUI.skin ではなく ToolWindow.Styles(Label、MutedLabel、WrappedLabel、LogLabel、Button、SelectedButton、TextField、Hint、Tag、Danger(1.5.0))を使ってください。スタイルが使えるのは描画コールバックの中だけです

ASCII 以外の文字と Direct3D 12

Direct3D 12 では、ウィンドウのフォントがまだ描いたことのない文字を描くとテクスチャのアップロードが起き、それがゲームのフレーム表示と重なるとゲームが落ちることがあります(Unity UUM-140564)。なので、タブで描く ASCII 以外の文字は、描画コールバックの中ではなく、Awake か Update で先に準備しておいてください。

ToolWindow.PrepareCharacters("スクラブ速度");

Assets のフォントを使う Mod なら、そちらで準備した文字をそのまま渡せます。

GameFonts.CharactersPrepared += () => ToolWindow.PrepareCharacters(GameFonts.PreparedCharacters);

CanDraw(text) を使うと、フォントに全部の文字があるかが分かります。ない文字は ? になります。

1.5.0 からは、ウィンドウのフォントを起動時ではなく、ウィンドウを最初に開いたときに作ります。開かない人はその分の時間を払わずに済みます。そのため PrepareCharacters は文字を覚えておくだけになりました。それ以降は Drawable がその文字をそのまま描き、Direct3D 12 ではウィンドウのアトラスを中核が 1 フレームに 1 回まとめて送ります。Direct3D 12 で中核の [Direct3D12] BatchFontAtlasUploads をオフにしているときだけは、今までどおり起動時にフォントを作って、その場で文字をフォントに描き込みます。なので呼ぶ側は今までどおり Awake か Update から呼んでおけば大丈夫で、手間はほとんどかかりません。描画コールバックから呼んだ分は、次の Update まで待ちます。

Font と CanDraw は、ウィンドウがまだ開いていなくても Awake か Update から読めば、その場でフォントを作るので前と同じ答えを返します。描画コールバックから読んだときは、Font は次のフレームまで null で、CanDraw はまだ確かめていない文字をそのフレームだけ描けないものとして数えます。

スクロール

ToolWindow.ApplyScroll(view, ref _scroll);   // GUI.BeginScrollView の直前に
_scroll = GUI.BeginScrollView(view, _scroll, content);
// ...
GUI.EndScrollView();

折り返すボタン(1.5.0)

FlowButton は、ボタンを並べた行の中のボタンを 1 つ描きます。次のボタンが指定した幅に収まらないときは、端からはみ出さずに次の行に回るので、細いウィンドウでも全部のボタンに手が届きます。ボタンの幅はラベルに要る分(最低 60 px)で、minWidth を渡せばその幅以上になります。Turn on / Turn off のようにラベルが変わるボタンには長い方の幅を渡しておくと、切り替わったときに行がずれません。

使う側は 2 つの数を持っておきます。bx は次のボタンを置く位置、y はそのボタンがある行の上端です。行の始めは bx = x にして、最後のボタンのあとは自分で y を RowHeight 分下げてください。呼ぶと bx はボタンの右(さらに 6 px 先)に進み、ボタンが折り返したときは y が RowHeight + 2 下がります。

selected: true にすると Styles.SelectedButton で描いて、下にアクセントの線を引きます。表示の切り替えで、いまどれが出ているかを見せるときの形です。文字列の代わりに GUIContent を渡すと、マウスがボタンの上にある間、そのツールチップがヒント行に出ます。押されたイベントで true を返します。

float bx = area.x, y = area.y;
if (ToolWindow.FlowButton(ref bx, ref y, area.x, area.width, $"Textures ({_textures.Count})", _view == View.Textures))
{
    _view = View.Textures;
}
if (ToolWindow.FlowButton(ref bx, ref y, area.x, area.width, $"Replacements ({_replacements.Count})", _view == View.Replacements))
{
    _view = View.Replacements;
}
if (ToolWindow.FlowButton(ref bx, ref y, area.x, area.width, new GUIContent("List again", "Lists the loaded textures again.")))
{
    ListAgain();
}
y += ToolWindow.RowHeight + 8;   // タブの残りは最後の行の下から

Assets タブのツールバーは、これで作っています。

絞り込みの入力欄(1.5.0)

FilterField(Rect rect, string value, string placeholder, ToolWindowStyles s) は、組み込みのタブと同じアクセントの下線が付いた入力欄で、空の間は薄い色でプレースホルダーを出します。新しい文字列を返します。Underline(Rect field) はその下線だけを引くので、入力欄を自分で描くときに使えます。Inspector、Assets、Console のタブはどちらも使っているので、自分のタブの入力欄も 1 行で同じ見た目にできます。

_filter = ToolWindow.FilterField(new Rect(area.x, y, 240, ToolWindow.RowHeight), _filter, "Filter by name", ToolWindow.Styles);

通知と作業中の表示(1.5.0)

ShowNotice(string message, NoticeKind kind, float seconds = 0f) は、ウィンドウのフッターにある通知の帯(ヒント行のすぐ上)にメッセージを出します。種類ごとの色の線が付いた 1 行で、長すぎるときは「...」で切れますが、マウスを乗せている間は全文が見えて、クリックすれば消せます。

seconds を 0 より大きくすると、最初に出てからその秒数がたったところで通知は自分で消え、その間に届いた通知は順番待ちになります(「1 more」)。ただし NoticeKind.Error は先に出ます。時間を指定しなければ、タブが変わるか次の通知に置き換わるまで残りますが、Error だけは別で、後から来た通知がその後ろで待ちます。同じメッセージがもう一度来たときは、待ち行列に並べずに表示時間だけやり直します。空のメッセージを出すと、いま出ている通知が消えます。

ShowNotice(string message) はこれまでどおりで、中では NoticeKind.Info の時間指定なしでオーバーロードを呼んでいます。

ToolWindow.ShowNotice("Speed reset.");                                // Info、タブが変わるまで
ToolWindow.ShowNotice("Cache cleared.", NoticeKind.Info, 3f);         // 3 秒で自分で消える
ToolWindow.ShowNotice("Could not save the file.", NoticeKind.Error);  // 残り続け、後の通知はその後ろで待つ

NoticeKind で通知の種類を決めます。Info は済んだことや知っておくとよいことで、アクセントカラーで出ます。Warning は黄色で、済んだけれど見ておいたほうがいい問題があるとき、Error は赤で、何かが失敗したときです。

Busy(string what, string detail = null) は、この描画の間だけタブを作業中にします。何フレームかにまたがる作業(1 フレームに 1 ファイルずつ読むコルーチンなど)をしている間は、タブの描画コールバックから毎回呼んでください。本体が暗くなって、真ん中の小さなパネルに what、detail とスピナーが出て、ヒント行にも同じ内容が出て、タブのコントロールには入力が届かなくなります。ヘッダーやタブのボタン、閉じるボタンはそのまま使えて、呼ぶのをやめた次のフレームでタブは元に戻ります。

private void DrawTab(Rect area)
{
    if (_reloading)
    {
        ToolWindow.Busy("Reloading files...", $"{_fileName}  ({_done} of {_total})");
        return; // どのみちタブのコントロールは入力を受け付けない
    }

    // 通常のコントロール
}

その場での確認(1.5.0)

AskConfirm(string id)、IsConfirming(string id)、Confirm(Rect row, string id, string question, string yes = "Yes", string hint = null) を使うと、取り消せない操作の前に、ダイアログを出さずにその場で確認できます。操作のボタンが押されたら AskConfirm を呼び、IsConfirming が true の間はそのボタンを ToolWindow.Styles.SelectedButton で描いて、その下の行に Confirm を描いてください。質問は 5 秒待って、Cancel か Esc を押したとき、タブを切り替えたとき、ウィンドウを閉じたときに消えます。質問はウィンドウ全体で 1 つだけなので、別の質問を出すとそちらに置き換わります。何も失わない操作(切るものも消すものもない操作)なら、確認せずにそのままやってください。

private void DrawClearRow(Rect row)
{
    var s = ToolWindow.Styles;
    bool asking = ToolWindow.IsConfirming("mymod.cache.clear");

    if (GUI.Button(row, "Clear cache", asking ? s.SelectedButton : s.Button))
    {
        ToolWindow.AskConfirm("mymod.cache.clear");
    }

    var confirmRow = new Rect(row.x, row.yMax + 4, row.width, ToolWindow.RowHeight);
    if (ToolWindow.Confirm(confirmRow, "mymod.cache.clear", "Clear the cache? It will be rebuilt on next load.", "Yes, clear"))
    {
        ClearCache();
        ToolWindow.ShowNotice("Cache cleared.");
    }
}

ヒントと長い文字列(1.5.0)

Hint(string text) は、この描画の間、text をウィンドウのヒント行に出します。そこにいつも出ているキーの説明より明るい色で出るので、枠のいらないツールチップのように使えます。マウスがその対象の上にある間に、タブの描画コールバックから呼んでください。Hint(Rect rect, string text) は同じことを、マウスが rect(呼ぶ側が描いている座標系)の中にある間だけして、中にあれば true を返します。コントロールの GUIContent のツールチップも、同じようにヒント行に出ます。

Elide(string text, GUIStyle style, float width) は、style で描いて width に収まるなら text をそのまま、収まらなければ「...」で切って返します。サロゲートペアの途中で切ることはありません。列に入りきらないかもしれない名前には Hint(Rect, string) と組み合わせて、全文を見られるようにしてください。

var nameRect = new Rect(area.x, area.y, 200, ToolWindow.RowHeight);
GUI.Label(nameRect, ToolWindow.Elide(fullName, styles.Label, nameRect.width), styles.Label);
ToolWindow.Hint(nameRect, fullName); // マウスが行の上にある間、ヒント行に全文が出る

メンバー

メンバー 内容
const string Guid "com.tomxv.dragnwash.modframework.toolwindow"
bool IsAvailable / bool IsOpen ウィンドウを表示できるか/開いているか
event Action<bool> OpenChanged 開いたときと閉じたときに呼ばれる
IDisposable AddTab(string owner, string title, Action<Rect> draw, int order = 0) タブを足す
IDisposable AddCommand(string owner, string name, string help, Func<string[], string> run, Func<string[], IEnumerable<string>> complete = null) Console タブのコマンドを足す(Tool window 1.1.0、実験的)
IDisposable AddOverlay(string owner, Action<Rect> draw) 窓が開いている間、窓より先にゲーム画面の上に描く。窓の矩形も受け取る(1.1.0、実験的)。Inspector の枠とギズモはこれで描いている
void BlockGameInput(string owner, bool block) その持ち主が true にしている間、ゲームの入力を止める。ピックやゲーム画面でのドラッグのときに使う(1.1.0、実験的)
string Drawable(string text) いま窓が描ける形にした文字列。まだ準備できていない文字は ? になる(1.1.0、実験的)
void Open(string tabTitle = null) / void Close() ウィンドウを(指定したタブで)開く/閉じる
void ShowNotice(string message) タブが変わるまで、フッターに 1 行のメッセージを出す
void ShowNotice(string message, NoticeKind kind, float seconds = 0f) フッターに種類つきの通知を出す。秒数を 0 より大きくすると、その時間で自分で消える(1.5.0)
enum NoticeKind Info、Warning、Error。通知の色の線と、ほかの通知の後ろで待つかどうかが決まる(1.5.0)
void Busy(string what, string detail = null) この描画の間タブを作業中にする。本体を暗くしてスピナーを出し、タブ自体の入力を止める(1.5.0)
void AskConfirm(string id) / bool IsConfirming(string id) 取り消せない操作の前に、その場で Yes/Cancel の質問を始める/いま質問中かを返す(1.5.0)
bool Confirm(Rect row, string id, string question, string yes = "Yes", string hint = null) その質問の行を描く。Yes が押されたフレームで true を返す(1.5.0)
void Hint(string text) / bool Hint(Rect rect, string text) この描画の間、または rect にマウスがある間、ヒント行に文字列を出す(1.5.0)
string Elide(string text, GUIStyle style, float width) width に収まるように「...」で切った文字列(1.5.0)
bool FlowButton(ref float bx, ref float y, float x, float width, string label, bool selected = false, float minWidth = 0f) 細いウィンドウでは折り返す行の中のボタン。幅はラベルに合わせ、選ばれているときは下にアクセントの線を引く(1.5.0)
bool FlowButton(ref float bx, ref float y, float x, float width, GUIContent content, bool selected = false, float minWidth = 0f) 同じもので、ツールチップをヒント行に出す(1.5.0)
string FilterField(Rect rect, string value, string placeholder, ToolWindowStyles s) アクセントの下線と薄いプレースホルダーの付いた入力欄。新しい文字列を返す(1.5.0)
void Underline(Rect field) 入力欄の下にアクセントの下線を引く(1.5.0)
void PrepareCharacters(string characters) / bool CanDraw(string text) 上の説明を見てください。1.5.0 から PrepareCharacters は文字を覚えておくだけ
void Fill(Rect rect, Color color) 矩形を塗る
bool ApplyScroll(Rect view, ref Vector2 scroll) スティックでスクロールする(システムが本物の入力を受け付けない環境では十字キーも)
RowHeight、Padding、PanelColor、InsetColor、AccentColor、MutedColor、ErrorColor、WarningColor ウィンドウに合うレイアウトと色
Font、FontSize、Styles ウィンドウが描くのに使っているもの。1.5.0 から、フォントはウィンドウを最初に開いたとき(または Awake か Update から Font を読んだとき)に作る

やめてほしいこと

  • 自分で OnGUI のウィンドウを開いたり、カーソルを解放したりゲームの入力を止めたりすること。いくつもの Mod がそれをやると、互いにぶつかります
  • 自分の開発者向け機能(書き出しやデバッグ用のキーなど)をスイッチの外に置くこと。この窓と同じように、DeveloperTools.Enabled(中核 API)を見て判断してください
  • プレイヤー向けの機能をツールウィンドウにしか置かないこと。ここは開発・デバッグ用で、プレイヤーには Mods 画面と Options 画面があります(中核 API)
  • ゲーム(あるいは Undo やスナップショット)で元に戻せることに、独自の Yes/Cancel ダイアログを出すこと。AskConfirm / Confirm はそれ以外のことに、それも本当に何かを失う操作にだけ使ってください

Clone this wiki locally