acdream/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
Erik 2818fcca8c fix(ui): scope title-bar-only-drag absorber to BeginChild — Settings tabs work
Previous fix put the InvisibleButton absorber inside Begin, which
covered the entire panel body — and the Settings panel's tab bar
has its hit-testing in that same area. Tabs lost click priority to
the absorber (their hover/click events were stolen) so the user
couldn't switch tabs. Worse, the chat-panel drag the absorber was
supposed to fix wasn't actually fixed because chat's body is
covered by a BeginChild for the scrollable tail — clicks land in
the child window, not the parent body, so the parent absorber
never sees them.

Right scope: scrollable BeginChild bodies. That's where the chat
panel's empty-space clicks actually land, and where the parent-
drag fall-through originates. Other panels (Settings, Vitals,
Debug) don't use BeginChild for content — their bodies are filled
with widgets that already absorb clicks naturally.

The fix:
 · Begin reverts to ImGui default (title bar drags, body of widget-
   filled panels naturally absorbs through the widgets themselves).
 · BeginChild grows the InvisibleButton absorber inside, so empty-
   space clicks inside a scroll region don't fall through to the
   parent's window-drag init.

Net effect:
 · Chat panel: empty clicks in the scroll tail no longer drag the
   parent window.
 · Settings panel: tabs are clickable again.
 · Vitals, Debug: unchanged.

dotnet build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:04:10 +02:00

256 lines
9.3 KiB
C#

using System.Numerics;
using AcDream.UI.Abstractions;
using ImGuiNET;
namespace AcDream.UI.ImGui;
/// <summary>
/// <see cref="IPanelRenderer"/> implemented as thin wrappers around
/// ImGui.NET calls. This is the ONLY place where ImGuiNET types appear
/// outside of bootstrap plumbing — panels that need a feature must
/// extend the abstraction here, not by importing ImGuiNET in panel
/// files.
/// </summary>
public sealed class ImGuiPanelRenderer : IPanelRenderer
{
/// <inheritdoc />
public bool Begin(string title) => ImGuiNET.ImGui.Begin(title);
/// <inheritdoc />
public void End() => ImGuiNET.ImGui.End();
/// <inheritdoc />
public void Text(string text) => ImGuiNET.ImGui.TextUnformatted(text);
/// <inheritdoc />
public void SameLine() => ImGuiNET.ImGui.SameLine();
/// <inheritdoc />
public void Separator() => ImGuiNET.ImGui.Separator();
/// <inheritdoc />
public void ProgressBar(float fraction, float width, string? overlay = null)
{
// Clamp defensively; ImGui clamps internally but the abstraction
// contract promises to handle out-of-range values.
if (fraction < 0f) fraction = 0f;
else if (fraction > 1f) fraction = 1f;
var size = new Vector2(width, 0f); // height 0 → ImGui picks based on font
ImGuiNET.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty);
}
// -- Phase I.1 widget extensions ---------------------------------
/// <inheritdoc />
public void TextColored(Vector4 rgba, string text)
=> ImGuiNET.ImGui.TextColored(rgba, text);
/// <inheritdoc />
public bool CollapsingHeader(string label, bool defaultOpen = true)
=> ImGuiNET.ImGui.CollapsingHeader(
label,
defaultOpen ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None);
/// <inheritdoc />
public bool TreeNode(string label) => ImGuiNET.ImGui.TreeNode(label);
/// <inheritdoc />
public void TreePop() => ImGuiNET.ImGui.TreePop();
/// <inheritdoc />
public bool Checkbox(string label, ref bool value)
=> ImGuiNET.ImGui.Checkbox(label, ref value);
/// <inheritdoc />
public bool Button(string label) => ImGuiNET.ImGui.Button(label);
/// <inheritdoc />
public bool Combo(string label, ref int selectedIndex, string[] items)
=> ImGuiNET.ImGui.Combo(label, ref selectedIndex, items, items.Length);
/// <inheritdoc />
public bool SliderFloat(string label, ref float value, float min, float max)
=> ImGuiNET.ImGui.SliderFloat(label, ref value, min, max);
/// <inheritdoc />
public void PlotLines(
string label,
float[] values,
int count,
int offset = 0,
string? overlay = null,
float? min = null,
float? max = null,
Vector2? size = null)
{
// ImGui.NET 1.91.6.1's PlotLines binding takes `ref float values`
// (pointer-to-first-element semantics) plus a separate values_count
// and values_offset. The "no fixed bound" / "default size" sentinels
// are float.MaxValue and Vector2.Zero respectively — we pass those
// when the caller leaves the optional args null.
if (count <= 0 || values.Length == 0)
{
// Nothing to plot — emit the label so layout doesn't shift but
// skip the native call (ref to values[0] would NRE on empty).
ImGuiNET.ImGui.TextUnformatted(label);
return;
}
float scaleMin = min ?? float.MaxValue;
float scaleMax = max ?? float.MaxValue;
Vector2 graphSize = size ?? Vector2.Zero;
ImGuiNET.ImGui.PlotLines(
label,
ref values[0],
count,
offset,
overlay ?? string.Empty,
scaleMin,
scaleMax,
graphSize);
}
/// <inheritdoc />
public void BeginTable(string id, int columns)
=> ImGuiNET.ImGui.BeginTable(id, columns);
/// <inheritdoc />
public void TableNextColumn() => ImGuiNET.ImGui.TableNextColumn();
/// <inheritdoc />
public void EndTable() => ImGuiNET.ImGui.EndTable();
/// <inheritdoc />
public bool InputTextSubmit(string label, ref string buffer, int maxLen, out string? submitted)
{
// EnterReturnsTrue: the call returns true on the frame the user
// pressed Enter. On every other frame ImGui still mutates `buffer`
// as the user types; we just don't surface a submit.
bool entered = ImGuiNET.ImGui.InputText(
label,
ref buffer,
(uint)maxLen,
ImGuiInputTextFlags.EnterReturnsTrue);
if (entered)
{
submitted = buffer;
buffer = string.Empty; // contract: clear for next frame
return true;
}
submitted = null;
return false;
}
/// <inheritdoc />
public void Spacing() => ImGuiNET.ImGui.Spacing();
/// <inheritdoc />
public void Dummy(Vector2 size) => ImGuiNET.ImGui.Dummy(size);
/// <inheritdoc />
public void TextWrapped(string text) => ImGuiNET.ImGui.TextWrapped(text);
// -- Phase J Tier 3 — scrollable child for chat-style layouts ---------
/// <inheritdoc />
public bool BeginChild(string id, Vector2 size, bool border = false)
{
// ImGuiChildFlags has changed names across ImGui.NET versions
// (Border vs Borders); 0x01 is the stable bit value for "draw
// a border". Casting from a numeric literal sidesteps the
// version-skew without requiring a hard reference to either
// enum spelling.
bool open = ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0));
if (open)
{
// Title-bar-only drag fix (chat tail specifically): empty
// clicks inside a scrollable child fall through to the
// parent window for drag-init, which is exactly what the
// user reported in the chat panel ("clicking anywhere
// moves the window"). An InvisibleButton sized to the
// child's content region absorbs those clicks so they
// don't propagate. Real widgets drawn afterwards still
// claim their own clicks (click priority = "last drawn,
// first checked"). Wheel scrolling is window-level, not
// item-level, so the absorber doesn't interfere with
// the chat tail's auto-scroll.
//
// Scoped to BeginChild only (NOT Begin) because Begin's
// body might host tab bars whose hit-testing competes with
// an absorber on equal terms — adding it at Begin level
// broke Settings tab clicks.
var avail = ImGuiNET.ImGui.GetContentRegionAvail();
if (avail.X > 0f && avail.Y > 0f)
{
var savedCursor = ImGuiNET.ImGui.GetCursorPos();
ImGuiNET.ImGui.InvisibleButton("##childbodyabsorb", avail);
ImGuiNET.ImGui.SetCursorPos(savedCursor);
}
}
return open;
}
/// <inheritdoc />
public void EndChild() => ImGuiNET.ImGui.EndChild();
/// <inheritdoc />
public float FrameHeightWithSpacing() => ImGuiNET.ImGui.GetFrameHeightWithSpacing();
/// <inheritdoc />
public void SetScrollHereY(float ratio) => ImGuiNET.ImGui.SetScrollHereY(ratio);
/// <inheritdoc />
public void SetKeyboardFocusHere() => ImGuiNET.ImGui.SetKeyboardFocusHere();
// -- Phase K.3 — main menu bar -----------------------------------------
/// <inheritdoc />
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
/// <inheritdoc />
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
/// <inheritdoc />
public bool BeginMenu(string label) => ImGuiNET.ImGui.BeginMenu(label);
/// <inheritdoc />
public void EndMenu() => ImGuiNET.ImGui.EndMenu();
/// <inheritdoc />
public bool MenuItem(string label, string? shortcut = null)
=> shortcut is null
? ImGuiNET.ImGui.MenuItem(label)
: ImGuiNET.ImGui.MenuItem(label, shortcut);
// -- Tab bar -----------------------------------------------------------
/// <inheritdoc />
public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id);
/// <inheritdoc />
public void EndTabBar() => ImGuiNET.ImGui.EndTabBar();
/// <inheritdoc />
public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label);
/// <inheritdoc />
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
// -- Selectable / copyable text ---------------------------------------
/// <inheritdoc />
public void TextMultilineReadOnly(string id, string content, Vector2 size)
{
// ImGui's InputTextMultiline takes a `ref string` even with the
// ReadOnly flag — we just hand it a local copy. maxLength caps
// what the user could type if ReadOnly were ever cleared; we
// size it to the current content (+1 for ImGui's internal NUL
// terminator in some bindings). Min of 1 keeps the empty case
// from confusing native bindings.
string buffer = content;
uint maxLen = (uint)System.Math.Max(content.Length + 1, 1);
ImGuiNET.ImGui.InputTextMultiline(id, ref buffer, maxLen, size,
ImGuiInputTextFlags.ReadOnly);
}
}