feat(studio): UX pass — selection highlight, in-studio panel picker, fixed layout
Fix 1 (Selection highlight): StudioInspector.DrawCanvas draws a bright-green 2px outline over Selected using the window draw list. ScreenPosition maps directly to FBO pixel space; rectMin offsets into screen space. Fix 2 (In-studio panel picker): DrawToolbar (BeginCombo over ListSlugs output). StudioWindow resolves _dumpFile once in OnLoad, tracks _currentSlug, and calls LoadDumpPanel on combo change. LoadDumpPanel removes old root via RemoveChild, loads new slug, resets Selected. No relaunch needed. Fix 3 (Fixed layout): All four panes use SetNextWindowPos + SetNextWindowSize with ImGuiCond.FirstUseEver. Toolbar (0,0 x windowW x 40), tree (0,40 x 280), canvas (280,40 x centre), props (right-340,40 x 340). User can drag from these. Build + dotnet test green (609 passed, 0 failed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fe7b4a057
commit
c03a241884
2 changed files with 163 additions and 17 deletions
|
|
@ -5,10 +5,12 @@ using ImGuiNET;
|
||||||
namespace AcDream.App.Studio;
|
namespace AcDream.App.Studio;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Three-pane ImGui IDE for the acdream UI Studio:
|
/// Four-pane ImGui IDE for the acdream UI Studio:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Toolbar</b> — panel picker (slug combo) across the top.</item>
|
||||||
/// <item><b>Canvas</b> — shows the panel FBO texture; click-to-inspect returns the
|
/// <item><b>Canvas</b> — shows the panel FBO texture; click-to-inspect returns the
|
||||||
/// panel-local pixel coordinate of the click.</item>
|
/// panel-local pixel coordinate of the click; draws a green outline over
|
||||||
|
/// <see cref="Selected"/>.</item>
|
||||||
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
|
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
|
||||||
/// sets <see cref="Selected"/>.</item>
|
/// sets <see cref="Selected"/>.</item>
|
||||||
/// <item><b>Properties</b> — shows the <see cref="Selected"/> element's geometry,
|
/// <item><b>Properties</b> — shows the <see cref="Selected"/> 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
|
/// 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
|
/// pixel y = 0 is the TOP of the panel, matching the UiRoot's top-left origin — so
|
||||||
/// click y maps directly without further inversion.</para>
|
/// click y maps directly without further inversion.</para>
|
||||||
|
///
|
||||||
|
/// <para>Layout: <see cref="DrawToolbar"/> / <see cref="DrawCanvas"/> /
|
||||||
|
/// <see cref="DrawTree"/> / <see cref="DrawProperties"/> each call
|
||||||
|
/// <c>SetNextWindowPos</c> + <c>SetNextWindowSize</c> with
|
||||||
|
/// <c>ImGuiCond.FirstUseEver</c> so the panes start in a docked-style arrangement
|
||||||
|
/// that the user can freely drag from.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StudioInspector
|
public sealed class StudioInspector
|
||||||
{
|
{
|
||||||
/// <summary>Currently selected element (set by tree-click or canvas-click).</summary>
|
/// <summary>Currently selected element (set by tree-click or canvas-click).</summary>
|
||||||
public UiElement? Selected { get; set; }
|
public UiElement? Selected { get; set; }
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slugs">All available panel slugs (from <c>UiDumpModel.ListSlugs</c>).</param>
|
||||||
|
/// <param name="current">The slug of the panel currently loaded.</param>
|
||||||
|
/// <param name="windowW">Studio window width (pixels).</param>
|
||||||
|
public string? DrawToolbar(IReadOnlyList<string> 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 ────────────────────────────────────────────────────────────────────
|
// ── Canvas ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -42,9 +87,18 @@ public sealed class StudioInspector
|
||||||
///
|
///
|
||||||
/// <para>The texture is drawn with uv0=(0,1) / uv1=(1,0) to flip V, correcting
|
/// <para>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.</para>
|
/// the GL bottom-left FBO origin to the ImGui top-left convention.</para>
|
||||||
|
///
|
||||||
|
/// <para>If <see cref="Selected"/> 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. <see cref="UiElement.ScreenPosition"/> 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).</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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");
|
ImGui.Begin("Canvas");
|
||||||
|
|
||||||
var imageSize = new Vector2(width, height);
|
var imageSize = new Vector2(width, height);
|
||||||
|
|
@ -55,13 +109,29 @@ public sealed class StudioInspector
|
||||||
var uv1 = new Vector2(1f, 0f);
|
var uv1 = new Vector2(1f, 0f);
|
||||||
ImGui.Image(panelTex, imageSize, uv0, uv1);
|
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;
|
(int x, int y)? result = null;
|
||||||
|
|
||||||
// Detect a left-click on the image.
|
// Detect a left-click on the image.
|
||||||
if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
|
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();
|
var mousePos = ImGui.GetMousePos();
|
||||||
|
|
||||||
// Panel-local pixel = mouse offset from image top-left.
|
// Panel-local pixel = mouse offset from image top-left.
|
||||||
|
|
@ -81,8 +151,10 @@ public sealed class StudioInspector
|
||||||
// ── Tree ──────────────────────────────────────────────────────────────────────
|
// ── Tree ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
|
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
|
||||||
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");
|
ImGui.Begin("Tree");
|
||||||
DrawTreeNode(root);
|
DrawTreeNode(root);
|
||||||
ImGui.End();
|
ImGui.End();
|
||||||
|
|
@ -120,8 +192,10 @@ public sealed class StudioInspector
|
||||||
// ── Properties ───────────────────────────────────────────────────────────────
|
// ── Properties ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
|
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
|
||||||
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");
|
ImGui.Begin("Properties");
|
||||||
|
|
||||||
var el = Selected;
|
var el = Selected;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,11 @@ public sealed class StudioWindow : IDisposable
|
||||||
private StudioInspector? _inspector;
|
private StudioInspector? _inspector;
|
||||||
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
|
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<string> _dumpSlugs = Array.Empty<string>(); // all slugs from the dump
|
||||||
|
|
||||||
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
|
// 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.
|
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
|
||||||
private AcDream.Core.Items.ClientObjectTable? _objects;
|
private AcDream.Core.Items.ClientObjectTable? _objects;
|
||||||
|
|
@ -123,11 +128,17 @@ public sealed class StudioWindow : IDisposable
|
||||||
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
|
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
|
||||||
// All other modes use LayoutSource + FixtureProvider (production path).
|
// All other modes use LayoutSource + FixtureProvider (production path).
|
||||||
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont);
|
_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;
|
UiElement? root;
|
||||||
if (_opts.DumpSlug is not null)
|
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. " +
|
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, " +
|
"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
|
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)
|
if (root is null)
|
||||||
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
|
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
|
||||||
|
else
|
||||||
|
_currentSlug = _opts.DumpSlug;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -265,12 +278,33 @@ public sealed class StudioWindow : IDisposable
|
||||||
// 4. Begin the ImGui frame.
|
// 4. Begin the ImGui frame.
|
||||||
_imgui.BeginFrame((float)dt);
|
_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;
|
(int x, int y)? click = null;
|
||||||
if (panelTex != 0)
|
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
|
// 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.
|
// in the same root-space the FBO was drawn in, so it maps 1:1.
|
||||||
if (click is { } c)
|
if (click is { } c)
|
||||||
|
|
@ -280,17 +314,55 @@ public sealed class StudioWindow : IDisposable
|
||||||
_inspector.Selected = hit;
|
_inspector.Selected = hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Element tree pane.
|
// 8. Element tree pane.
|
||||||
if (_panelRoot is not null)
|
if (_panelRoot is not null)
|
||||||
_inspector.DrawTree(_panelRoot);
|
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
|
||||||
|
|
||||||
// 8. Properties pane.
|
// 9. Properties pane.
|
||||||
_inspector.DrawProperties();
|
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
|
||||||
|
|
||||||
// 9. Finalise ImGui and flush draw data to the window.
|
// 9. Finalise ImGui and flush draw data to the window.
|
||||||
_imgui.Render();
|
_imgui.Render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load a different dump panel at runtime (no relaunch required).
|
||||||
|
/// Removes the current <see cref="_panelRoot"/> from the UI tree,
|
||||||
|
/// loads the named slug from the dump, and installs the new root.
|
||||||
|
/// Resets <see cref="StudioInspector.Selected"/> to null.
|
||||||
|
/// No-op when the dump file is not available or the slug fails to load.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
_imgui?.Dispose();
|
_imgui?.Dispose();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue