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;
///
/// Standalone Silk.NET window that boots the production render stack
/// () and previews a single UI panel
/// identified by a .
///
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path]
///
/// Task 3 adds an ImGui IDE on top of the panel FBO:
///
/// - Canvas pane — the panel rendered off-screen via .
/// - Tree pane — the element hierarchy; click-to-select.
/// - Properties pane — geometry/anchors/flags of the selected element.
/// - Click-to-inspect — a left-click in the canvas selects the topmost
/// element under the cursor via .
///
///
/// 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 GameWindow.
///
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 _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;
// 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));
}
///
/// Open the window and block until it is closed.
/// Mirrors GameWindow.Run().
///
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(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 uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path).
//
// Fix C: pass the per-element font resolver into LayoutSource so that elements
// with a non-zero FontDid get their own dat font at build time. This is wired
// ONLY in the studio path; GameWindow's Import calls continue to pass null so the
// live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up).
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont,
fontResolve: _stack.ResolveDatFont);
// 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 .");
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 KEYBOARD input into UiHost (keyboard coords are not spatial, so no remapping needed).
// Do NOT wire mouse here — raw Silk window coords would be offset by the canvas sub-window's
// position (tree pane width + ImGui chrome) and land in the wrong panel-local location.
// Mouse is forwarded manually from DrawCanvas with correct panel-local mapping below.
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(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) ──────────────
// MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
// is never covered by the floating panes (replaces the old 40px toolbar).
// Tree: 280px wide on the left, below menu bar.
// Canvas: centre strip between tree and properties.
// Props: 340px wide on the right, below menu bar.
const int kMenuBarH = 22; // ImGui default main menu bar height
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 = kMenuBarH;
int paneH = Math.Max(1, ih - kMenuBarH);
// 5. Main menu bar — panel picker combo pinned to the window top.
// BeginMainMenuBar returns true when the bar is visible (always is); the combo
// inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
string? pickedSlug = null;
if (ImGuiNET.ImGui.BeginMainMenuBar())
{
ImGuiNET.ImGui.SetNextItemWidth(300f);
string preview = _currentSlug ?? "(none)";
if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
{
foreach (var slug in _dumpSlugs)
{
bool selected = string.Equals(slug, _currentSlug,
System.StringComparison.OrdinalIgnoreCase);
if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
pickedSlug = slug;
if (selected)
ImGuiNET.ImGui.SetItemDefaultFocus();
}
ImGuiNET.ImGui.EndCombo();
}
ImGuiNET.ImGui.EndMainMenuBar();
}
if (pickedSlug is not null)
LoadDumpPanel(pickedSlug);
// 6. Canvas pane — show the FBO texture; gather canvas mouse events.
var canvasEvt = default(CanvasInputEvent);
if (panelTex != 0)
canvasEvt = _inspector.DrawCanvas(
(nint)panelTex, iw, ih,
canvasX, canvasW, paneY, paneH);
// 7. Forward canvas mouse events to the panel UiHost or the inspector tree.
//
// Coordinate mapping (see StudioInspector.DrawCanvas summary):
// panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip)
// The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0.
//
// Interact mode (default): canvas mouse events go directly to UiRoot so elements respond.
// OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded.
// A Console.WriteLine confirms each forwarded left-click for live verification.
//
// Inspect mode: left-click hit-tests and selects the element in the tree (old behavior).
// OnMouseMove is still forwarded so hover/tooltip state in the panel stays live.
if (canvasEvt.IsHovered)
{
int mx = canvasEvt.MouseX;
int my = canvasEvt.MouseY;
var root = _stack.UiHost.Root;
// Always forward mouse-move so hover highlights / tooltips in the panel work.
root.OnMouseMove(mx, my);
if (_inspector.InteractMode)
{
// ── Interact: live panel interaction ──────────────────────────────
if (canvasEvt.LeftDown)
{
Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})");
root.OnMouseDown(UiMouseButton.Left, mx, my);
}
if (canvasEvt.LeftUp)
root.OnMouseUp(UiMouseButton.Left, mx, my);
if (canvasEvt.ScrollDelta != 0)
root.OnScroll(canvasEvt.ScrollDelta);
}
else
{
// ── Inspect: click selects an element in the tree ─────────────────
if (canvasEvt.LeftDown)
{
var hit = root.Pick(mx, my);
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();
}
///
/// 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();
_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;
}
}