Editor/EffigyEditor/ToolsMenuIcons.cs
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
namespace Marionette.EditorTools;
/// <summary>
/// [EditorApp] only takes an icon *name* (or a content path), so neither of this addon's apps can
/// declare its real mark there — Effigy's would draw the stock cube and Marionette's the stock
/// accessibility_new figure. After the menus exist we stamp the proper pixmap onto each Tools
/// option via Option.SetIcon(Pixmap).
///
/// WHY THIS RETRIES INSTEAD OF STAMPING ONCE. Every ingredient this needs arrives on its own
/// schedule: the MenuBar is built by the editor, the Tools menu is filled by whoever registers
/// into it, and Effigy's icon is read through Project.Current, which is null until the project is
/// resolved. Firing on editor.created caught a moment when any of those could still be missing,
/// and the old code answered that by returning quietly — so the icon was simply absent until an
/// unrelated hotload happened to re-run Apply and find everything ready. That is the "blank
/// sometimes, fine next launch" behaviour. Now a failed attempt just leaves the entry unstamped
/// and the frame event tries again, so the ordering stops mattering.
///
/// BOTH APPS GO THROUGH HERE for that same reason. The retry above is the whole value of this
/// file; a second copy of it for Marionette would be a second copy to get subtly wrong.
/// </summary>
internal static class ToolsMenuIcons
{
/// <summary>One Tools-menu entry to stamp: the option's text, and how to build its pixmap.
/// The factory is only ever called until it returns non-null, and the result is cached.</summary>
sealed class Entry
{
public string Option;
public Func<Pixmap> Factory;
public Pixmap Icon;
public bool Stamped;
}
static readonly Entry[] Entries =
{
new() { Option = "Effigy", Factory = EffigyWindow.AppIcon },
new() { Option = "Marionette", Factory = () => MarionetteIcon.Build() },
};
/// <summary>Frames spent with something still unstamped, used to complain exactly once instead
/// of never (the old silence) or every frame. At editor framerate this is a few seconds.</summary>
static int _attempts;
const int ComplainAfter = 600;
[Event( "editor.created" )]
static void OnEditorCreated( EditorMainWindow window ) => Rearm();
[Event( "hotloaded" )]
[Event( "refresh" )]
static void OnReload() => Rearm();
/// <summary>Menus may have been rebuilt from under us, so drop the cached pixmaps along with
/// the latches and let the frame event re-resolve everything.</summary>
static void Rearm()
{
foreach ( var entry in Entries )
{
entry.Stamped = false;
entry.Icon = null;
}
_attempts = 0;
}
[EditorEvent.Frame]
static void OnFrame()
{
var pending = false;
foreach ( var entry in Entries )
{
if ( entry.Stamped )
continue;
if ( TryApply( entry ) )
entry.Stamped = true;
else
pending = true;
}
if ( !pending )
return;
if ( ++_attempts == ComplainAfter )
{
var missing = new List<string>();
foreach ( var entry in Entries )
if ( !entry.Stamped )
missing.Add( entry.Option );
Log.Warning( $"Toolshed: could not stamp Tools menu icons for {string.Join( ", ", missing )} — no icon could be built, or the menu option was never registered. Those entries keep their default icons." );
}
}
static bool TryApply( Entry entry )
{
// EditorWindow, not SceneViewWidget.Current.GetWindow(). GetWindow() gives back a plain
// Widget, which has no MenuBar on it — the scene view does not know it is inside the main
// window. EditorWindow is the editor's own static handle on that window (it is what
// EditorMainWindow hands out, statically imported through the csproj's GlobalToolsNamespace
// using), and it is typed, so MenuBar is right there.
if ( EditorWindow?.MenuBar is not { } bar )
return false;
entry.Icon ??= entry.Factory();
if ( entry.Icon is null )
return false;
// FindOrCreateMenu is the only lookup MenuBar exposes — there is no plain FindMenu. On a
// frame before the editor has built its Tools menu this creates an empty one, which is
// harmless: the editor's own registration calls FindOrCreateMenu too and so fills this
// very object, and until it does the GetOption check below keeps the retry alive.
var tools = bar.FindOrCreateMenu( "Tools" );
if ( tools is null )
return false;
// GetOption, not GetAllOptionsRecursive. The recursive one is INTERNAL to the editor
// assembly — it shows up in Sandbox.Tools.xml because the doc file carries internals too,
// which is exactly what makes reading those docs a trap from out here. GetOption is public,
// takes the option's text, and is what this wanted anyway: one option, by name.
if ( tools.GetOption( entry.Option ) is not { } option )
return false;
option.SetIcon( entry.Icon );
return true;
}
}