Skip to content

Tool window

Tom_XV edited this page Sep 23, 2026 · 7 revisions

English | 日本語

This is the Tool window library (DragNWash.ModFramework.ToolWindow.dll, namespace DragNWash.ModFramework.ToolWindow). It's one shared in-game window for developer and debug tools, and each mod adds its own tabs to it. You open it with F1 (players can change the key in its settings). From core 1.2.0 it only opens while Developer tools is on (Options → Mods → Drag'n Wash ModFramework), so a player who has just installed a mod never sees it. A call to Open is refused too, and leaves one line in the log.

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

The window is drawn with Unity's IMGUI, and the library takes care of what makes that hard in this game. It frees the cursor the game locks when there's a gamepad, stops clicks and drags on the window from reaching the game, turns gamepad and Steam Deck trackpad presses into clicks, scrolls with the sticks, and draws with a font that has Japanese and Chinese glyphs if the system has one. On the Steam Deck, and with any gamepad, a press over the window goes back to the system as a real left mouse button, the sticks as real wheel steps and the d-pad as real arrow keys. That's why every control works there: fields, drags, sliders and scroll bars, and the lists you walk with the arrow keys.

Adding a tab

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 gets called from OnGUI, with the tab's content area in window coordinates.
  • Tabs are sorted by order, and then by when they were added. Keep titles short and in ASCII.
  • If draw throws, the exception is logged with your GUID and the tab shows the error, so the window itself doesn't break.
  • Use ToolWindow.Styles (Label, MutedLabel, WrappedLabel, LogLabel, Button, SelectedButton, TextField, Hint, Tag, Danger (1.5.0)) instead of GUI.skin. The styles are only valid inside a draw callback.

Non-ASCII text and Direct3D 12

On Direct3D 12, drawing a character the window font hasn't rasterized yet uploads a texture, and if that upload happens while the game is presenting a frame, the game can crash (Unity UUM-140564). So prepare every non-ASCII character a tab draws ahead of time, from Awake or Update, never from the draw callback:

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

If your mod uses Assets fonts, you can pass along what it has already prepared:

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

CanDraw(text) tells you whether the font has every glyph. Any that are missing show up as ?.

From 1.5.0 the window font is made the first time the window opens, instead of at startup, so a player who never opens it never pays for it. Because of that, PrepareCharacters only notes the characters now: Drawable draws them as they are from then on, and on Direct3D 12 the core already uploads the window's atlas once per frame. Only on Direct3D 12 with the core's [Direct3D12] BatchFontAtlasUploads switched off is the font still made at startup and the characters rasterized into it right away, as before. So keep calling it from Awake or Update the same way; it just costs next to nothing. A call from a draw callback is held until the next Update.

Font and CanDraw still answer from Awake or Update before the window has opened, because they make the font right then. From a draw callback, Font is null until the next frame, and CanDraw counts a character it hasn't checked yet as not drawable for that one frame.

Scrolling

ToolWindow.ApplyScroll(view, ref _scroll);   // just before GUI.BeginScrollView
_scroll = GUI.BeginScrollView(view, _scroll, content);
// ...
GUI.EndScrollView();

Buttons that wrap (1.5.0)

FlowButton draws one button in a row of buttons, and when the next button won't fit in the width you give, it starts a new row instead of running off the edge. So in a narrow window every button stays reachable. Each button is as wide as its label needs (60 px at least), or as wide as minWidth when you pass it. Pass the longer label's width for a button whose label changes, like Turn on / Turn off, so the row doesn't jump when it flips.

You keep two numbers for it. bx is where the next button goes, and y is the top of the row it's on. Start a row with bx = x, and after the last button move y down by RowHeight yourself. The call moves bx past the button (and 6 px on), and when a button wraps, y goes down by RowHeight + 2.

With selected: true the button is drawn with Styles.SelectedButton and the accent line under it, the way a view switch shows which view is showing. Pass a GUIContent instead of a string and its tooltip shows on the hint line while the pointer is on the button. It returns true on the event the button is pressed.

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;   // the rest of the tab starts under the last row

The Assets tab's toolbar is built with it.

Filter fields (1.5.0)

FilterField(Rect rect, string value, string placeholder, ToolWindowStyles s) is a text field with the accent underline the built-in tabs use, and a dim placeholder while it's empty. It returns the new text. Underline(Rect field) draws just that underline, for a text field you draw yourself. The Inspector, Assets and Console tabs use both, so your tab's fields can look the same with one call.

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

Notices and busy (1.5.0)

ShowNotice(string message, NoticeKind kind, float seconds = 0f) puts a message in the notice strip in the window's footer, just above the hint line. It's one line with a colour bar for its kind, and if it's too long it gets cut with "...". While the pointer is on it the whole text shows, and a click dismisses it.

If seconds is above zero, the notice clears itself that long after it first shows, and any notice that arrives in the meantime waits its turn ("1 more"). A NoticeKind.Error goes first. With no time limit, a notice stays until the tab changes or the next notice replaces it. The exception is an Error: later notices wait behind that one. Sending the same message again starts its time over instead of queueing a copy, and an empty message clears the notice that's showing.

ShowNotice(string message) hasn't changed. It calls the overload with NoticeKind.Info and no time limit.

ToolWindow.ShowNotice("Speed reset.");                                // Info, until the tab changes
ToolWindow.ShowNotice("Cache cleared.", NoticeKind.Info, 3f);         // clears itself after 3 seconds
ToolWindow.ShowNotice("Could not save the file.", NoticeKind.Error);  // stays; later notices wait behind it

NoticeKind says what sort of notice it is. Info, in the accent colour, is for something that's done or worth knowing. Warning, in yellow, is for something that's done but has a problem worth a look. Error, in red, means something failed.

Busy(string what, string detail = null) marks the tab as busy for this draw. While the tab works through something spread over several frames (say, a coroutine that does one file a frame), call it from the tab's draw callback on every draw. The body dims, a small panel in the middle shows what, detail and a spinner, the hint line repeats them, and the tab's controls get no input. The header, the tab buttons and the close button keep working, and the tab comes back the frame after you stop calling it.

private void DrawTab(Rect area)
{
    if (_reloading)
    {
        ToolWindow.Busy("Reloading files...", $"{_fileName}  ({_done} of {_total})");
        return; // the tab's controls would see no input anyway
    }

    // normal controls
}

Confirming in place (1.5.0)

AskConfirm(string id), IsConfirming(string id) and Confirm(Rect row, string id, string question, string yes = "Yes", string hint = null) let you ask before an action that can't be taken back, right there in the tab with no dialog. Call AskConfirm when the action's button is pressed. While IsConfirming is true, draw that button with ToolWindow.Styles.SelectedButton, and draw Confirm on a row under it. The question waits five seconds, and it goes away on Cancel, on Esc, on a tab change or when the window closes. The window holds one question at a time, so asking another replaces it. If an action loses nothing (there's nothing to disconnect and nothing to clear), just do it and don't ask.

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.");
    }
}

Hints and long text (1.5.0)

Hint(string text) shows text on the window's hint line for this draw, brighter than the key hints it stands in for. Think of it as a tooltip that doesn't need a box. Call it from a tab's draw callback while the pointer is on whatever the text is about. Hint(Rect rect, string text) does the same, but only while the pointer is inside rect (in the coordinates you draw in), and returns true when it is. A control's GUIContent tooltip shows up on the hint line the same way.

Elide(string text, GUIStyle style, float width) gives you text fitted to width when it's drawn with style. If it fits you get it whole, and if it doesn't it's cut with an ellipsis. It never splits a surrogate pair. Use it together with Hint(Rect, string) so a name that's longer than its column can still be read in full.

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); // the full name shows on the hint line while the pointer is on the row

Members

Member What it does
const string Guid "com.tomxv.dragnwash.modframework.toolwindow"
bool IsAvailable / bool IsOpen Whether the window can be shown / is open
event Action<bool> OpenChanged Raised when it opens or closes
IDisposable AddTab(string owner, string title, Action<Rect> draw, int order = 0) Adds a tab
void Open(string tabTitle = null) / void Close() Opens the window (on a tab, if you give one) or closes it
IDisposable AddCommand(string owner, string name, string help, Func<string[], string> run, Func<string[], IEnumerable<string>> complete = null) A command for the Console tab (Tool window 1.1.0, experimental)
IDisposable AddOverlay(string owner, Action<Rect> draw) Draws over the game while the window is open, before the window itself, and gets the window's rectangle (1.1.0, experimental). The Inspector draws its outlines and gizmo with it
void BlockGameInput(string owner, bool block) Holds back the game's input while the owner has it set to true, say for a pick mode or a drag in the game view (1.1.0, experimental)
string Drawable(string text) The text as the window can draw it right now, with characters that aren't rasterised yet turned into ? (1.1.0, experimental)
void ShowNotice(string message) A one-line message in the footer that stays until the tab changes
void ShowNotice(string message, NoticeKind kind, float seconds = 0f) A footer notice with a kind, and a time after which it clears itself when that's above zero (1.5.0)
enum NoticeKind Info, Warning or Error. It sets a notice's colour bar and whether it waits behind others (1.5.0)
void Busy(string what, string detail = null) Marks a tab busy for this draw. It dims the body, shows a spinner and blocks the tab's own input (1.5.0)
void AskConfirm(string id) / bool IsConfirming(string id) Starts an in-place Yes/Cancel question for an action that can't be undone, and tells you whether it's still asking (1.5.0)
bool Confirm(Rect row, string id, string question, string yes = "Yes", string hint = null) Draws that question's row, and returns true on the frame Yes is pressed (1.5.0)
void Hint(string text) / bool Hint(Rect rect, string text) Puts text on the hint line, for this draw or while the pointer is in rect (1.5.0)
string Elide(string text, GUIStyle style, float width) Text cut with an ellipsis to fit width (1.5.0)
bool FlowButton(ref float bx, ref float y, float x, float width, string label, bool selected = false, float minWidth = 0f) A button in a row that wraps in a narrow window, sized to its label, with the accent line under it when selected (1.5.0)
bool FlowButton(ref float bx, ref float y, float x, float width, GUIContent content, bool selected = false, float minWidth = 0f) The same, with the content's tooltip on the hint line (1.5.0)
string FilterField(Rect rect, string value, string placeholder, ToolWindowStyles s) A text field with the accent underline and a dim placeholder; returns the new text (1.5.0)
void Underline(Rect field) The accent underline under a text field (1.5.0)
void PrepareCharacters(string characters) / bool CanDraw(string text) See above. From 1.5.0, PrepareCharacters only notes the characters
void Fill(Rect rect, Color color) Fills a rectangle
bool ApplyScroll(Rect view, ref Vector2 scroll) Scrolling with the sticks (and with the d-pad, where the system doesn't take real input)
RowHeight, Padding, PanelColor, InsetColor, AccentColor, MutedColor, ErrorColor, WarningColor Layout and colours that match the window
Font, FontSize, Styles What the window draws with. From 1.5.0 the font is made when the window first opens, or when you read Font from Awake or Update

Do not

  • Open your own OnGUI window, or unlock the cursor or block game input yourself. When several mods do that, they fight each other.
  • Keep a developer feature of your own (an export, a debug key) outside the switch. Check DeveloperTools.Enabled (Core API) the way the window does.
  • Put player-facing features only in the tool window. It's meant for developer and debug tools, and players have the Mods screen and the Options screen for theirs (Core API).
  • Show a Yes/Cancel dialog of your own for something the game (or an undo, or a snapshot) can put back. For everything else use AskConfirm / Confirm, and only for actions that really lose something.

Clone this wiki locally