diff --git a/AcDream.slnx b/AcDream.slnx index 5a5029d..1cf8f24 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + diff --git a/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj new file mode 100644 index 0000000..65853fa --- /dev/null +++ b/src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + latest + true + + + + + + + + + + + + + diff --git a/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs new file mode 100644 index 0000000..70ba689 --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiBootstrapper.cs @@ -0,0 +1,62 @@ +using Hexa.NET.ImGui; +using Hexa.NET.ImGui.Backends.OpenGL3; + +namespace AcDream.UI.ImGui; + +/// +/// One-shot ImGui setup / teardown for the devtools overlay. Called from +/// GameWindow when ACDREAM_DEVTOOLS=1. Hides the cimgui +/// context + OpenGL3 renderer-impl lifecycles behind two static methods +/// so the calling code stays clean. +/// +/// +/// Intentionally not an IDisposable singleton — the host +/// window owns the one call to at application +/// exit. Re-initialisation mid-session is not supported. +/// +/// +public static class ImGuiBootstrapper +{ + private static bool _initialized; + + /// + /// 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. + /// + /// + /// GLSL version directive for the ImGui-internal shader. + /// "#version 330" matches acdream's existing shaders and is + /// the safest default for the OpenGL 4.3 core profile we ship. + /// + 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; + } + + /// Tear down the OpenGL3 renderer + destroy the ImGui context. + public static void Shutdown() + { + if (!_initialized) return; + + ImGuiImplOpenGL3.Shutdown(); + Hexa.NET.ImGui.ImGui.DestroyContext(); + + _initialized = false; + } + + /// True after has run successfully. + public static bool IsInitialized => _initialized; +} diff --git a/src/AcDream.UI.ImGui/ImGuiPanelHost.cs b/src/AcDream.UI.ImGui/ImGuiPanelHost.cs new file mode 100644 index 0000000..d9a3a4c --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiPanelHost.cs @@ -0,0 +1,46 @@ +using AcDream.UI.Abstractions; + +namespace AcDream.UI.ImGui; + +/// +/// implementation for the ImGui backend. Owns the +/// registered panel set; iterates + draws every frame when the caller is +/// inside an ImGui frame (between ImGui.NewFrame and +/// ImGui.Render). +/// +/// +/// This class does not call ImGui.NewFrame / ImGui.Render +/// itself. Those belong to the caller (GameWindow) so GL-state +/// ownership is explicit and the render-loop integration point is obvious. +/// +/// +public sealed class ImGuiPanelHost : IPanelHost +{ + private readonly Dictionary _panels = new(); + private readonly ImGuiPanelRenderer _renderer = new(); + + /// + public void Register(IPanel panel) + { + ArgumentNullException.ThrowIfNull(panel); + _panels[panel.Id] = panel; // idempotent by Id + } + + /// + public void Unregister(string panelId) => _panels.Remove(panelId); + + /// + 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); + } + } + + /// Current registered count (for diagnostics). + public int Count => _panels.Count; +} diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs new file mode 100644 index 0000000..17e463d --- /dev/null +++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs @@ -0,0 +1,41 @@ +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.UI.ImGui; + +/// +/// 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. +/// +public sealed class ImGuiPanelRenderer : IPanelRenderer +{ + /// + public bool Begin(string title) => Hexa.NET.ImGui.ImGui.Begin(title); + + /// + public void End() => Hexa.NET.ImGui.ImGui.End(); + + /// + public void Text(string text) => Hexa.NET.ImGui.ImGui.TextUnformatted(text); + + /// + public void SameLine() => Hexa.NET.ImGui.ImGui.SameLine(); + + /// + public void Separator() => Hexa.NET.ImGui.ImGui.Separator(); + + /// + 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); + } +} diff --git a/src/AcDream.UI.ImGui/SilkInputBridge.cs b/src/AcDream.UI.ImGui/SilkInputBridge.cs new file mode 100644 index 0000000..6c47f28 --- /dev/null +++ b/src/AcDream.UI.ImGui/SilkInputBridge.cs @@ -0,0 +1,188 @@ +using System.Numerics; +using Hexa.NET.ImGui; +using Silk.NET.Input; + +namespace AcDream.UI.ImGui; + +/// +/// 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. +/// +/// +/// Event-driven (we subscribe to Silk.NET events); does not poll. Each +/// handler writes directly to ImGui.GetIO() via the AddXxx +/// family of calls. Frame-start book-keeping (display size, delta time, +/// active modifier latch) happens in . +/// +/// +/// +/// Call at app shutdown to unsubscribe from Silk.NET +/// events. +/// +/// +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; + } + } + + /// + /// Per-frame bookkeeping. Call right before ImGui.NewFrame(). + /// Sets display size (in logical pixels) and delta-time on ImGui's IO. + /// + 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); + } + + /// + /// Silk.NET → ImGui key map. Covers text-input + navigation keys + + /// WASD + function keys. Unlisted keys fall through to no-op. + /// + private static readonly Dictionary 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; + } + } +}