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>
393 lines
16 KiB
C#
393 lines
16 KiB
C#
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.UI;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.Options;
|
||
using Silk.NET.Input;
|
||
using Silk.NET.Maths;
|
||
using Silk.NET.OpenGL;
|
||
using Silk.NET.Windowing;
|
||
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.PixelFormats;
|
||
using static Silk.NET.OpenGL.ClearBufferMask;
|
||
|
||
namespace AcDream.App.Studio;
|
||
|
||
/// <summary>
|
||
/// Standalone Silk.NET window that boots the production render stack
|
||
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
|
||
/// identified by a <see cref="LayoutSource"/>.
|
||
///
|
||
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path]
|
||
///
|
||
/// Task 3 adds an ImGui IDE on top of the panel FBO:
|
||
/// <list type="bullet">
|
||
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
|
||
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
|
||
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
|
||
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
|
||
/// element under the cursor via <see cref="UiRoot.Pick"/>.</item>
|
||
/// </list>
|
||
///
|
||
/// The window is intentionally thin: no game world, no physics, no streaming —
|
||
/// just GL + UiHost + the layout under test, identical to how the panel
|
||
/// appears inside <c>GameWindow</c>.
|
||
/// </summary>
|
||
public sealed class StudioWindow : IDisposable
|
||
{
|
||
private readonly StudioOptions _opts;
|
||
|
||
// Created in OnLoad, released in OnClosing.
|
||
private IWindow? _window;
|
||
private DatCollection? _dats;
|
||
private RenderStack? _stack;
|
||
private LayoutSource? _source;
|
||
|
||
// Task 3 additions.
|
||
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
|
||
private PanelFbo? _panelFbo;
|
||
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<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
|
||
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
|
||
private AcDream.Core.Items.ClientObjectTable? _objects;
|
||
|
||
// Headless screenshot mode: set when --screenshot was passed.
|
||
// True after the first OnRender fires the screenshot (guard against repeat).
|
||
private bool _screenshotDone;
|
||
|
||
public StudioWindow(StudioOptions opts)
|
||
{
|
||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Open the window and block until it is closed.
|
||
/// Mirrors <c>GameWindow.Run()</c>.
|
||
/// </summary>
|
||
public void Run()
|
||
{
|
||
// Resolve quality settings the same way GameWindow.Run() does
|
||
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
|
||
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||
var startupDisplay = startupStore.LoadDisplay();
|
||
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
|
||
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
|
||
|
||
var options = WindowOptions.Default with
|
||
{
|
||
Size = new Vector2D<int>(1280, 720),
|
||
Title = "acdream UI Studio",
|
||
API = new GraphicsAPI(
|
||
ContextAPI.OpenGL,
|
||
ContextProfile.Core,
|
||
ContextFlags.ForwardCompatible,
|
||
new APIVersion(4, 3)),
|
||
VSync = false,
|
||
// MSAA from quality preset — must be baked into the GL context at creation.
|
||
Samples = startupQuality.MsaaSamples,
|
||
PreferredStencilBufferBits = 8,
|
||
// Headless screenshot mode: hide the window so no desktop flash occurs.
|
||
// The GL context is still fully valid on a hidden window; FBO rendering
|
||
// is off-screen and independent of window visibility.
|
||
IsVisible = _opts.ScreenshotPath is null,
|
||
};
|
||
|
||
_window = Window.Create(options);
|
||
_window.Load += OnLoad;
|
||
_window.Update += OnUpdate;
|
||
_window.Render += OnRender;
|
||
_window.Closing += OnClosing;
|
||
_window.Run();
|
||
}
|
||
|
||
private void OnLoad()
|
||
{
|
||
var gl = GL.GetApi(_window!);
|
||
|
||
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read);
|
||
|
||
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read
|
||
// after the GL context is confirmed, mirroring GameWindow.OnLoad).
|
||
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||
var display = store.LoadDisplay();
|
||
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
|
||
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
|
||
|
||
_stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality));
|
||
|
||
// Load the panel described by options and add it to the UI tree.
|
||
// Task 4b: --dump <slug> 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)
|
||
{
|
||
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, " +
|
||
"or pass --dump-file <path>.");
|
||
root = null;
|
||
}
|
||
else
|
||
{
|
||
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
|
||
{
|
||
root = _source.Load(_opts);
|
||
if (root is null)
|
||
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
|
||
}
|
||
|
||
_panelRoot = root;
|
||
if (root is not null)
|
||
{
|
||
_stack.UiHost.Root.AddChild(root);
|
||
|
||
// Task 4: populate the panel with sample data via production controllers,
|
||
// so inventory / vitals / toolbar panels render with plausible content.
|
||
// Dump source is static — no FixtureProvider needed.
|
||
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
|
||
{
|
||
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
|
||
_objects = SampleData.BuildObjectTable();
|
||
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats);
|
||
}
|
||
}
|
||
|
||
// Task 3: ImGui IDE — interactive mode only.
|
||
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
|
||
_panelFbo = new PanelFbo(gl);
|
||
if (_opts.ScreenshotPath is null)
|
||
{
|
||
var input = _window!.CreateInput();
|
||
// Wire input into UiHost (interactive only).
|
||
foreach (var mouse in input.Mice)
|
||
_stack.UiHost.WireMouse(mouse);
|
||
foreach (var kb in input.Keyboards)
|
||
_stack.UiHost.WireKeyboard(kb);
|
||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
||
_inspector = new StudioInspector();
|
||
}
|
||
}
|
||
|
||
private void OnUpdate(double dt) { }
|
||
|
||
private void OnRender(double dt)
|
||
{
|
||
if (_stack is null || _panelFbo is null) return;
|
||
|
||
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
|
||
if (_opts.ScreenshotPath is not null)
|
||
{
|
||
if (_screenshotDone) return; // fire exactly once
|
||
_screenshotDone = true;
|
||
|
||
// Pick render size from the loaded root's bounds (clamped to sane limits).
|
||
// Fall back to 1280×720 when the root has no explicit size.
|
||
int w = 1280, h = 720;
|
||
if (_panelRoot is not null)
|
||
{
|
||
float rw = _panelRoot.Width;
|
||
float rh = _panelRoot.Height;
|
||
if (rw >= 1f && rh >= 1f)
|
||
{
|
||
w = Math.Clamp((int)rw, 256, 2048);
|
||
h = Math.Clamp((int)rh, 256, 2048);
|
||
}
|
||
}
|
||
|
||
// Tick once so widget state is initialised (e.g. bar fills).
|
||
_stack.UiHost.Tick(dt);
|
||
|
||
// Render the panel into the FBO.
|
||
_panelFbo.Render(w, h, _stack.UiHost);
|
||
|
||
// Read back RGBA pixels (FBO origin = bottom-left).
|
||
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
|
||
if (pixels.Length == 0)
|
||
{
|
||
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
|
||
_window?.Close();
|
||
return;
|
||
}
|
||
|
||
// Flip rows vertically: FBO bottom-left → PNG top-left.
|
||
int stride = w * 4;
|
||
byte[] flipped = new byte[pixels.Length];
|
||
for (int row = 0; row < h; row++)
|
||
{
|
||
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
|
||
}
|
||
|
||
// Build ImageSharp image and save as PNG.
|
||
var path = _opts.ScreenshotPath;
|
||
var dir = Path.GetDirectoryName(path);
|
||
if (!string.IsNullOrEmpty(dir))
|
||
Directory.CreateDirectory(dir);
|
||
|
||
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
|
||
img.SaveAsPng(path);
|
||
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
|
||
|
||
_window?.Close();
|
||
return;
|
||
}
|
||
|
||
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
|
||
if (_imgui is null || _inspector is null) return;
|
||
|
||
var gl = _stack.Gl;
|
||
int iw = _window!.Size.X;
|
||
int ih = _window!.Size.Y;
|
||
|
||
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
|
||
_stack.UiHost.Tick(dt);
|
||
|
||
// 2. Render the panel into the off-screen FBO; get the color texture.
|
||
// The FBO is the same logical size as the window, so element rects map 1:1 to
|
||
// FBO pixels — no scale factor needed when displaying the canvas at full size.
|
||
uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
|
||
|
||
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
|
||
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
|
||
gl.Clear(ColorBufferBit | DepthBufferBit);
|
||
|
||
// 4. Begin the ImGui frame.
|
||
_imgui.BeginFrame((float)dt);
|
||
|
||
// ── 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,
|
||
canvasX, canvasW, paneY, paneH);
|
||
|
||
// 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)
|
||
{
|
||
var hit = _stack.UiHost.Root.Pick(c.x, c.y);
|
||
if (hit is not null)
|
||
_inspector.Selected = hit;
|
||
}
|
||
|
||
// 8. Element tree pane.
|
||
if (_panelRoot is not null)
|
||
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
|
||
|
||
// 9. Properties pane.
|
||
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
|
||
|
||
// 9. Finalise ImGui and flush draw data to the window.
|
||
_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()
|
||
{
|
||
_imgui?.Dispose();
|
||
_panelFbo?.Dispose();
|
||
_imgui = null;
|
||
_panelFbo = null;
|
||
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
|
||
_dats?.Dispose();
|
||
_dats = null;
|
||
_stack = null;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_imgui?.Dispose();
|
||
_panelFbo?.Dispose();
|
||
_imgui = null;
|
||
_panelFbo = null;
|
||
_window?.Dispose();
|
||
_window = null;
|
||
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
|
||
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
|
||
_stack?.Dispose();
|
||
_dats?.Dispose();
|
||
_dats = null;
|
||
_stack = null;
|
||
}
|
||
}
|