Canvas clicks now reach the panel UiHost so buttons, tabs, and slots respond to user interaction. Previously only UiRoot.Pick (inspector selection) received click events; the panel itself was inert. Key changes: - StudioInspector.DrawCanvas now returns a CanvasInputEvent struct (was nullable click tuple) — carries isHovered, move position, leftDown/Up, and scroll delta, all in panel-local pixels. - Coordinate mapping: panel_local = mouse_screen - GetItemRectMin() (1:1, no scale factor). V-flip (uv0.Y=1, uv1.Y=0) makes screen top = panel Y=0, so NO extra Y inversion. Documented in comments. - StudioWindow.OnLoad: removed WireMouse — raw Silk window coords are offset by the canvas sub-window position and land in the wrong place. WireKeyboard kept (keyboard input needs no spatial remapping). - StudioWindow.OnRender: forwards OnMouseMove always (hover states), plus OnMouseDown/Up/OnScroll in Interact mode. Console.WriteLine on each forwarded left-click for live verification. - Interact/Inspect toggle: checkbox in the Studio toolbar (default Interact). Inspect mode restores old click-to-select-element behavior while still forwarding OnMouseMove for hover states. - CanvasCoordMappingTests: 7 pure-math unit tests covering the origin, interior points, corner, chrome OOB, and the no-extra-flip invariant (no GL required). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
14 KiB
C#
295 lines
14 KiB
C#
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using ImGuiNET;
|
|
|
|
namespace AcDream.App.Studio;
|
|
|
|
/// <summary>
|
|
/// All canvas mouse events gathered by <see cref="StudioInspector.DrawCanvas"/> in one frame.
|
|
/// All coordinates are already mapped to panel-local pixels (origin top-left, same as UiRoot).
|
|
/// </summary>
|
|
public readonly struct CanvasInputEvent
|
|
{
|
|
/// <summary>Mouse is currently hovering the canvas image. When false all other fields are 0 / false.</summary>
|
|
public readonly bool IsHovered;
|
|
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
|
|
public readonly int MouseX;
|
|
/// <summary>Panel-local pixel coordinate of the mouse this frame (valid when <see cref="IsHovered"/>).</summary>
|
|
public readonly int MouseY;
|
|
/// <summary>Left-button went down this frame.</summary>
|
|
public readonly bool LeftDown;
|
|
/// <summary>Left-button came up this frame.</summary>
|
|
public readonly bool LeftUp;
|
|
/// <summary>Mouse-wheel scroll delta (lines, positive = up). Zero when no scroll.</summary>
|
|
public readonly int ScrollDelta;
|
|
|
|
public CanvasInputEvent(bool hovered, int mx, int my, bool ld, bool lu, int scroll)
|
|
{
|
|
IsHovered = hovered;
|
|
MouseX = mx;
|
|
MouseY = my;
|
|
LeftDown = ld;
|
|
LeftUp = lu;
|
|
ScrollDelta = scroll;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Four-pane ImGui IDE for the acdream UI Studio:
|
|
/// <list type="bullet">
|
|
/// <item><b>Toolbar</b> — panel picker (slug combo) across the top.</item>
|
|
/// <item><b>Canvas</b> — shows the panel FBO texture; in Interact mode mouse events
|
|
/// are forwarded to the panel UiHost (buttons/tabs respond); in Inspect mode a
|
|
/// left-click hit-tests and selects the element under the cursor.</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><b>Coordinate mapping for the canvas:</b>
|
|
/// The FBO is rendered at the full window size and displayed 1:1 inside the Canvas ImGui
|
|
/// sub-window. After <c>ImGui.Image</c> we call <c>ImGui.GetItemRectMin()</c> to get the
|
|
/// screen-space top-left of the drawn image (accounting for the sub-window's title bar,
|
|
/// padding, and any scrolling). Subtracting that from the raw mouse screen position gives
|
|
/// panel-local pixels directly — no additional scale factor is needed because the image is
|
|
/// drawn 1:1.</para>
|
|
///
|
|
/// <para><b>V-flip — no extra Y inversion needed:</b>
|
|
/// The FBO origin is bottom-left (GL convention), so we pass uv0=(0,1), uv1=(1,0) to
|
|
/// <c>ImGui.Image</c> to flip V. After this flip, displayed row 0 (top of the image on
|
|
/// screen) corresponds to panel Y=0 (the top of the UI panel), matching UiRoot's
|
|
/// top-left origin. Therefore the panel-local Y computed above maps directly into UiRoot
|
|
/// without further inversion — do NOT flip Y again.</para>
|
|
///
|
|
/// <para>Layout: the four panes call <c>SetNextWindowPos</c> + <c>SetNextWindowSize</c>
|
|
/// with <c>ImGuiCond.FirstUseEver</c> so they start docked but can be freely dragged.</para>
|
|
/// </summary>
|
|
public sealed class StudioInspector
|
|
{
|
|
/// <summary>Currently selected element (set by tree-click or canvas-click in Inspect mode).</summary>
|
|
public UiElement? Selected { get; set; }
|
|
|
|
/// <summary>
|
|
/// When true (default) canvas mouse events are forwarded to the panel UiHost so elements
|
|
/// respond to clicks. When false a canvas click hit-tests and selects an element in the
|
|
/// inspector tree instead. Toggle via the "Interact / Inspect" checkbox in the toolbar.
|
|
/// </summary>
|
|
public bool InteractMode { get; set; } = true;
|
|
|
|
// ── Toolbar ───────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Draw the "Studio" toolbar window (top strip) containing a slug combo-box and
|
|
/// the Interact / Inspect mode toggle. Returns the newly-selected slug when the
|
|
/// user picks a different panel, or null when unchanged.
|
|
/// <para>The mode toggle sets <see cref="InteractMode"/>: checked = Interact (panel
|
|
/// elements respond to clicks), unchecked = Inspect (clicks select elements in the
|
|
/// tree).</para>
|
|
/// </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.SameLine();
|
|
bool interact = InteractMode;
|
|
if (ImGui.Checkbox("Interact", ref interact))
|
|
InteractMode = interact;
|
|
if (ImGui.IsItemHovered())
|
|
ImGui.SetTooltip("Interact: canvas clicks reach the panel (buttons/tabs respond).\nUncheck to Inspect: clicks select elements in the tree.");
|
|
|
|
ImGui.End();
|
|
return result;
|
|
}
|
|
|
|
// ── Canvas ────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Draw the "Canvas" ImGui window containing the panel FBO texture and return all
|
|
/// canvas mouse events for this frame as a <see cref="CanvasInputEvent"/>.
|
|
///
|
|
/// <para><b>Coordinate mapping:</b> After <c>ImGui.Image</c>, <c>GetItemRectMin()</c>
|
|
/// returns the actual screen-space top-left of the drawn image (accounting for the
|
|
/// sub-window title bar, padding, and scrolling). Subtracting that from the raw ImGui
|
|
/// mouse position gives panel-local pixels directly — no scale factor because the
|
|
/// image is drawn 1:1.</para>
|
|
///
|
|
/// <para><b>V-flip — no extra Y inversion:</b> we pass uv0=(0,1) / uv1=(1,0) so the
|
|
/// GL bottom-left origin is flipped to top-left on screen. After the flip, screen
|
|
/// row 0 = panel Y 0 (top of the UI), so the computed Y already matches UiRoot's
|
|
/// top-left origin — do NOT flip Y again.</para>
|
|
///
|
|
/// <para>If <see cref="Selected"/> is non-null a bright-green 2-pixel outline is
|
|
/// drawn over it using the window draw list.</para>
|
|
/// </summary>
|
|
public CanvasInputEvent 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);
|
|
// 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);
|
|
|
|
// rectMin: screen-space top-left of the image AFTER ImGui.Image + any chrome offset.
|
|
// This is what lets us translate raw mouse screen coords into panel-local pixels.
|
|
var rectMin = ImGui.GetItemRectMin();
|
|
|
|
// ── Selection highlight ───────────────────────────────────────────────
|
|
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);
|
|
}
|
|
|
|
// ── Gather canvas mouse events ────────────────────────────────────────
|
|
// IsItemHovered is true when the mouse is over the Image item (not just the window).
|
|
bool hovered = ImGui.IsItemHovered();
|
|
int mx = 0, my = 0;
|
|
bool leftDown = false, leftUp = false;
|
|
int scroll = 0;
|
|
|
|
if (hovered)
|
|
{
|
|
var mousePos = ImGui.GetMousePos();
|
|
// Panel-local pixel = mouse offset from the image's screen-space top-left.
|
|
// Scale is 1:1 (image drawn at full FBO size). Y needs no extra flip — see summary.
|
|
int ix = (int)(mousePos.X - rectMin.X);
|
|
int iy = (int)(mousePos.Y - rectMin.Y);
|
|
// Clamp to image bounds (mouse can be on the image edge pixel).
|
|
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
|
|
{
|
|
mx = ix;
|
|
my = iy;
|
|
leftDown = ImGui.IsMouseClicked(ImGuiMouseButton.Left);
|
|
leftUp = ImGui.IsMouseReleased(ImGuiMouseButton.Left);
|
|
float wheelY = ImGui.GetIO().MouseWheel;
|
|
scroll = (int)wheelY; // positive = scroll up
|
|
}
|
|
else
|
|
{
|
|
// Mouse is over ImGui chrome (title bar, padding) adjacent to image — not over the panel.
|
|
hovered = false;
|
|
}
|
|
}
|
|
|
|
ImGui.End();
|
|
return new CanvasInputEvent(hovered, mx, my, leftDown, leftUp, scroll);
|
|
}
|
|
|
|
// ── Tree ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
|
|
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();
|
|
}
|
|
|
|
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(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;
|
|
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();
|
|
}
|
|
}
|