feat(ui): AcDream.UI.ImGui backend — Hexa.NET.ImGui + Silk.NET input bridge

Second piece of Phase D.2a: the ImGui-specific backend that implements
AcDream.UI.Abstractions' IPanelRenderer / IPanelHost. No GameWindow
hookup yet — compiles standalone for clean review before integration.

Packages:
  * Hexa.NET.ImGui 2.2.9 (auto-generated from cimgui 1.92.2b)
  * Hexa.NET.ImGui.Backends 1.0.18 (consolidated — OpenGL3 is here)
  * Silk.NET.Input 2.23.0 + Silk.NET.OpenGL 2.23.0 (matches AcDream.App)

Files:

  ImGuiBootstrapper.cs
    One-shot static Initialize(glslVersion) / Shutdown() pair. Creates
    the ImGui context, applies dark style, enables NavEnableKeyboard,
    and boots ImGuiImplOpenGL3. Re-init is a no-op.

  SilkInputBridge.cs
    Event-driven Silk.NET -> ImGui IO bridge. Subscribes on construction;
    Dispose() unsubscribes. Covers:
      - KeyDown/Up -> ImGui.AddKeyEvent with modifier latching
        (Ctrl/Shift/Alt/Super routed via both ModXxx flags AND named
        key events so both IsKeyPressed checks and ImGui shortcut
        matching work)
      - KeyChar -> AddInputCharacter for text fields
      - MouseMove -> AddMousePosEvent
      - MouseDown/Up -> AddMouseButtonEvent (L=0, R=1, M=2)
      - Scroll -> AddMouseWheelEvent (both axes)
    Silk.NET.Input.Key -> ImGuiKey map covers WASD, arrows, modifiers,
    letters, digits, function keys. Unmapped keys silently ignored.
    BeginFrame(displaySize, dt) sets IO.DisplaySize + IO.DeltaTime.

  ImGuiPanelRenderer.cs
    IPanelRenderer impl — one-line wrappers on ImGui.Begin/End,
    TextUnformatted, SameLine, Separator, ProgressBar. The ONLY place
    Hexa.NET.ImGui types appear outside bootstrap/input plumbing. Panels
    still never import ImGui.

  ImGuiPanelHost.cs
    IPanelHost impl. Dictionary keyed by IPanel.Id for idempotent
    Register. RenderAll iterates visible panels and calls their Render.
    Does NOT call ImGui.NewFrame / ImGui.Render — ownership belongs to
    the caller (GameWindow) so GL state is explicit. Diagnostic `Count`
    property.

No behavior change yet; next commit wires this into GameWindow behind
ACDREAM_DEVTOOLS=1 and ships the first visible VitalsPanel.
This commit is contained in:
Erik 2026-04-25 00:29:09 +02:00
parent fc03fa377b
commit a7dbce3474
6 changed files with 361 additions and 0 deletions

View file

@ -7,6 +7,7 @@
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
<Project Path="src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/RetailTimeProbe/RetailTimeProbe.csproj" />

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- Hexa.NET.ImGui backend — chosen over ImGui.NET + Silk.NET.OpenGL.Extensions.ImGui
for AOT-ready native-lib bundling and closer tracking of upstream cimgui.
See docs/plans/2026-04-24-ui-framework.md §"Choice: Hexa.NET.ImGui". -->
<PackageReference Include="Hexa.NET.ImGui" Version="2.2.9" />
<PackageReference Include="Hexa.NET.ImGui.Backends" Version="1.0.18" />
<!-- SilkInputBridge references Silk.NET.Input types directly so we need
the package even though AcDream.App transitively has it. -->
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,62 @@
using Hexa.NET.ImGui;
using Hexa.NET.ImGui.Backends.OpenGL3;
namespace AcDream.UI.ImGui;
/// <summary>
/// One-shot ImGui setup / teardown for the devtools overlay. Called from
/// <c>GameWindow</c> when <c>ACDREAM_DEVTOOLS=1</c>. Hides the cimgui
/// context + OpenGL3 renderer-impl lifecycles behind two static methods
/// so the calling code stays clean.
///
/// <para>
/// Intentionally <b>not</b> an <c>IDisposable</c> singleton — the host
/// window owns the one call to <see cref="Shutdown"/> at application
/// exit. Re-initialisation mid-session is not supported.
/// </para>
/// </summary>
public static class ImGuiBootstrapper
{
private static bool _initialized;
/// <summary>
/// Create an ImGui context, apply the dark style + enable keyboard
/// navigation, and bootstrap the OpenGL3 renderer backend. The GL
/// context owned by Silk.NET must be current on the calling thread.
/// </summary>
/// <param name="glslVersion">
/// GLSL version directive for the ImGui-internal shader.
/// <c>"#version 330"</c> matches acdream's existing shaders and is
/// the safest default for the OpenGL 4.3 core profile we ship.
/// </param>
public static void Initialize(string glslVersion = "#version 330")
{
if (_initialized) return;
Hexa.NET.ImGui.ImGui.CreateContext();
Hexa.NET.ImGui.ImGui.StyleColorsDark();
var io = Hexa.NET.ImGui.ImGui.GetIO();
io.ConfigFlags |= ImGuiConfigFlags.NavEnableKeyboard;
// DO NOT enable NavEnableGamepad — we don't wire a gamepad backend.
// DO NOT enable DockingEnable / ViewportsEnable — out of scope for D.2a.
ImGuiImplOpenGL3.Init(glslVersion);
_initialized = true;
}
/// <summary>Tear down the OpenGL3 renderer + destroy the ImGui context.</summary>
public static void Shutdown()
{
if (!_initialized) return;
ImGuiImplOpenGL3.Shutdown();
Hexa.NET.ImGui.ImGui.DestroyContext();
_initialized = false;
}
/// <summary>True after <see cref="Initialize"/> has run successfully.</summary>
public static bool IsInitialized => _initialized;
}

View file

@ -0,0 +1,46 @@
using AcDream.UI.Abstractions;
namespace AcDream.UI.ImGui;
/// <summary>
/// <see cref="IPanelHost"/> implementation for the ImGui backend. Owns the
/// registered panel set; iterates + draws every frame when the caller is
/// inside an ImGui frame (between <c>ImGui.NewFrame</c> and
/// <c>ImGui.Render</c>).
///
/// <para>
/// <b>This class does not call <c>ImGui.NewFrame</c> / <c>ImGui.Render</c>
/// itself.</b> Those belong to the caller (GameWindow) so GL-state
/// ownership is explicit and the render-loop integration point is obvious.
/// </para>
/// </summary>
public sealed class ImGuiPanelHost : IPanelHost
{
private readonly Dictionary<string, IPanel> _panels = new();
private readonly ImGuiPanelRenderer _renderer = new();
/// <inheritdoc />
public void Register(IPanel panel)
{
ArgumentNullException.ThrowIfNull(panel);
_panels[panel.Id] = panel; // idempotent by Id
}
/// <inheritdoc />
public void Unregister(string panelId) => _panels.Remove(panelId);
/// <inheritdoc />
public void RenderAll(PanelContext ctx)
{
// Order-independent — ImGui windows stack in the order they're drawn
// for focus purposes but we have <=1 panel in D.2a.
foreach (var panel in _panels.Values)
{
if (!panel.IsVisible) continue;
panel.Render(ctx, _renderer);
}
}
/// <summary>Current registered count (for diagnostics).</summary>
public int Count => _panels.Count;
}

View file

@ -0,0 +1,41 @@
using System.Numerics;
using AcDream.UI.Abstractions;
namespace AcDream.UI.ImGui;
/// <summary>
/// <see cref="IPanelRenderer"/> implemented as thin wrappers around
/// Hexa.NET.ImGui calls. This is the ONLY place where Hexa.NET.ImGui
/// types appear outside of bootstrap / input-bridge plumbing — panels
/// that need a feature must extend the abstraction here, not by importing
/// ImGui in panel files.
/// </summary>
public sealed class ImGuiPanelRenderer : IPanelRenderer
{
/// <inheritdoc />
public bool Begin(string title) => Hexa.NET.ImGui.ImGui.Begin(title);
/// <inheritdoc />
public void End() => Hexa.NET.ImGui.ImGui.End();
/// <inheritdoc />
public void Text(string text) => Hexa.NET.ImGui.ImGui.TextUnformatted(text);
/// <inheritdoc />
public void SameLine() => Hexa.NET.ImGui.ImGui.SameLine();
/// <inheritdoc />
public void Separator() => Hexa.NET.ImGui.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
Hexa.NET.ImGui.ImGui.ProgressBar(fraction, size, overlay ?? string.Empty);
}
}

View file

@ -0,0 +1,188 @@
using System.Numerics;
using Hexa.NET.ImGui;
using Silk.NET.Input;
namespace AcDream.UI.ImGui;
/// <summary>
/// Forwards Silk.NET keyboard / mouse events to ImGui's IO. Replaces what
/// you'd get from the stock GLFW or SDL backends in a non-Silk.NET host.
///
/// <para>
/// Event-driven (we subscribe to Silk.NET events); does not poll. Each
/// handler writes directly to <c>ImGui.GetIO()</c> via the <c>AddXxx</c>
/// family of calls. Frame-start book-keeping (display size, delta time,
/// active modifier latch) happens in <see cref="BeginFrame"/>.
/// </para>
///
/// <para>
/// Call <see cref="Dispose"/> at app shutdown to unsubscribe from Silk.NET
/// events.
/// </para>
/// </summary>
public sealed class SilkInputBridge : IDisposable
{
private readonly IInputContext _input;
private readonly IKeyboard? _keyboard;
private readonly IMouse? _mouse;
public SilkInputBridge(IInputContext input)
{
_input = input ?? throw new ArgumentNullException(nameof(input));
_keyboard = input.Keyboards.Count > 0 ? input.Keyboards[0] : null;
_mouse = input.Mice.Count > 0 ? input.Mice[0] : null;
if (_keyboard is not null)
{
_keyboard.KeyDown += OnKeyDown;
_keyboard.KeyUp += OnKeyUp;
_keyboard.KeyChar += OnKeyChar;
}
if (_mouse is not null)
{
_mouse.MouseMove += OnMouseMove;
_mouse.MouseDown += OnMouseDown;
_mouse.MouseUp += OnMouseUp;
_mouse.Scroll += OnScroll;
}
}
/// <summary>
/// Per-frame bookkeeping. Call right before <c>ImGui.NewFrame()</c>.
/// Sets display size (in logical pixels) and delta-time on ImGui's IO.
/// </summary>
public void BeginFrame(Vector2 displaySize, float deltaSeconds)
{
var io = Hexa.NET.ImGui.ImGui.GetIO();
io.DisplaySize = displaySize;
io.DeltaTime = deltaSeconds > 0f ? deltaSeconds : 1f / 60f;
}
// ─── event handlers ──────────────────────────────────────────────
private void OnKeyDown(IKeyboard kb, Key key, int scancode) => AddKey(key, down: true);
private void OnKeyUp (IKeyboard kb, Key key, int scancode) => AddKey(key, down: false);
private void OnKeyChar(IKeyboard kb, char c)
{
// Feeds typed text into any focused ImGui TextField. Safe to call
// even when no TextField has focus — ImGui buffers the character
// and discards it if nothing claims it.
Hexa.NET.ImGui.ImGui.GetIO().AddInputCharacter(c);
}
private void OnMouseMove(IMouse m, Vector2 pos)
{
Hexa.NET.ImGui.ImGui.GetIO().AddMousePosEvent(pos.X, pos.Y);
}
private void OnMouseDown(IMouse m, MouseButton button) => AddMouseButton(button, down: true);
private void OnMouseUp (IMouse m, MouseButton button) => AddMouseButton(button, down: false);
private void OnScroll(IMouse m, ScrollWheel wheel)
{
Hexa.NET.ImGui.ImGui.GetIO().AddMouseWheelEvent(wheel.X, wheel.Y);
}
// ─── helpers ─────────────────────────────────────────────────────
private static void AddKey(Key key, bool down)
{
// Update modifier latches first (ImGui reads these when any AddKeyEvent fires).
var io = Hexa.NET.ImGui.ImGui.GetIO();
if (key is Key.ControlLeft or Key.ControlRight) io.AddKeyEvent(ImGuiKey.ModCtrl, down);
if (key is Key.ShiftLeft or Key.ShiftRight) io.AddKeyEvent(ImGuiKey.ModShift, down);
if (key is Key.AltLeft or Key.AltRight) io.AddKeyEvent(ImGuiKey.ModAlt, down);
if (key is Key.SuperLeft or Key.SuperRight) io.AddKeyEvent(ImGuiKey.ModSuper, down);
if (KeyMap.TryGetValue(key, out var imguiKey))
io.AddKeyEvent(imguiKey, down);
// Unmapped keys are silently ignored — fine for D.2a; panels that
// need exotic keys can extend the map.
}
private static void AddMouseButton(MouseButton button, bool down)
{
int idx = button switch
{
MouseButton.Left => 0,
MouseButton.Right => 1,
MouseButton.Middle => 2,
_ => -1,
};
if (idx < 0) return;
Hexa.NET.ImGui.ImGui.GetIO().AddMouseButtonEvent(idx, down);
}
/// <summary>
/// Silk.NET → ImGui key map. Covers text-input + navigation keys +
/// WASD + function keys. Unlisted keys fall through to no-op.
/// </summary>
private static readonly Dictionary<Key, ImGuiKey> KeyMap = new()
{
// Navigation + control
[Key.Tab] = ImGuiKey.Tab,
[Key.Left] = ImGuiKey.LeftArrow,
[Key.Right] = ImGuiKey.RightArrow,
[Key.Up] = ImGuiKey.UpArrow,
[Key.Down] = ImGuiKey.DownArrow,
[Key.PageUp] = ImGuiKey.PageUp,
[Key.PageDown] = ImGuiKey.PageDown,
[Key.Home] = ImGuiKey.Home,
[Key.End] = ImGuiKey.End,
[Key.Insert] = ImGuiKey.Insert,
[Key.Delete] = ImGuiKey.Delete,
[Key.Backspace] = ImGuiKey.Backspace,
[Key.Space] = ImGuiKey.Space,
[Key.Enter] = ImGuiKey.Enter,
[Key.Escape] = ImGuiKey.Escape,
// Modifiers (also add via the mod-flag path, but these let ImGui
// see them as named keys too).
[Key.ControlLeft] = ImGuiKey.LeftCtrl,
[Key.ControlRight] = ImGuiKey.RightCtrl,
[Key.ShiftLeft] = ImGuiKey.LeftShift,
[Key.ShiftRight] = ImGuiKey.RightShift,
[Key.AltLeft] = ImGuiKey.LeftAlt,
[Key.AltRight] = ImGuiKey.RightAlt,
// Letters (Silk.NET.Key.A..Z map 1:1 to ImGuiKey.A..Z).
[Key.A] = ImGuiKey.A, [Key.B] = ImGuiKey.B, [Key.C] = ImGuiKey.C, [Key.D] = ImGuiKey.D,
[Key.E] = ImGuiKey.E, [Key.F] = ImGuiKey.F, [Key.G] = ImGuiKey.G, [Key.H] = ImGuiKey.H,
[Key.I] = ImGuiKey.I, [Key.J] = ImGuiKey.J, [Key.K] = ImGuiKey.K, [Key.L] = ImGuiKey.L,
[Key.M] = ImGuiKey.M, [Key.N] = ImGuiKey.N, [Key.O] = ImGuiKey.O, [Key.P] = ImGuiKey.P,
[Key.Q] = ImGuiKey.Q, [Key.R] = ImGuiKey.R, [Key.S] = ImGuiKey.S, [Key.T] = ImGuiKey.T,
[Key.U] = ImGuiKey.U, [Key.V] = ImGuiKey.V, [Key.W] = ImGuiKey.W, [Key.X] = ImGuiKey.X,
[Key.Y] = ImGuiKey.Y, [Key.Z] = ImGuiKey.Z,
// Digit row
[Key.Number0] = ImGuiKey.Key0, [Key.Number1] = ImGuiKey.Key1, [Key.Number2] = ImGuiKey.Key2,
[Key.Number3] = ImGuiKey.Key3, [Key.Number4] = ImGuiKey.Key4, [Key.Number5] = ImGuiKey.Key5,
[Key.Number6] = ImGuiKey.Key6, [Key.Number7] = ImGuiKey.Key7, [Key.Number8] = ImGuiKey.Key8,
[Key.Number9] = ImGuiKey.Key9,
// Function keys
[Key.F1] = ImGuiKey.F1, [Key.F2] = ImGuiKey.F2, [Key.F3] = ImGuiKey.F3, [Key.F4] = ImGuiKey.F4,
[Key.F5] = ImGuiKey.F5, [Key.F6] = ImGuiKey.F6, [Key.F7] = ImGuiKey.F7, [Key.F8] = ImGuiKey.F8,
[Key.F9] = ImGuiKey.F9, [Key.F10] = ImGuiKey.F10, [Key.F11] = ImGuiKey.F11, [Key.F12] = ImGuiKey.F12,
};
public void Dispose()
{
if (_keyboard is not null)
{
_keyboard.KeyDown -= OnKeyDown;
_keyboard.KeyUp -= OnKeyUp;
_keyboard.KeyChar -= OnKeyChar;
}
if (_mouse is not null)
{
_mouse.MouseMove -= OnMouseMove;
_mouse.MouseDown -= OnMouseDown;
_mouse.MouseUp -= OnMouseUp;
_mouse.Scroll -= OnScroll;
}
}
}