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();