diff --git a/src/AcDream.App/Studio/StudioInspector.cs b/src/AcDream.App/Studio/StudioInspector.cs
index c8a85b9a..26e6dc0d 100644
--- a/src/AcDream.App/Studio/StudioInspector.cs
+++ b/src/AcDream.App/Studio/StudioInspector.cs
@@ -4,49 +4,88 @@ using ImGuiNET;
namespace AcDream.App.Studio;
+///
+/// All canvas mouse events gathered by in one frame.
+/// All coordinates are already mapped to panel-local pixels (origin top-left, same as UiRoot).
+///
+public readonly struct CanvasInputEvent
+{
+ /// Mouse is currently hovering the canvas image. When false all other fields are 0 / false.
+ public readonly bool IsHovered;
+ /// Panel-local pixel coordinate of the mouse this frame (valid when ).
+ public readonly int MouseX;
+ /// Panel-local pixel coordinate of the mouse this frame (valid when ).
+ public readonly int MouseY;
+ /// Left-button went down this frame.
+ public readonly bool LeftDown;
+ /// Left-button came up this frame.
+ public readonly bool LeftUp;
+ /// Mouse-wheel scroll delta (lines, positive = up). Zero when no scroll.
+ 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;
+ }
+}
+
///
/// 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; draws a green outline over
-/// .
+/// - Canvas — 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.
/// - Tree — recursive ImGui tree of the element hierarchy; clicking a node
/// sets .
/// - Properties — shows the element's geometry,
/// anchors, and z-order.
///
///
-/// 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).
+/// Coordinate mapping for the canvas:
+/// The FBO is rendered at the full window size and displayed 1:1 inside the Canvas ImGui
+/// sub-window. After ImGui.Image we call ImGui.GetItemRectMin() 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.
///
-/// 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 ImGui.Image, 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.
+/// V-flip — no extra Y inversion needed:
+/// The FBO origin is bottom-left (GL convention), so we pass uv0=(0,1), uv1=(1,0) to
+/// ImGui.Image 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.
///
-/// Layout: / /
-/// / each call
-/// SetNextWindowPos + SetNextWindowSize with
-/// ImGuiCond.FirstUseEver so the panes start in a docked-style arrangement
-/// that the user can freely drag from.
+/// Layout: the four panes call SetNextWindowPos + SetNextWindowSize
+/// with ImGuiCond.FirstUseEver so they start docked but can be freely dragged.
///
public sealed class StudioInspector
{
- /// Currently selected element (set by tree-click or canvas-click).
+ /// Currently selected element (set by tree-click or canvas-click in Inspect mode).
public UiElement? Selected { get; set; }
+ ///
+ /// 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.
+ ///
+ public bool InteractMode { get; set; } = true;
+
// ── 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.
+ /// 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.
+ /// The mode toggle sets : checked = Interact (panel
+ /// elements respond to clicks), unchecked = Inspect (clicks select elements in the
+ /// tree).
///
/// All available panel slugs (from UiDumpModel.ListSlugs).
/// The slug of the panel currently loaded.
@@ -74,6 +113,13 @@ public sealed class StudioInspector
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;
}
@@ -81,20 +127,24 @@ public sealed class StudioInspector
// ── Canvas ────────────────────────────────────────────────────────────────────
///
- /// 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.
+ /// Draw the "Canvas" ImGui window containing the panel FBO texture and return all
+ /// canvas mouse events for this frame as a .
///
- /// 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.
+ /// Coordinate mapping: After ImGui.Image, GetItemRectMin()
+ /// 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.
///
- /// 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).
+ /// V-flip — no extra Y inversion: 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.
+ ///
+ /// If is non-null a bright-green 2-pixel outline is
+ /// drawn over it using the window draw list.
///
- public (int x, int y)? DrawCanvas(nint panelTex, int width, int height,
+ 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);
@@ -109,12 +159,11 @@ 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.
+ // 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)
{
@@ -127,25 +176,39 @@ public sealed class StudioInspector
0f, ImDrawFlags.None, 2f);
}
- (int x, int y)? result = null;
+ // ── 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;
- // Detect a left-click on the image.
- if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
+ if (hovered)
{
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.
+ // 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.
+ // Clamp to image bounds (mouse can be on the image edge pixel).
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
- result = (ix, iy);
+ {
+ 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 result;
+ return new CanvasInputEvent(hovered, mx, my, leftDown, leftUp, scroll);
}
// ── Tree ──────────────────────────────────────────────────────────────────────
diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs
index 28ac55ef..7e09df92 100644
--- a/src/AcDream.App/Studio/StudioWindow.cs
+++ b/src/AcDream.App/Studio/StudioWindow.cs
@@ -183,9 +183,10 @@ public sealed class StudioWindow : IDisposable
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);
+ // 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);
@@ -319,21 +320,57 @@ public sealed class StudioWindow : IDisposable
if (pickedSlug is not null)
LoadDumpPanel(pickedSlug);
- // 6. Canvas pane — show the FBO texture; detect clicks.
- (int x, int y)? click = null;
+ // 6. Canvas pane — show the FBO texture; gather canvas mouse events.
+ var canvasEvt = default(CanvasInputEvent);
if (panelTex != 0)
- click = _inspector.DrawCanvas(
+ canvasEvt = _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)
+ // 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)
{
- var hit = _stack.UiHost.Root.Pick(c.x, c.y);
- if (hit is not null)
- _inspector.Selected = hit;
+ 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.
diff --git a/tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs b/tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs
new file mode 100644
index 00000000..9d007bf3
--- /dev/null
+++ b/tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs
@@ -0,0 +1,118 @@
+using AcDream.App.Studio;
+
+namespace AcDream.App.Tests.Studio;
+
+///
+/// Pure-math tests for the canvas → panel-local coordinate mapping used by
+/// .
+///
+/// No GL context required — we're just verifying the formula:
+/// panel_local = (raw_mouse_screen) - (image_screen_top_left)
+///
+/// The image_screen_top_left is what ImGui.GetItemRectMin() returns after
+/// ImGui.Image: the sub-window top-left + title-bar height + inner padding + any
+/// scrolling. We model it as a constant offset in these tests.
+///
+/// V-flip: the image is drawn with uv0=(0,1) / uv1=(1,0) so GL's bottom-left
+/// origin is flipped to top-left on screen. After the flip, screen Y=0 (top of image)
+/// = panel Y=0 (top of the UI), so NO additional Y inversion is applied.
+///
+public class CanvasCoordMappingTests
+{
+ // Simulates the mapping DrawCanvas performs:
+ // panel pixel = mouse_screen - image_rectMin
+ // Returns null when the result falls outside [0, width) x [0, height).
+ private static (int px, int py)? Map(
+ float mouseScreenX, float mouseScreenY,
+ float imageOriginX, float imageOriginY,
+ int panelWidth, int panelHeight)
+ {
+ int ix = (int)(mouseScreenX - imageOriginX);
+ int iy = (int)(mouseScreenY - imageOriginY);
+ if (ix < 0 || ix >= panelWidth || iy < 0 || iy >= panelHeight)
+ return null;
+ return (ix, iy);
+ }
+
+ [Fact]
+ public void TopLeft_of_image_maps_to_panel_origin()
+ {
+ // The canvas image starts at screen (300, 50) (after sub-window chrome).
+ // A click exactly at the image's screen top-left → panel (0, 0).
+ var result = Map(mouseScreenX: 300f, mouseScreenY: 50f,
+ imageOriginX: 300f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Equal((0, 0), result);
+ }
+
+ [Fact]
+ public void Interior_point_maps_correctly()
+ {
+ // Image origin at screen (300, 50). Mouse at screen (780, 230).
+ // Expected panel coord: (780-300, 230-50) = (480, 180).
+ var result = Map(mouseScreenX: 780f, mouseScreenY: 230f,
+ imageOriginX: 300f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Equal((480, 180), result);
+ }
+
+ [Fact]
+ public void Bottom_right_corner_maps_to_last_valid_pixel()
+ {
+ // Image is 1280×720, origin at screen (300, 50).
+ // Last pixel in bottom-right is panel (1279, 719) → screen (1579, 769).
+ var result = Map(mouseScreenX: 1579f, mouseScreenY: 769f,
+ imageOriginX: 300f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Equal((1279, 719), result);
+ }
+
+ [Fact]
+ public void Mouse_on_ImGui_chrome_above_image_returns_null()
+ {
+ // The ImGui window title-bar / padding is above rectMin, i.e. at screenY < 50.
+ // The mouse there should NOT produce a panel event.
+ var result = Map(mouseScreenX: 400f, mouseScreenY: 40f, // 10px above image origin
+ imageOriginX: 300f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Mouse_below_image_returns_null()
+ {
+ // screenY = 50 + 720 = 770 → iy = 720 which is >= panelHeight (720).
+ var result = Map(mouseScreenX: 400f, mouseScreenY: 770f,
+ imageOriginX: 300f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Y_is_not_inverted_after_vflip()
+ {
+ // Confirm the "no extra Y inversion" contract:
+ // The image is V-flipped in ImGui (uv0.Y=1, uv1.Y=0), so screen top row = panel Y=0.
+ // A click near the TOP of the image should give a SMALL panel Y, not a large one.
+ // Image origin at (300, 50). Click at (400, 55) → panel (100, 5). Y is small (near top).
+ var result = Map(mouseScreenX: 400f, mouseScreenY: 55f,
+ imageOriginX: 300f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Equal((100, 5), result);
+ // NOT (100, 715) — which would be the result if Y were incorrectly inverted.
+ Assert.True(result!.Value.py < 720 / 2, "Y near screen top should map to small panel Y, not near bottom");
+ }
+
+ [Fact]
+ public void LargeChrome_offset_is_fully_absorbed()
+ {
+ // Simulate a canvas sub-window with large chrome: ImGui title (20px) + padding (8px)
+ // puts the image origin at screenY = menuBar(22) + titleBar(20) + padding(8) = 50.
+ // Also a wide tree pane puts imageOriginX = 280 + padding.
+ // A click at screen (400, 110) with origin (290, 50) → panel (110, 60).
+ var result = Map(mouseScreenX: 400f, mouseScreenY: 110f,
+ imageOriginX: 290f, imageOriginY: 50f,
+ panelWidth: 1280, panelHeight: 720);
+ Assert.Equal((110, 60), result);
+ }
+}