feat(studio): ImGui inspector — canvas FBO, element tree, props, click-to-inspect

Task 3 of the acdream UI Studio plan. The studio previously drew the
panel straight to the window; it now renders the panel into an off-screen
FBO (PanelFbo) and displays it in an ImGui Canvas pane alongside a Tree
pane (recursive element hierarchy, click-to-select) and a Properties pane
(EventId/type/rect/anchors/ZOrder/flags of the selected element).

Click-to-inspect: a left-click inside the Canvas calls UiElement.HitTest
on the panel root (same-assembly internal access) and selects the topmost
element at the cursor, wiring the canvas directly to the tree selection.

PanelFbo lifecycle mirrors PaperdollViewportRenderer (RGBA8 color +
Depth24Stencil8 renderbuffer, GLStateScope-sealed, lazy resize on size
change). V-flip in DrawCanvas (uv0=(0,1)/uv1=(1,0)) corrects GL's
bottom-left FBO origin to ImGui's top-left convention so click Y maps
directly to panel-local Y without inversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 15:06:12 +02:00
parent df6b5b3b39
commit d2240974ec
3 changed files with 359 additions and 4 deletions

View file

@ -0,0 +1,158 @@
using System.Numerics;
using AcDream.App.UI;
using ImGuiNET;
namespace AcDream.App.Studio;
/// <summary>
/// Three-pane ImGui IDE for the acdream UI Studio:
/// <list type="bullet">
/// <item><b>Canvas</b> — shows the panel FBO texture; click-to-inspect returns the
/// panel-local pixel coordinate of the click.</item>
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
/// sets <see cref="Selected"/>.</item>
/// <item><b>Properties</b> — shows the <see cref="Selected"/> element's geometry,
/// anchors, and z-order.</item>
/// </list>
///
/// <para>Coordinate mapping for the canvas:
/// The FBO is rendered at the same logical size as the window, so a click at image-local
/// pixel (ix, iy) maps directly to panel coord (ix, iy) — no scale factor needed when
/// the image is drawn 1:1. We draw it 1:1 if it fits; if the Canvas ImGui window is
/// smaller we let ImGui clip it (the click coordinates are still image-local pixels so
/// no scale correction is needed in v1).</para>
///
/// <para>V-flip: the FBO origin is bottom-left (GL convention), but ImGui images use
/// top-left. We pass uv0=(0,1), uv1=(1,0) to <c>ImGui.Image</c>, which flips V so
/// 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.</para>
/// </summary>
public sealed class StudioInspector
{
/// <summary>Currently selected element (set by tree-click or canvas-click).</summary>
public UiElement? Selected { get; set; }
// ── Canvas ────────────────────────────────────────────────────────────────────
/// <summary>
/// Draw the "Canvas" ImGui window containing the panel FBO texture.
/// If the user left-clicks inside the image, returns the panel-local pixel
/// coordinate of the click (origin top-left, matching UiRoot's space); else null.
///
/// <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>
/// </summary>
public (int x, int y)? DrawCanvas(nint panelTex, int width, int height)
{
ImGui.Begin("Canvas");
var imageSize = new Vector2(width, height);
// V-flip: FBO origin is bottom-left; ImGui images expect top-left.
// uv0 = bottom-left of texture = top of the panel in screen space.
// uv1 = top-right of texture = bottom of the panel in screen space.
var uv0 = new Vector2(0f, 1f);
var uv1 = new Vector2(1f, 0f);
ImGui.Image(panelTex, imageSize, uv0, uv1);
(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.
// Because we flipped V (uv0.Y=1, uv1.Y=0), image row 0 IS the top of the panel,
// so y needs no further inversion.
int ix = (int)(mousePos.X - rectMin.X);
int iy = (int)(mousePos.Y - rectMin.Y);
// Clamp to image bounds.
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
result = (ix, iy);
}
ImGui.End();
return result;
}
// ── Tree ──────────────────────────────────────────────────────────────────────
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
public void DrawTree(UiElement root)
{
ImGui.Begin("Tree");
DrawTreeNode(root);
ImGui.End();
}
private void DrawTreeNode(UiElement el)
{
// Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]"
string label = $"0x{el.EventId:X8} [{el.GetType().Name}]";
bool isSelected = ReferenceEquals(el, Selected);
bool hasChildren = el.Children.Count > 0;
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow
| ImGuiTreeNodeFlags.SpanAvailWidth;
if (!hasChildren)
flags |= ImGuiTreeNodeFlags.Leaf;
if (isSelected)
flags |= ImGuiTreeNodeFlags.Selected;
bool open = ImGui.TreeNodeEx(label, flags);
// Click on the node label (not the arrow) selects it.
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
Selected = el;
if (open)
{
foreach (var child in el.Children)
DrawTreeNode(child);
ImGui.TreePop();
}
}
// ── Properties ───────────────────────────────────────────────────────────────
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
public void DrawProperties()
{
ImGui.Begin("Properties");
var el = Selected;
if (el is null)
{
ImGui.TextUnformatted("(nothing selected)");
ImGui.End();
return;
}
ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}");
ImGui.TextUnformatted($"Type: {el.GetType().Name}");
ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}");
ImGui.Separator();
ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})");
ImGui.TextUnformatted($"Anchors: {el.Anchors}");
ImGui.TextUnformatted($"ZOrder: {el.ZOrder}");
ImGui.Separator();
ImGui.TextUnformatted($"Visible: {el.Visible}");
ImGui.TextUnformatted($"Enabled: {el.Enabled}");
ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}");
ImGui.TextUnformatted($"Draggable: {el.Draggable}");
ImGui.TextUnformatted($"Resizable: {el.Resizable}");
ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}");
ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}");
ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}");
ImGui.Separator();
var sp = el.ScreenPosition;
ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})");
ImGui.TextUnformatted($"Children: {el.Children.Count}");
ImGui.End();
}
}