diff --git a/src/AcDream.App/Studio/StudioInspector.cs b/src/AcDream.App/Studio/StudioInspector.cs
index 43cc03b0..c8a85b9a 100644
--- a/src/AcDream.App/Studio/StudioInspector.cs
+++ b/src/AcDream.App/Studio/StudioInspector.cs
@@ -5,10 +5,12 @@ using ImGuiNET;
namespace AcDream.App.Studio;
///
-/// Three-pane ImGui IDE for the acdream UI Studio:
+/// Four-pane ImGui IDE for the acdream UI Studio:
///
+/// - Toolbar — panel picker (slug combo) across the top.
/// - Canvas — shows the panel FBO texture; click-to-inspect returns the
-/// panel-local pixel coordinate of the click.
+/// panel-local pixel coordinate of the click; draws a green outline over
+/// .
/// - Tree — recursive ImGui tree of the element hierarchy; clicking a node
/// sets .
/// - Properties — shows the element's geometry,
@@ -27,12 +29,55 @@ namespace AcDream.App.Studio;
/// the panel renders right-side-up in the canvas. Because V is flipped, image-local
/// pixel y = 0 is the TOP of the panel, matching the UiRoot's top-left origin — so
/// click y maps directly without further inversion.
+///
+/// Layout: / /
+/// / each call
+/// SetNextWindowPos + SetNextWindowSize with
+/// ImGuiCond.FirstUseEver so the panes start in a docked-style arrangement
+/// that the user can freely drag from.
///
public sealed class StudioInspector
{
/// Currently selected element (set by tree-click or canvas-click).
public UiElement? Selected { get; set; }
+ // ── Toolbar ───────────────────────────────────────────────────────────────────
+
+ ///
+ /// Draw the "Studio" toolbar window (top strip) containing a slug combo-box.
+ /// Returns the newly-selected slug when the user picks a different panel, or
+ /// null when the selection is unchanged.
+ ///
+ /// All available panel slugs (from UiDumpModel.ListSlugs).
+ /// The slug of the panel currently loaded.
+ /// Studio window width (pixels).
+ public string? DrawToolbar(IReadOnlyList slugs, string? current, int windowW)
+ {
+ ImGui.SetNextWindowPos(new Vector2(0f, 0f), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new Vector2(windowW, 40f), ImGuiCond.FirstUseEver);
+ ImGui.Begin("Studio",
+ ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse);
+
+ ImGui.SetNextItemWidth(300f);
+ string? result = null;
+ string preview = current ?? "(none)";
+ if (ImGui.BeginCombo("Panel", preview))
+ {
+ foreach (var slug in slugs)
+ {
+ bool selected = string.Equals(slug, current, StringComparison.OrdinalIgnoreCase);
+ if (ImGui.Selectable(slug, selected) && !selected)
+ result = slug;
+ if (selected)
+ ImGui.SetItemDefaultFocus();
+ }
+ ImGui.EndCombo();
+ }
+
+ ImGui.End();
+ return result;
+ }
+
// ── Canvas ────────────────────────────────────────────────────────────────────
///
@@ -42,9 +87,18 @@ public sealed class StudioInspector
///
/// The texture is drawn with uv0=(0,1) / uv1=(1,0) to flip V, correcting
/// the GL bottom-left FBO origin to the ImGui top-left convention.
+ ///
+ /// If is non-null and has non-zero size, a bright-green
+ /// 2-pixel outline is drawn over the element's screen-space rect using the window
+ /// draw list. gives the absolute top-left in
+ /// root/panel space, which maps 1:1 to FBO pixels (the image is drawn 1:1 and both
+ /// the element tree and the FBO share the same top-left origin).
///
- public (int x, int y)? DrawCanvas(nint panelTex, int width, int height)
+ public (int x, int y)? DrawCanvas(nint panelTex, int width, int height,
+ int windowX, int windowW, int windowY, int windowH)
{
+ ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Canvas");
var imageSize = new Vector2(width, height);
@@ -55,13 +109,29 @@ public sealed class StudioInspector
var uv1 = new Vector2(1f, 0f);
ImGui.Image(panelTex, imageSize, uv0, uv1);
+ // ── Selection highlight ───────────────────────────────────────────────
+ // After ImGui.Image the item rect gives us the screen-space top-left of
+ // the drawn texture. ScreenPosition is in root/panel space (top-left origin,
+ // same coordinate system the FBO was drawn in), so offsetting by rectMin
+ // converts directly to screen space for the draw list.
+ var rectMin = ImGui.GetItemRectMin();
+ var el = Selected;
+ if (el is not null && el.Width > 0f && el.Height > 0f)
+ {
+ var sp = el.ScreenPosition;
+ var p0 = new Vector2(rectMin.X + sp.X, rectMin.Y + sp.Y);
+ var p1 = new Vector2(p0.X + el.Width, p0.Y + el.Height);
+ var dl = ImGui.GetWindowDrawList();
+ dl.AddRect(p0, p1,
+ ImGui.GetColorU32(new Vector4(0.2f, 1f, 0.4f, 1f)),
+ 0f, ImDrawFlags.None, 2f);
+ }
+
(int x, int y)? result = null;
// Detect a left-click on the image.
if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
{
- // Image item rect: top-left of the drawn image in screen (display) coords.
- var rectMin = ImGui.GetItemRectMin();
var mousePos = ImGui.GetMousePos();
// Panel-local pixel = mouse offset from image top-left.
@@ -81,8 +151,10 @@ public sealed class StudioInspector
// ── Tree ──────────────────────────────────────────────────────────────────────
/// Draw the "Tree" ImGui window. Clicking a node sets .
- public void DrawTree(UiElement root)
+ public void DrawTree(UiElement root, int windowX, int windowY, int windowW, int windowH)
{
+ ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Tree");
DrawTreeNode(root);
ImGui.End();
@@ -120,8 +192,10 @@ public sealed class StudioInspector
// ── Properties ───────────────────────────────────────────────────────────────
/// Draw the "Properties" ImGui window for .
- public void DrawProperties()
+ public void DrawProperties(int windowX, int windowY, int windowW, int windowH)
{
+ ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver);
+ ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver);
ImGui.Begin("Properties");
var el = Selected;
diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs
index 64ab906f..2565fc13 100644
--- a/src/AcDream.App/Studio/StudioWindow.cs
+++ b/src/AcDream.App/Studio/StudioWindow.cs
@@ -49,6 +49,11 @@ public sealed class StudioWindow : IDisposable
private StudioInspector? _inspector;
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
+ // UX-pass additions: panel picker + current-slug tracking.
+ private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode)
+ private string? _dumpFile; // resolved dump file path (once, in OnLoad)
+ private IReadOnlyList _dumpSlugs = Array.Empty(); // all slugs from the dump
+
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects;
@@ -123,11 +128,17 @@ public sealed class StudioWindow : IDisposable
// Task 4b: --dump uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path).
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont);
+
+ // Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime).
+ _dumpFile = _opts.ResolveDumpFile();
+ if (_dumpFile is not null)
+ _dumpSlugs = UiDumpModel.ListSlugs(_dumpFile)
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
+
UiElement? root;
if (_opts.DumpSlug is not null)
{
- var dumpFile = _opts.ResolveDumpFile();
- if (dumpFile is null)
+ if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
@@ -136,9 +147,11 @@ public sealed class StudioWindow : IDisposable
}
else
{
- root = DumpLayout.Load(dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
+ root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
if (root is null)
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
+ else
+ _currentSlug = _opts.DumpSlug;
}
}
else
@@ -265,12 +278,33 @@ public sealed class StudioWindow : IDisposable
// 4. Begin the ImGui frame.
_imgui.BeginFrame((float)dt);
- // 5. Canvas pane — show the FBO texture; detect clicks.
+ // ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
+ // Toolbar: full width, 40px tall at y=0.
+ // Tree: 280px wide on the left, below toolbar.
+ // Canvas: centre strip between tree and properties.
+ // Props: 340px wide on the right, below toolbar.
+ const int kToolbarH = 40;
+ const int kTreeW = 280;
+ const int kPropsW = 340;
+ int canvasX = kTreeW;
+ int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
+ int propsX = iw - kPropsW;
+ int paneY = kToolbarH;
+ int paneH = Math.Max(1, ih - kToolbarH);
+
+ // 5. Toolbar pane — slug picker.
+ string? pickedSlug = _inspector.DrawToolbar(_dumpSlugs, _currentSlug, iw);
+ if (pickedSlug is not null)
+ LoadDumpPanel(pickedSlug);
+
+ // 6. Canvas pane — show the FBO texture; detect clicks.
(int x, int y)? click = null;
if (panelTex != 0)
- click = _inspector.DrawCanvas((nint)panelTex, iw, ih);
+ click = _inspector.DrawCanvas(
+ (nint)panelTex, iw, ih,
+ canvasX, canvasW, paneY, paneH);
- // 6. If the user clicked inside the canvas, hit-test the whole UI tree (UiRoot.Pick honors
+ // 7. If the user clicked inside the canvas, hit-test the whole UI tree (UiRoot.Pick honors
// Z-order + modal exclusivity) and select the topmost element. The canvas click coord is
// in the same root-space the FBO was drawn in, so it maps 1:1.
if (click is { } c)
@@ -280,17 +314,55 @@ public sealed class StudioWindow : IDisposable
_inspector.Selected = hit;
}
- // 7. Element tree pane.
+ // 8. Element tree pane.
if (_panelRoot is not null)
- _inspector.DrawTree(_panelRoot);
+ _inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
- // 8. Properties pane.
- _inspector.DrawProperties();
+ // 9. Properties pane.
+ _inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
// 9. Finalise ImGui and flush draw data to the window.
_imgui.Render();
}
+ ///
+ /// Load a different dump panel at runtime (no relaunch required).
+ /// Removes the current from the UI tree,
+ /// loads the named slug from the dump, and installs the new root.
+ /// Resets to null.
+ /// No-op when the dump file is not available or the slug fails to load.
+ ///
+ public void LoadDumpPanel(string slug)
+ {
+ if (_stack is null || _inspector is null) return;
+ if (_dumpFile is null)
+ {
+ Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available.");
+ return;
+ }
+
+ // Remove the existing panel root from the tree.
+ if (_panelRoot is not null)
+ {
+ _stack.UiHost.Root.RemoveChild(_panelRoot);
+ _panelRoot = null;
+ }
+
+ // Load the new panel.
+ var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err);
+ if (newRoot is null)
+ {
+ Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}");
+ _currentSlug = null;
+ return;
+ }
+
+ _stack.UiHost.Root.AddChild(newRoot);
+ _panelRoot = newRoot;
+ _currentSlug = slug;
+ _inspector.Selected = null;
+ }
+
private void OnClosing()
{
_imgui?.Dispose();