From 84512d3c647392b25c25f81db26f88859b9001df Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 23:17:41 +0200 Subject: [PATCH] feat(input): #21 Phase K.1a - input architecture skeleton (parallel to existing handlers) Introduces the abstraction without changing user-visible behavior. Existing keyboard/mouse handlers in GameWindow continue working unchanged. The new InputDispatcher runs alongside, fires InputAction events, and a diagnostic Console.WriteLine subscriber proves the path is observable. K.1b cuts the existing handlers over; K.1c flips bindings to retail. New types in src/AcDream.UI.Abstractions/Input/: - InputAction enum (~110 actions, doc-grouped by retail keymap category: MovementCommands, ItemSelectionCommands, UICommands, QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse selection, plus Acdream-specific debug actions for the existing F-key behaviors) - KeyChord record struct (Silk.NET.Input.Key + ModifierMask + Device) - ModifierMask [Flags] enum matching retail keymap bit values (Shift=0x01, Ctrl=0x02, Alt=0x04, Win=0x08) - ActivationType enum (Press, Release, Hold, DoubleClick, Analog) - Binding record (chord -> action -> activation) - InputScope enum with stack semantics (Always at bottom, Game on top during normal play; Chat / EditField / Dialog / MeleeCombat / MissileCombat / MagicCombat / Camera push as transient overlays) - KeyBindings collection class with Find / ForAction / Add / Remove. AcdreamCurrentDefaults() factory matches today's hardcoded binds (W/S/A/D/Z/X movement, Shift run, F-key debug surface) so K.1a doesn't change behavior. RetailDefaults() is K.1c's job; for now it returns the same map. - IKeyboardSource / IMouseSource - test-fakeable interfaces wrapping Silk.NET. Both surface WantCaptureMouse / WantCaptureKeyboard flags so the dispatcher can gate per ImGui state. - InputDispatcher: multicast event Fired; scope stack with PushScope/PopScope/ActiveScope; per-frame Tick() fires Hold-type bindings for currently-held chords; mouse buttons encoded as KeyChord with Device=1. New adapters in src/AcDream.App/Input/: - SilkKeyboardSource - Silk.NET IKeyboard wrapper, tracks held state - SilkMouseSource - Silk.NET IMouse wrapper, proxies ImGui WantCapture flags for both keyboard and mouse GameWindow.cs: - Constructs adapters + dispatcher in OnLoad - Subscribes to dispatcher.Fired with diagnostic Console.WriteLine ("[input] {action} {activation}") so the path is observable in launch.log without touching any actual game state - Calls _inputDispatcher.Tick() per frame in OnUpdate - Existing IsKeyPressed and event handlers unchanged Memory crib at memory/project_input_pipeline.md describes the five layers (Silk events -> Source interfaces -> Dispatcher -> Action events -> Subscribers) with file paths + scope semantics + the K.1c retail-defaults plan. Indexed in MEMORY.md. Two deviations from plan, both documented: 1. InputDispatcher placed in UI.Abstractions/Input/ rather than App/Input/ - it has no Silk dependencies (uses only the test- fakeable interfaces) and the test fakes live in UI.Abstractions.Tests. Mirrors LiveCommandBus precedent. Silk adapters + GameWindow wiring stay in App. 2. WantCaptureKeyboard moved to IMouseSource alongside WantCaptureMouse (the dispatcher needs both at the same point). 34 new tests covering KeyChord equality, ModifierMask flags, KeyBindings lookup, dispatcher chord matching with modifier mismatch rejection, Hold-type Press/Release transitions, Tick() firing held bindings, scope stack push/pop with mismatched-pop throwing, WantCapture* gating. Solution total: 1118 green (243 Core.Net + 215 UI + 660 Core), 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Input/SilkKeyboardSource.cs | 46 ++++ src/AcDream.App/Input/SilkMouseSource.cs | 76 +++++ src/AcDream.App/Rendering/GameWindow.cs | 38 +++ .../AcDream.UI.Abstractions.csproj | 3 + .../Input/ActivationType.cs | 27 ++ src/AcDream.UI.Abstractions/Input/Binding.cs | 16 ++ .../Input/IKeyboardSource.cs | 38 +++ .../Input/IMouseSource.cs | 49 ++++ .../Input/InputAction.cs | 259 ++++++++++++++++++ .../Input/InputDispatcher.cs | 211 ++++++++++++++ .../Input/InputScope.cs | 44 +++ .../Input/KeyBindings.cs | 104 +++++++ src/AcDream.UI.Abstractions/Input/KeyChord.cs | 19 ++ .../Input/ModifierMask.cs | 25 ++ .../Input/FakeKeyboardSource.cs | 38 +++ .../Input/FakeMouseSource.cs | 42 +++ .../Input/InputDispatcherTests.cs | 189 +++++++++++++ .../Input/KeyBindingsTests.cs | 106 +++++++ .../Input/KeyChordTests.cs | 54 ++++ .../Input/ModifierMaskTests.cs | 42 +++ 20 files changed, 1426 insertions(+) create mode 100644 src/AcDream.App/Input/SilkKeyboardSource.cs create mode 100644 src/AcDream.App/Input/SilkMouseSource.cs create mode 100644 src/AcDream.UI.Abstractions/Input/ActivationType.cs create mode 100644 src/AcDream.UI.Abstractions/Input/Binding.cs create mode 100644 src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs create mode 100644 src/AcDream.UI.Abstractions/Input/IMouseSource.cs create mode 100644 src/AcDream.UI.Abstractions/Input/InputAction.cs create mode 100644 src/AcDream.UI.Abstractions/Input/InputDispatcher.cs create mode 100644 src/AcDream.UI.Abstractions/Input/InputScope.cs create mode 100644 src/AcDream.UI.Abstractions/Input/KeyBindings.cs create mode 100644 src/AcDream.UI.Abstractions/Input/KeyChord.cs create mode 100644 src/AcDream.UI.Abstractions/Input/ModifierMask.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs diff --git a/src/AcDream.App/Input/SilkKeyboardSource.cs b/src/AcDream.App/Input/SilkKeyboardSource.cs new file mode 100644 index 0000000..65c1d1d --- /dev/null +++ b/src/AcDream.App/Input/SilkKeyboardSource.cs @@ -0,0 +1,46 @@ +using System; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// Bridges a Silk.NET to the test-fakeable +/// interface used by +/// . K.1a wiring: GameWindow constructs +/// one of these wrapping the first keyboard from +/// IInputContext.Keyboards; the dispatcher fans events out to +/// subscribers without ever touching Silk types directly. +/// +public sealed class SilkKeyboardSource : IKeyboardSource +{ + private readonly IKeyboard _keyboard; + + public event Action? KeyDown; + public event Action? KeyUp; + + public SilkKeyboardSource(IKeyboard keyboard) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers()); + _keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers()); + } + + public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key); + + public ModifierMask CurrentModifiers => ReadModifiers(); + + private ModifierMask ReadModifiers() + { + ModifierMask m = ModifierMask.None; + if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight)) + m |= ModifierMask.Shift; + if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight)) + m |= ModifierMask.Ctrl; + if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight)) + m |= ModifierMask.Alt; + if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight)) + m |= ModifierMask.Win; + return m; + } +} diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs new file mode 100644 index 0000000..3669189 --- /dev/null +++ b/src/AcDream.App/Input/SilkMouseSource.cs @@ -0,0 +1,76 @@ +using System; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// Bridges a Silk.NET + the active ImGui IO +/// (for / ) +/// to the test-fakeable used by +/// . +/// +/// +/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the +/// constructor takes two delegates so the App.Rendering layer can +/// proxy ImGui.GetIO().WantCaptureMouse via whichever ImGui +/// package is currently active without leaking the type onto the +/// abstraction interface. +/// +/// +public sealed class SilkMouseSource : IMouseSource +{ + private readonly IMouse _mouse; + private readonly Func _wantCaptureMouse; + private readonly Func _wantCaptureKeyboard; + private float _lastX; + private float _lastY; + private bool _haveLastPos; + + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; + + /// Caller-supplied probe for the current modifier mask. Reused + /// from the keyboard source so mouse events carry consistent modifier + /// state. + public Func ModifierProbe { get; set; } = () => ModifierMask.None; + + public SilkMouseSource( + IMouse mouse, + Func wantCaptureMouse, + Func wantCaptureKeyboard) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse)); + _wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard)); + + _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe()); + _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe()); + _mouse.MouseMove += (_, pos) => + { + float dx, dy; + if (_haveLastPos) + { + dx = pos.X - _lastX; + dy = pos.Y - _lastY; + } + else + { + dx = 0f; + dy = 0f; + _haveLastPos = true; + } + _lastX = pos.X; + _lastY = pos.Y; + MouseMove?.Invoke(dx, dy); + }; + _mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y); + } + + public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button); + + public bool WantCaptureMouse => _wantCaptureMouse(); + public bool WantCaptureKeyboard => _wantCaptureKeyboard(); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 77fe759..7aae2cd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -396,6 +396,16 @@ public sealed class GameWindow : IDisposable // the orbited position (no snap back). private bool _rmbHeld; + // Phase K.1a — input architecture skeleton. Lives ALONGSIDE the + // existing IsKeyPressed + KeyDown handlers; nothing subscribes to + // dispatcher.Fired except a diagnostic logger. K.1b cuts the legacy + // paths over. + private AcDream.App.Input.SilkKeyboardSource? _kbSource; + private AcDream.App.Input.SilkMouseSource? _mouseSource; + private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; + private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = + AcDream.UI.Abstractions.Input.KeyBindings.RetailDefaults(); + // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. @@ -801,6 +811,29 @@ public sealed class GameWindow : IDisposable }; } + // Phase K.1a — input dispatcher skeleton wired ALONGSIDE the + // existing IsKeyPressed + KeyDown handlers above. The dispatcher + // observes the same Silk.NET keyboard/mouse and fires high-level + // InputAction events; in K.1a nothing actually drives behavior + // off the dispatcher except a diagnostic Console.WriteLine, so + // this is a no-op for the player. K.1b cuts the legacy paths + // over to subscribe to dispatcher.Fired. + var firstKb = _input.Keyboards.FirstOrDefault(); + var firstMouse = _input.Mice.FirstOrDefault(); + if (firstKb is not null && firstMouse is not null) + { + _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); + _mouseSource = new AcDream.App.Input.SilkMouseSource( + firstMouse, + wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, + wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); + _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; + _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( + _kbSource, _mouseSource, _keyBindings); + _inputDispatcher.Fired += (action, activation) => + Console.WriteLine($"[input] {action} {activation}"); + } + _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); @@ -3783,6 +3816,11 @@ public sealed class GameWindow : IDisposable // are in the kernel buffer. Fires EntitySpawned events synchronously. _liveSession?.Tick(); + // Phase K.1a — tick the input dispatcher so Hold-type bindings + // re-fire while their chord is held. K.1b adds the subscribers + // that actually consume the events. + _inputDispatcher?.Tick(); + if (_cameraController is null || _input is null) return; var kb = _input.Keyboards[0]; diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj index 0fc070c..2f4ca83 100644 --- a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj +++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj @@ -6,6 +6,9 @@ latest true + + + diff --git a/src/AcDream.UI.Abstractions/Input/ActivationType.cs b/src/AcDream.UI.Abstractions/Input/ActivationType.cs new file mode 100644 index 0000000..14f40da --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/ActivationType.cs @@ -0,0 +1,27 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// When (in the lifecycle of a chord) the bound +/// fires. Modeled on the retail keymap's "activation type" sixth-field — +/// the retail format uses it primarily for mouse double-click detection, +/// but we also surface for the +/// CameraInstantMouseLook MMB-hold behavior and +/// for future axis bindings. +/// +public enum ActivationType +{ + /// Fire on key-down. Default for most actions. + Press, + /// Fire on key-up. Used for paired walk-mode toggles. + Release, + /// Fire continuously while the chord is held — the + /// dispatcher emits a on key-down and a + /// on key-up, then ticks + /// in between via Tick(). + Hold, + /// Mouse-button double-click within retail's chord window. + DoubleClick, + /// Mouse axis or other analog input. Reserved for future + /// rebindable mouse-look — K.1a does not emit this. + Analog, +} diff --git a/src/AcDream.UI.Abstractions/Input/Binding.cs b/src/AcDream.UI.Abstractions/Input/Binding.cs new file mode 100644 index 0000000..7f49740 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/Binding.cs @@ -0,0 +1,16 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// Single chord → action binding. Multiple bindings may resolve to the +/// same action (e.g. retail's W and Up-arrow both → MovementForward); +/// enumerates them. +/// +/// The keyboard or mouse chord that triggers the action. +/// The high-level action emitted on the dispatcher's +/// Fired event when the chord matches in the active scope. +/// When the action fires — defaults to +/// (key-down). +public readonly record struct Binding( + KeyChord Chord, + InputAction Action, + ActivationType Activation = ActivationType.Press); diff --git a/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs b/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs new file mode 100644 index 0000000..e179039 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs @@ -0,0 +1,38 @@ +using System; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Test-fakeable wrapper over a keyboard event source. The production +/// adapter lives in src/AcDream.App/Input/SilkKeyboardSource.cs +/// and bridges Silk.NET's to this interface; +/// tests use a hand-rolled FakeKeyboardSource that lets the +/// test drive / events on +/// demand. +/// +/// +/// The interface deliberately omits any Silk.NET-specific types other +/// than (which is a value-type enum). That lets the +/// dispatcher be unit-testable without spinning a window context. +/// ImGui's WantCaptureKeyboard / WantCaptureMouse flags are exposed +/// on (single source of UI-capture truth) +/// rather than split across both surfaces. +/// +/// +public interface IKeyboardSource +{ + /// Fires on every transition from up → down for any key. + event Action? KeyDown; + + /// Fires on every transition from down → up for any key. + event Action? KeyUp; + + /// True iff the given key is currently pressed. + bool IsHeld(Key key); + + /// Bitmask of modifier keys currently held. Used by the + /// dispatcher's per-frame Tick() to assemble fresh chords for held + /// keys without re-listening to every event. + ModifierMask CurrentModifiers { get; } +} diff --git a/src/AcDream.UI.Abstractions/Input/IMouseSource.cs b/src/AcDream.UI.Abstractions/Input/IMouseSource.cs new file mode 100644 index 0000000..f4f9251 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/IMouseSource.cs @@ -0,0 +1,49 @@ +using System; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Test-fakeable wrapper over a mouse event source. The production +/// adapter lives in src/AcDream.App/Input/SilkMouseSource.cs +/// and bridges Silk.NET's to this interface; +/// tests use a FakeMouseSource in +/// tests/AcDream.UI.Abstractions.Tests/Input/. +/// +/// +/// and +/// are proxied from the active UI backend (ImGui in K.1a) so the +/// dispatcher can suppress firing while a panel is hovered or a text +/// field is focused. They live on IMouseSource rather than being +/// split across both surfaces because the dispatcher uses a single +/// reference to gate both branches. +/// +/// +public interface IMouseSource +{ + /// Fires on every transition from up → down for any button. + event Action? MouseDown; + + /// Fires on every transition from down → up for any button. + event Action? MouseUp; + + /// Fires on every cursor delta. Args are (dx, dy) in pixels + /// since the previous fire. + event Action? MouseMove; + + /// Fires on every wheel-tick. Positive = up, negative = down. + event Action? Scroll; + + /// True iff the given mouse button is currently pressed. + bool IsHeld(MouseButton button); + + /// True when the UI (ImGui) wants the mouse — the dispatcher + /// suppresses MouseDown/MouseUp/MouseMove/Scroll firing in that case + /// so e.g. RMB orbit doesn't trigger while hovering a panel. + bool WantCaptureMouse { get; } + + /// True when the UI (ImGui) wants the keyboard — the dispatcher + /// suppresses KeyDown firing in that case so typing into a chat field + /// doesn't fire game actions. + bool WantCaptureKeyboard { get; } +} diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs new file mode 100644 index 0000000..6afd512 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -0,0 +1,259 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// High-level input actions. Each name matches an entry in the retail +/// acclient.keymap "Bindings" header (see +/// docs/research/named-retail/retail-default.keymap.txt). The +/// Acdream* entries at the bottom are extensions for current +/// debug bindings that have no retail equivalent. +/// +/// +/// K.1a defines the enum but only the existing acdream-current chords +/// resolve to actions; K.1c flips the bindings table to the full retail +/// preset. Several actions in this enum (UseQuickSlot_*, +/// Combat*, UseSpellSlot_*) have NO subscribers in K.1 — +/// the chord fires the event but nothing acts on it. Phase L panels +/// will subscribe. +/// +/// +public enum InputAction +{ + /// Sentinel — no action. Reserved for "unbound". + None = 0, + + // ── MovementCommands ────────────────────────────────── + /// Move forward (run by default; walk if active). + MovementForward, + /// Move backward. + MovementBackup, + /// Turn the character to the left. + MovementTurnLeft, + /// Turn the character to the right. + MovementTurnRight, + /// Strafe (sidestep) to the left. + MovementStrafeLeft, + /// Strafe (sidestep) to the right. + MovementStrafeRight, + /// Hold-modifier — toggles forward motion to walk while held (retail Shift). + MovementWalkMode, + /// Toggle autorun on/off (retail Q). + MovementRunLock, + /// Charged jump — hold to power, release to launch. + MovementJump, + /// Cancel current motion / return to ready stance. + MovementStop, + /// Ready posture / sheathe-unsheathe (retail Y). + Ready, + /// Sit posture (retail G). + Sitting, + /// Crouch posture (retail H). + Crouch, + /// Sleep posture (retail B). + Sleeping, + + // ── ItemSelectionCommands ───────────────────────────── + /// Pick up the selected item. + SelectionPickUp, + /// Split a stack of the selected item. + SelectionSplitStack, + /// Cycle back to the previous selection. + SelectionPreviousSelection, + /// Closest compass-tracked item. + SelectionClosestCompassItem, + /// Previous compass-tracked item. + SelectionPreviousCompassItem, + /// Next compass-tracked item. + SelectionNextCompassItem, + /// Closest item in radius. + SelectionClosestItem, + /// Previous item in radius. + SelectionPreviousItem, + /// Next item in radius. + SelectionNextItem, + /// Closest monster. + SelectionClosestMonster, + /// Previous monster. + SelectionPreviousMonster, + /// Next monster. + SelectionNextMonster, + /// Most-recent attacker (for retaliation). + SelectionLastAttacker, + /// Closest player. + SelectionClosestPlayer, + /// Previous player. + SelectionPreviousPlayer, + /// Next player. + SelectionNextPlayer, + /// Previous fellow. + SelectionPreviousFellow, + /// Next fellow. + SelectionNextFellow, + /// Examine (Appraise) the current selection. + SelectionExamine, + + // ── UICommands ──────────────────────────────────────── + /// Use the selected item / interact (retail R). + UseSelected, + /// Cancel the topmost UI / clear selection / open log-out menu. + EscapeKey, + /// Log out of the game (retail Shift+Esc). + LOGOUT, + /// Toggle the help / control reference panel (retail F1). + ToggleHelp, + /// Toggle the plugin manager panel (retail F1+Shift+Ctrl). + TogglePluginManager, + /// Toggle the allegiance panel (retail F3). + ToggleAllegiancePanel, + /// Toggle the fellowship panel (retail F4). + ToggleFellowshipPanel, + /// Toggle the spellbook panel (retail F5). + ToggleSpellbookPanel, + /// Toggle the spell-components panel (retail F6). + ToggleSpellComponentsPanel, + /// Toggle the attributes panel (retail F8). + ToggleAttributesPanel, + /// Toggle the skills panel (retail F9). + ToggleSkillsPanel, + /// Toggle the world / map panel (retail F10). + ToggleWorldPanel, + /// Toggle the options / settings panel (retail F11) — opens + /// our SettingsPanel in K.3. + ToggleOptionsPanel, + /// Toggle the inventory panel (retail F12). + ToggleInventoryPanel, + /// Toggle floating chat window 1 (retail Alt+1). + ToggleFloatingChatWindow1, + /// Toggle floating chat window 2. + ToggleFloatingChatWindow2, + /// Toggle floating chat window 3. + ToggleFloatingChatWindow3, + /// Toggle floating chat window 4. + ToggleFloatingChatWindow4, + /// Capture a screenshot (retail PrintScreen). + CaptureScreenshot, + + // ── QuickslotCommands ───────────────────────────────── + UseQuickSlot_1, + UseQuickSlot_2, + UseQuickSlot_3, + UseQuickSlot_4, + UseQuickSlot_5, + UseQuickSlot_6, + UseQuickSlot_7, + UseQuickSlot_8, + UseQuickSlot_9, + UseQuickSlot_14, + UseQuickSlot_15, + UseQuickSlot_16, + UseQuickSlot_17, + UseQuickSlot_18, + /// Drop-target shortcut creation (retail 0 / drag-drop). + CreateShortcut, + + // ── Chat ────────────────────────────────────────────── + /// Focus the chat input field (retail Tab). + ToggleChatEntry, + /// Send the current chat-input contents (retail Return). + EnterChatMode, + + // ── Combat ──────────────────────────────────────────── + /// Toggle combat-stance on/off (retail Grave / `). + CombatToggleCombat, + // Mode-dependent (dormant in K — Phase L lights them up) + CombatDecreaseAttackPower, + CombatIncreaseAttackPower, + CombatLowAttack, + CombatMediumAttack, + CombatHighAttack, + CombatDecreaseMissileAccuracy, + CombatIncreaseMissileAccuracy, + CombatAimLow, + CombatAimMedium, + CombatAimHigh, + CombatPrevSpellTab, + CombatNextSpellTab, + CombatPrevSpell, + CombatCastCurrentSpell, + CombatNextSpell, + CombatFirstSpellTab, + CombatLastSpellTab, + CombatFirstSpell, + CombatLastSpell, + UseSpellSlot_1, + UseSpellSlot_2, + UseSpellSlot_3, + UseSpellSlot_4, + UseSpellSlot_5, + UseSpellSlot_6, + UseSpellSlot_7, + UseSpellSlot_8, + UseSpellSlot_9, + + // ── Emotes ──────────────────────────────────────────── + /// Cry emote (retail U). + Cry, + /// Laugh emote (retail I). + Laugh, + /// Cheer emote — celebratory jump (retail O). + Cheer, + /// Wave emote (retail J). + Wave, + /// Point emote (retail K). + PointState, + + // ── Camera ──────────────────────────────────────────── + /// Toggle alternate camera mode (retail F2 / Numpad-/). + CameraActivateAlternateMode, + /// Hold-MMB instant mouse-look — hardcoded behavior in K.2. + CameraInstantMouseLook, + CameraRotateLeft, + CameraRotateRight, + CameraRotateUp, + CameraRotateDown, + CameraMoveToward, + CameraMoveAway, + CameraViewDefault, + CameraViewFirstPerson, + CameraViewLookDown, + CameraViewMapMode, + + // ── Scroll ──────────────────────────────────────────── + /// Scroll up — wheel up or Ctrl+Up. + ScrollUp, + /// Scroll down — wheel down or Ctrl+Down. + ScrollDown, + + // ── Mouse selection ─────────────────────────────────── + /// Single left-click select. + SelectLeft, + /// Single right-click select. + SelectRight, + /// Single middle-click select. + SelectMid, + /// Double left-click select. + SelectDblLeft, + /// Double right-click select. + SelectDblRight, + /// Double middle-click select. + SelectDblMid, + + // ── Acdream debug actions (existing F-key behavior) ── + /// F1 toggles entire DebugPanel visibility (acdream-only). + AcdreamToggleDebugPanel, + /// F2 toggles collision wireframes. + AcdreamToggleCollisionWires, + /// F3 dumps player + nearby entities to console. + AcdreamDumpNearby, + /// F7 cycles time of day. + AcdreamCycleTimeOfDay, + /// F8 decreases mouse sensitivity. + AcdreamSensitivityDown, + /// F9 increases mouse sensitivity. + AcdreamSensitivityUp, + /// F10 cycles weather. + AcdreamCycleWeather, + /// F (existing) toggles between fly camera and orbit/chase mode. + AcdreamToggleFlyMode, + /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c). + AcdreamTogglePlayerMode, +} diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs new file mode 100644 index 0000000..d955da4 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Multicast event dispatcher: subscribes to an +/// + , looks up +/// the active in , and +/// fires for every match. Owns the runtime scope +/// stack — top of stack wins; when no binding matches the topmost +/// scope, lookup falls through to the scope below. +/// +/// +/// Why not LiveCommandBus? +/// is single-handler-per-type by design (one canonical recipient per +/// command). Input is fundamentally multicast: a single key press may +/// drive movement, animation, audio, and a panel toggle simultaneously. +/// The two are complementary — input fans out to many subscribers, who +/// then issue commands through the bus to one canonical recipient. +/// +/// +/// +/// K.1a wiring: GameWindow constructs a dispatcher alongside the +/// existing IsKeyPressed + event-handler paths. Nothing +/// subscribes to yet except a diagnostic console +/// logger — the dispatcher is observable but doesn't drive any +/// behavior. K.1b cuts the existing handlers over to the dispatcher's +/// action stream. +/// +/// +public sealed class InputDispatcher +{ + private readonly IKeyboardSource _keyboard; + private readonly IMouseSource _mouse; + private readonly KeyBindings _bindings; + private readonly Stack _scopes = new(); + private readonly HashSet _heldHoldChords = new(); + + /// Fires every time a binding matches a press / release / hold tick. + /// Multicast — every subscriber gets every event in subscription order. + public event Action? Fired; + + public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + + _scopes.Push(InputScope.Always); // bottom of the stack + _scopes.Push(InputScope.Game); // default top for normal play + + _keyboard.KeyDown += OnKeyDown; + _keyboard.KeyUp += OnKeyUp; + _mouse.MouseDown += OnMouseDown; + _mouse.MouseUp += OnMouseUp; + _mouse.Scroll += OnScroll; + } + + /// Topmost scope on the stack — what the dispatcher looks up first. + public InputScope ActiveScope => _scopes.Peek(); + + /// Push a scope onto the active stack. Top wins. + public void PushScope(InputScope scope) => _scopes.Push(scope); + + /// Pop the topmost scope. is the + /// scope the caller believes is on top; mismatch throws to catch + /// unbalanced push/pop early. + public void PopScope(InputScope expected) + { + if (_scopes.Peek() != expected) + throw new InvalidOperationException( + $"PopScope expected {expected} but top is {_scopes.Peek()}"); + _scopes.Pop(); + } + + /// + /// Per-frame tick. Re-fires + /// activations for every chord that's currently held. Call once per + /// game-update tick (NOT once per render frame — Hold semantics are + /// "fire while held," not "fire every render"). + /// + public void Tick() + { + if (_mouse.WantCaptureKeyboard) return; + + // Snapshot to avoid issues if a subscriber mutates _heldHoldChords. + if (_heldHoldChords.Count == 0) return; + var snapshot = new KeyChord[_heldHoldChords.Count]; + _heldHoldChords.CopyTo(snapshot); + for (int i = 0; i < snapshot.Length; i++) + { + var chord = snapshot[i]; + var hold = _bindings.Find(chord, ActivationType.Hold); + if (hold is not null) + Fired?.Invoke(hold.Value.Action, ActivationType.Hold); + } + } + + private void OnKeyDown(Key key, ModifierMask mods) + { + if (_mouse.WantCaptureKeyboard) return; + var chord = new KeyChord(key, mods, Device: 0); + + var press = _bindings.Find(chord, ActivationType.Press); + if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + + var hold = _bindings.Find(chord, ActivationType.Hold); + if (hold is not null) + { + // Emit a Press transition so subscribers can latch state, then + // record the chord so Tick() will re-fire Hold every frame. + Fired?.Invoke(hold.Value.Action, ActivationType.Press); + _heldHoldChords.Add(chord); + } + } + + private void OnKeyUp(Key key, ModifierMask mods) + { + // Release fires regardless of WantCaptureKeyboard so we don't + // strand a Hold subscriber in the "held" state if the UI captured + // mid-press. + var chord = new KeyChord(key, mods, Device: 0); + + var release = _bindings.Find(chord, ActivationType.Release); + if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + + // Any matching Hold binding gets a Release transition. Walk the + // tracked set looking for a chord with a matching Key (ignoring + // modifiers — modifiers may have been released first). + var toRemove = new List(); + foreach (var held in _heldHoldChords) + { + if (held.Key == key && held.Device == 0) + toRemove.Add(held); + } + foreach (var held in toRemove) + { + _heldHoldChords.Remove(held); + var hold = _bindings.Find(held, ActivationType.Hold); + if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + } + } + + private void OnMouseDown(MouseButton button, ModifierMask mods) + { + if (_mouse.WantCaptureMouse) return; + var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); + + var press = _bindings.Find(chord, ActivationType.Press); + if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + + var hold = _bindings.Find(chord, ActivationType.Hold); + if (hold is not null) + { + Fired?.Invoke(hold.Value.Action, ActivationType.Press); + _heldHoldChords.Add(chord); + } + } + + private void OnMouseUp(MouseButton button, ModifierMask mods) + { + var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); + + var release = _bindings.Find(chord, ActivationType.Release); + if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + + var keyForLookup = MouseButtonToKey(button); + var toRemove = new List(); + foreach (var held in _heldHoldChords) + { + if (held.Key == keyForLookup && held.Device == 1) + toRemove.Add(held); + } + foreach (var held in toRemove) + { + _heldHoldChords.Remove(held); + var hold = _bindings.Find(held, ActivationType.Hold); + if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + } + } + + private void OnScroll(float delta) + { + if (_mouse.WantCaptureMouse) return; + // Wheel ticks emit ScrollUp / ScrollDown actions if either chord + // is bound. We don't go through KeyBindings.Find here — wheel is + // a fixed mapping for now (rebindable in K.1c). + // Empty in K.1a — no subscribers; the action is observable via + // direct subscription if a future caller wants it. + _ = delta; + } + + /// + /// Encode a Silk.NET as a + /// value for chord lookup. Reuses the high values of the Key enum + /// that Silk.NET doesn't assign meaningful keyboard codes to. This + /// is an internal-only convention; the dispatcher always sets + /// Device=1 on the resulting . + /// + public static Key MouseButtonToKey(MouseButton button) => button switch + { + MouseButton.Left => (Key)(-1001), + MouseButton.Right => (Key)(-1002), + MouseButton.Middle => (Key)(-1003), + MouseButton.Button4 => (Key)(-1004), + MouseButton.Button5 => (Key)(-1005), + _ => (Key)(-1000 - (int)button), + }; +} diff --git a/src/AcDream.UI.Abstractions/Input/InputScope.cs b/src/AcDream.UI.Abstractions/Input/InputScope.cs new file mode 100644 index 0000000..857f381 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/InputScope.cs @@ -0,0 +1,44 @@ +namespace AcDream.UI.Abstractions.Input; + +/// +/// Categorical buckets the InputDispatcher uses to gate which +/// bindings are active. Managed as a stack at runtime — the topmost +/// scope wins; if a chord doesn't match in the topmost scope the +/// dispatcher falls through to the next one down. +/// sits at the bottom of the stack and catches global chords like +/// Esc / F1 that should fire regardless of focus. +/// +/// +/// K.1a defines the enum but only pushes + +/// by default. Combat scopes light up in Phase L +/// when CombatState.CurrentMode tracking lands. +/// +/// +public enum InputScope +{ + /// Bottom of the stack — Esc, F1, F11 etc fire here so they + /// work no matter what else is focused. + Always, + /// Game world — movement, camera, targeting, hotbar. + Game, + /// Chat input has focus — scope is suspended + /// (W goes to the text field, not the player). + Chat, + /// A generic non-chat text-edit field (rebind capture, name + /// entry, etc.) has focus. + EditField, + /// A modal dialog is open and capturing input. + Dialog, + /// Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn + /// remap to power + attack-level. Dormant until Phase L. + MeleeCombat, + /// Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn + /// remap to accuracy + aim-level. Dormant until Phase L. + MissileCombat, + /// Magic mode — 1-9 cast UseSpellSlot; Insert/PgUp etc. + /// page through spell tabs. Dormant until Phase L. + MagicCombat, + /// Camera alternate mode (F2 / Numpad-/) — arrow keys rotate + /// the camera instead of the character. + Camera, +} diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs new file mode 100644 index 0000000..527b9f5 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Mutable collection of s. Owns lookup by chord +/// (for the dispatcher) and lookup by action (for the Settings UI). +/// Insertion-order preserved — first-match-wins on lookup, so a user +/// can add a custom binding ahead of a default and have it take effect +/// without removing the default. +/// +/// +/// JSON IO + the real preset land in K.1c. +/// For K.1a, proxies +/// so the bindings table matches +/// today's hard-coded chords — no behavior change during the K.1a/K.1b +/// cutover. +/// +/// +public sealed class KeyBindings +{ + private readonly List _bindings = new(); + + /// All bindings in insertion order. + public IReadOnlyList All => _bindings; + + /// Append a binding. Duplicates are allowed; first match wins. + public void Add(Binding b) => _bindings.Add(b); + + /// Remove the first occurrence of the given binding (structural equality). + public bool Remove(Binding b) => _bindings.Remove(b); + + /// Drop every binding. + public void Clear() => _bindings.Clear(); + + /// + /// First binding (in insertion order) whose chord matches AND whose + /// activation type matches the requested phase. Null if none. + /// + public Binding? Find(KeyChord chord, ActivationType activation) + { + for (int i = 0; i < _bindings.Count; i++) + { + var b = _bindings[i]; + if (b.Chord == chord && b.Activation == activation) return b; + } + return null; + } + + /// + /// All bindings for a given action (one action can have multiple keys + /// — retail's W and Up-arrow both → MovementForward). + /// + public IEnumerable ForAction(InputAction action) + { + for (int i = 0; i < _bindings.Count; i++) + if (_bindings[i].Action == action) yield return _bindings[i]; + } + + /// + /// K.1a stub: returns CURRENT acdream binds (W=fwd, S=back, A/D=turn, + /// Z/X=strafe, Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel, + /// F2=collision wires, F3=dump, F7=cycle time, F8/F9=sensitivity, + /// F10=cycle weather, F=fly toggle, Space=jump). NOT the retail + /// preset — that lands in K.1c. This stub keeps behavior unchanged + /// during K.1a/K.1b cutover. + /// + public static KeyBindings AcdreamCurrentDefaults() + { + var b = new KeyBindings(); + + // Movement (current acdream — wrong for retail but unchanged for K.1a) + b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); + b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); + b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); + b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight)); + b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementRunLock, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); + + // Acdream debug binds + b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel)); + b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.AcdreamToggleCollisionWires)); + b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.AcdreamDumpNearby)); + b.Add(new(new KeyChord(Key.F7, ModifierMask.None), InputAction.AcdreamCycleTimeOfDay)); + b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.AcdreamSensitivityDown)); + b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp)); + b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather)); + b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode)); + b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); + + return b; + } + + /// + /// K.1c will replace this with the retail-faithful preset built from + /// docs/research/named-retail/retail-default.keymap.txt. + /// For K.1a, returns the acdream-current map so behavior is unchanged. + /// + // TODO K.1c: replace with full retail preset + JSON LoadOrDefault. + public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults(); +} diff --git a/src/AcDream.UI.Abstractions/Input/KeyChord.cs b/src/AcDream.UI.Abstractions/Input/KeyChord.cs new file mode 100644 index 0000000..e2aed26 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/KeyChord.cs @@ -0,0 +1,19 @@ +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// A single key (keyboard or mouse) plus its modifier bitmask. Equality +/// is structural: two chords match iff key, modifier mask, and device +/// index all match — so Ctrl+A does not match Shift+Ctrl+A +/// or bare A. +/// +/// The primary trigger key. Encoded as a Silk.NET +/// for keyboard chords; for mouse chords the key is reinterpreted via +/// conventions and +/// is set to 1. +/// Bitmask of held modifier keys. +/// for bare key chords. +/// Retail keymap convention: 0 = keyboard, 1 = mouse, +/// 2+ = future devices. Defaults to 0 (keyboard). +public readonly record struct KeyChord(Key Key, ModifierMask Modifiers, byte Device = 0); diff --git a/src/AcDream.UI.Abstractions/Input/ModifierMask.cs b/src/AcDream.UI.Abstractions/Input/ModifierMask.cs new file mode 100644 index 0000000..55831d0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Input/ModifierMask.cs @@ -0,0 +1,25 @@ +using System; + +namespace AcDream.UI.Abstractions.Input; + +/// +/// Bitmask of modifier keys held alongside a primary chord key. Bit values +/// are taken from the retail acclient.keymap "Metakeys" table: +/// LSHIFT=1, LCTRL=2, LALT=4, LWIN=8. Same numeric layout the +/// retail keymap text format writes — keeping it compatible lets us load +/// future user keymap exports byte-for-byte. +/// +[Flags] +public enum ModifierMask : uint +{ + /// No modifier held — bare key. + None = 0, + /// Shift (left or right) — retail keymap bit 1. + Shift = 0x01, + /// Control (left or right) — retail keymap bit 2. + Ctrl = 0x02, + /// Alt / Menu (left or right) — retail keymap bit 3. + Alt = 0x04, + /// Windows / GUI key — retail keymap bit 4. + Win = 0x08, +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs b/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs new file mode 100644 index 0000000..4b59296 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// Hand-rolled in-memory for unit tests. +/// Tests drive the surface by calling / +/// ; the dispatcher under test reacts via the +/// usual / events. +/// +internal sealed class FakeKeyboardSource : IKeyboardSource +{ + public event Action? KeyDown; + public event Action? KeyUp; + + private readonly HashSet _held = new(); + + public ModifierMask CurrentModifiers { get; set; } = ModifierMask.None; + + public bool IsHeld(Key key) => _held.Contains(key); + + public void EmitKeyDown(Key key, ModifierMask mods) + { + CurrentModifiers = mods; + _held.Add(key); + KeyDown?.Invoke(key, mods); + } + + public void EmitKeyUp(Key key, ModifierMask mods) + { + CurrentModifiers = mods; + _held.Remove(key); + KeyUp?.Invoke(key, mods); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs b/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs new file mode 100644 index 0000000..f88a9b2 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +/// +/// Hand-rolled in-memory for unit tests. +/// Tests drive button + cursor + wheel events; tests can also flip +/// / +/// to simulate ImGui-focus behavior. +/// +internal sealed class FakeMouseSource : IMouseSource +{ + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; + + private readonly HashSet _held = new(); + + public bool IsHeld(MouseButton button) => _held.Contains(button); + + public bool WantCaptureMouse { get; set; } + public bool WantCaptureKeyboard { get; set; } + + public void EmitMouseDown(MouseButton button, ModifierMask mods) + { + _held.Add(button); + MouseDown?.Invoke(button, mods); + } + + public void EmitMouseUp(MouseButton button, ModifierMask mods) + { + _held.Remove(button); + MouseUp?.Invoke(button, mods); + } + + public void EmitMouseMove(float dx, float dy) => MouseMove?.Invoke(dx, dy); + public void EmitScroll(float delta) => Scroll?.Invoke(delta); +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs new file mode 100644 index 0000000..bedb5a5 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class InputDispatcherTests +{ + private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings, List<(InputAction, ActivationType)> fired) + Build() + { + var kb = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var bindings = new KeyBindings(); + var dispatcher = new InputDispatcher(kb, mouse, bindings); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (a, t) => fired.Add((a, t)); + return (dispatcher, kb, mouse, bindings, fired); + } + + [Fact] + public void KeyDown_with_matching_chord_fires_press_action() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + + Assert.Single(fired); + Assert.Equal((InputAction.MovementForward, ActivationType.Press), fired[0]); + } + + [Fact] + public void KeyDown_with_mismatched_modifier_does_not_fire() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding(new KeyChord(Key.A, ModifierMask.Ctrl), InputAction.SelectionExamine)); + + // Press A without Ctrl — no fire. + kb.EmitKeyDown(Key.A, ModifierMask.None); + Assert.Empty(fired); + + // Press A with Shift+Ctrl — also no fire (extra modifier). + kb.EmitKeyDown(Key.A, ModifierMask.Shift | ModifierMask.Ctrl); + Assert.Empty(fired); + + // Exactly Ctrl+A fires. + kb.EmitKeyDown(Key.A, ModifierMask.Ctrl); + Assert.Single(fired); + } + + [Fact] + public void WantCaptureKeyboard_suppresses_KeyDown_events() + { + var (_, kb, mouse, bindings, fired) = Build(); + bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + mouse.WantCaptureKeyboard = true; + + kb.EmitKeyDown(Key.W, ModifierMask.None); + + Assert.Empty(fired); + } + + [Fact] + public void WantCaptureMouse_suppresses_MouseDown_events() + { + var (_, _, mouse, bindings, fired) = Build(); + var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1); + bindings.Add(new Binding(leftClick, InputAction.SelectLeft)); + mouse.WantCaptureMouse = true; + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + + Assert.Empty(fired); + } + + [Fact] + public void Default_active_scope_is_Game() + { + var (dispatcher, _, _, _, _) = Build(); + Assert.Equal(InputScope.Game, dispatcher.ActiveScope); + } + + [Fact] + public void PushScope_changes_ActiveScope() + { + var (dispatcher, _, _, _, _) = Build(); + dispatcher.PushScope(InputScope.Chat); + Assert.Equal(InputScope.Chat, dispatcher.ActiveScope); + } + + [Fact] + public void PopScope_with_mismatched_expected_throws() + { + var (dispatcher, _, _, _, _) = Build(); + dispatcher.PushScope(InputScope.Chat); + Assert.Throws(() => dispatcher.PopScope(InputScope.Dialog)); + } + + [Fact] + public void PopScope_restores_previous() + { + var (dispatcher, _, _, _, _) = Build(); + dispatcher.PushScope(InputScope.Chat); + dispatcher.PopScope(InputScope.Chat); + Assert.Equal(InputScope.Game, dispatcher.ActiveScope); + } + + [Fact] + public void Hold_binding_fires_Press_on_KeyDown_and_Release_on_KeyUp() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + InputAction.MovementRunLock, + ActivationType.Hold)); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None); + Assert.Contains((InputAction.MovementRunLock, ActivationType.Press), fired); + + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None); + Assert.Contains((InputAction.MovementRunLock, ActivationType.Release), fired); + } + + [Fact] + public void Tick_fires_Hold_for_currently_held_chords() + { + var (dispatcher, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + InputAction.MovementRunLock, + ActivationType.Hold)); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None); + fired.Clear(); // discard the initial Press transition + + dispatcher.Tick(); + dispatcher.Tick(); + + // Two Hold ticks while held. + Assert.Equal(2, fired.Count); + Assert.All(fired, e => Assert.Equal((InputAction.MovementRunLock, ActivationType.Hold), e)); + + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None); + fired.Clear(); + + dispatcher.Tick(); + Assert.Empty(fired); // no longer held + } + + [Fact] + public void Release_binding_fires_only_on_KeyUp() + { + var (_, kb, _, bindings, fired) = Build(); + bindings.Add(new Binding( + new KeyChord(Key.W, ModifierMask.None), + InputAction.MovementStop, + ActivationType.Release)); + + kb.EmitKeyDown(Key.W, ModifierMask.None); + Assert.Empty(fired); + + kb.EmitKeyUp(Key.W, ModifierMask.None); + Assert.Single(fired); + Assert.Equal((InputAction.MovementStop, ActivationType.Release), fired[0]); + } + + [Fact] + public void MouseDown_with_matching_chord_fires_action() + { + var (_, _, mouse, bindings, fired) = Build(); + var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1); + bindings.Add(new Binding(leftClick, InputAction.SelectLeft)); + + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); + + Assert.Single(fired); + Assert.Equal((InputAction.SelectLeft, ActivationType.Press), fired[0]); + } + + [Fact] + public void Tick_no_op_when_no_chords_held() + { + var (dispatcher, _, _, _, fired) = Build(); + dispatcher.Tick(); + Assert.Empty(fired); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs new file mode 100644 index 0000000..4faffa4 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs @@ -0,0 +1,106 @@ +using System.Linq; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class KeyBindingsTests +{ + [Fact] + public void Add_appends_to_All() + { + var b = new KeyBindings(); + var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward); + b.Add(binding); + Assert.Single(b.All); + Assert.Equal(binding, b.All[0]); + } + + [Fact] + public void Remove_drops_binding() + { + var b = new KeyBindings(); + var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward); + b.Add(binding); + Assert.True(b.Remove(binding)); + Assert.Empty(b.All); + } + + [Fact] + public void Clear_empties_all() + { + var b = new KeyBindings(); + b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + b.Clear(); + Assert.Empty(b.All); + } + + [Fact] + public void Find_returns_matching_binding_by_chord_and_activation() + { + var b = new KeyBindings(); + var press = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Press); + var release = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Release); + b.Add(press); + b.Add(release); + + Assert.Equal(press, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press)); + Assert.Equal(release, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Release)); + } + + [Fact] + public void Find_returns_null_when_no_match() + { + var b = new KeyBindings(); + b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press)); + } + + [Fact] + public void ForAction_returns_all_chords_bound_to_action() + { + var b = new KeyBindings(); + // Retail-style: W and Up both → MovementForward. + b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); + b.Add(new Binding(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward)); + b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); + + var forward = b.ForAction(InputAction.MovementForward).ToList(); + Assert.Equal(2, forward.Count); + Assert.Contains(forward, x => x.Chord.Key == Key.W); + Assert.Contains(forward, x => x.Chord.Key == Key.Up); + } + + [Fact] + public void AcdreamCurrentDefaults_includes_WASD_movement() + { + var b = KeyBindings.AcdreamCurrentDefaults(); + Assert.NotNull(b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.A, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.D, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.Z, ModifierMask.None), ActivationType.Press)); + Assert.NotNull(b.Find(new KeyChord(Key.X, ModifierMask.None), ActivationType.Press)); + } + + [Fact] + public void AcdreamCurrentDefaults_binds_shift_as_hold_for_run() + { + var b = KeyBindings.AcdreamCurrentDefaults(); + var hold = b.Find(new KeyChord(Key.ShiftLeft, ModifierMask.None), ActivationType.Hold); + Assert.NotNull(hold); + Assert.Equal(InputAction.MovementRunLock, hold!.Value.Action); + } + + [Fact] + public void RetailDefaults_proxies_AcdreamCurrentDefaults_in_K1a() + { + // K.1a stub — RetailDefaults() returns the acdream-current binds so + // K.1b cutover doesn't change behavior. K.1c flips this to the + // retail preset. + var retail = KeyBindings.RetailDefaults(); + var current = KeyBindings.AcdreamCurrentDefaults(); + Assert.Equal(current.All.Count, retail.All.Count); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs new file mode 100644 index 0000000..9ffcf0c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs @@ -0,0 +1,54 @@ +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class KeyChordTests +{ + [Fact] + public void Same_key_no_modifier_equal() + { + var a = new KeyChord(Key.W, ModifierMask.None); + var b = new KeyChord(Key.W, ModifierMask.None); + Assert.Equal(a, b); + } + + [Fact] + public void CtrlA_does_not_match_bare_A() + { + var ctrlA = new KeyChord(Key.A, ModifierMask.Ctrl); + var bareA = new KeyChord(Key.A, ModifierMask.None); + Assert.NotEqual(ctrlA, bareA); + } + + [Fact] + public void CtrlA_does_not_match_ShiftCtrlA() + { + var ctrlA = new KeyChord(Key.A, ModifierMask.Ctrl); + var shiftCtrlA = new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl); + Assert.NotEqual(ctrlA, shiftCtrlA); + } + + [Fact] + public void Different_keys_with_same_modifier_distinct() + { + Assert.NotEqual( + new KeyChord(Key.A, ModifierMask.Shift), + new KeyChord(Key.B, ModifierMask.Shift)); + } + + [Fact] + public void Default_device_is_zero_keyboard() + { + var c = new KeyChord(Key.W, ModifierMask.None); + Assert.Equal(0, c.Device); + } + + [Fact] + public void Different_device_with_same_key_distinct() + { + var keyboard = new KeyChord(Key.A, ModifierMask.None, Device: 0); + var mouse = new KeyChord(Key.A, ModifierMask.None, Device: 1); + Assert.NotEqual(keyboard, mouse); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs new file mode 100644 index 0000000..43bdc29 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs @@ -0,0 +1,42 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public class ModifierMaskTests +{ + [Fact] + public void None_is_zero() + { + Assert.Equal((uint)0, (uint)ModifierMask.None); + } + + [Fact] + public void Bit_values_match_retail_keymap_metakey_table() + { + // Retail's "Metakeys" table: LSHIFT=1, LCTRL=2, LALT=4, LWIN=8. + // The retail keymap text format writes these as bit-flags so a + // user editing acclient.keymap by hand sees the same values. + Assert.Equal((uint)0x01, (uint)ModifierMask.Shift); + Assert.Equal((uint)0x02, (uint)ModifierMask.Ctrl); + Assert.Equal((uint)0x04, (uint)ModifierMask.Alt); + Assert.Equal((uint)0x08, (uint)ModifierMask.Win); + } + + [Fact] + public void Or_combines_bits() + { + var combo = ModifierMask.Shift | ModifierMask.Ctrl; + Assert.True(combo.HasFlag(ModifierMask.Shift)); + Assert.True(combo.HasFlag(ModifierMask.Ctrl)); + Assert.False(combo.HasFlag(ModifierMask.Alt)); + } + + [Fact] + public void Equality_distinguishes_distinct_masks() + { + Assert.NotEqual(ModifierMask.Shift, ModifierMask.Ctrl); + Assert.NotEqual(ModifierMask.None, ModifierMask.Shift); + Assert.Equal(ModifierMask.Shift | ModifierMask.Ctrl, + ModifierMask.Ctrl | ModifierMask.Shift); + } +}