diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 3f87ee69..8223e4c9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -142,6 +142,12 @@ DAT resolver prefixes it exactly once. Empty append fragments now preserve retail's intentional blank section rows, including the blank after Workmanship and the literal leading newline before Armor Level. +The adjacent world-input follow-up also completes retail right-click +assessment. `SelectRight` is now a release-completed configurable click with +the shared three-pixel drag threshold, then pulses, selects, and appraises the +picked world object through the same owners as the magnifying glass and E key. +RMB camera drags and empty-world releases do not issue appraisal requests. + **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; @@ -162,6 +168,8 @@ animated target remains above row chrome but below text; changing selection updates the open window without another magnifier click. Item text begins at the top and assessed melee/missile/armor/magic items show their retail-ordered stats and full DAT spell descriptions. +Right-clicking a visible world object opens the same examination window; +right-dragging the camera does not. The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`, then its description after one retail paragraph break, without the bogus 255-item/255-container line. diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d2b9bf38..d1bc8206 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -30,7 +30,10 @@ ports the optional public shared-cooldown metadata, the authoritative cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot` presentation; its connected gate passed. The existing magnifying-glass Assess -command was already retail-faithful. Slice 3's first connected gate exposed +command was already retail-faithful. World right-click assessment now follows +the configurable `SelectRight` action's retail release-completed gesture, +including drag cancellation, then reuses the canonical +picker/selection/appraisal path. Slice 3's first connected gate exposed three deeper defects, now corrected: the response enum mislabeled ACE's creature bit `0x0100` and dropped monster packets while retaining the busy cursor; the imported 310 x 400 examination layout was incorrectly hosted as diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 705c5e75..855dcb2f 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -98,6 +98,9 @@ adjacent cooldown follow-up now preserves the item's shared group/duration, projects the authoritative cooldown-enchantment bucket, and draws retail's ten DAT-authored radial steps across all item-slot surfaces; its connected gate passed. The pre-existing Assess command has been reconciled against retail. +The same slice now includes retail world right-click assessment through the +configurable `SelectRight` complete-click gesture; RMB orbit drags cancel +instead of appraising the release point. Slice 3's first connected gate found and corrected the real remaining defects: ACE creature flag `0x0100` was mislabeled as a weapon profile and left the appraisal busy reference held, retail's examination UI had been mounted as a diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index adfa2754..657a4bfd 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -226,6 +226,17 @@ title, and boots-layout fixtures protect the full path. The Release solution build and 3,533 App tests / 3 skips plus 7,956 complete-solution tests / 5 skips pass; the connected boots comparison is the remaining visual gate. +The world right-click follow-up ports +`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` and the +`sr_Examine` branch of `RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`. +The configurable `SelectRight` binding now completes on release, cancels when +pointer travel crosses the retail-observed three-pixel drag threshold, and +routes through the existing world picker, lighting pulse, canonical +`SelectionState`, and appraisal request owner. Empty world space remains a +no-op, right-drag camera orbit does not appraise its release point, and the +independent configurable `SelectionExamine` action now reaches the same +request/target-mode path. + ## Slice 1 — spell-bar overflow arrows ### Retail oracle diff --git a/docs/research/2026-07-17-retail-world-selection-pseudocode.md b/docs/research/2026-07-17-retail-world-selection-pseudocode.md index 79aaa904..f1377b6b 100644 --- a/docs/research/2026-07-17-retail-world-selection-pseudocode.md +++ b/docs/research/2026-07-17-retail-world-selection-pseudocode.md @@ -190,6 +190,37 @@ procedural arrow. The indicator is persistent selection presentation. Retail also has a separate, short-lived click confirmation owned by SmartBox; it is not implemented in `CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`. +## Right-click selection and examination + +`UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820` + +```text +on right mouse up: + if mouse movement owns the gesture: + finish mouse movement and do not examine + else if instant mouse-look owns the gesture: + leave mouse-look and do not examine + else if no higher-priority SmartBox search is pending: + searchReason = sr_Examine + FindObject(releaseX, releaseY) +``` + +`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0` + +```text +if a valid object was found for sr_Examine: + begin the normal SmartBox lighting pulse + SetSelectedObject(foundObjectId) + ClientUISystem.ExamineObject(foundObjectId) +else: + do nothing +``` + +The action is release-completed rather than mouse-down-completed so an RMB +camera drag does not appraise the object under the release point. Acdream maps +the configurable `SelectRight` action to the same complete-click gesture and +keeps `SelectionExamine` independently configurable (retail default E). + ## SmartBox click lighting pulse `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0` diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs index 5800bd0d..c788afa0 100644 --- a/src/AcDream.App/Input/GameplayInputActionRouter.cs +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -262,7 +262,8 @@ internal sealed class GameplayInputActionRouter : IDisposable return; if (activation is not ActivationType.Press - and not ActivationType.DoubleClick) + and not ActivationType.DoubleClick + and not ActivationType.Click) { return; } diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index b635f654..097f23e1 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -73,9 +73,16 @@ internal sealed class SelectionInteractionController case InputAction.SelectLeft: PickAndStoreSelection(useImmediately: false); return true; + case InputAction.SelectRight: + PickSelectAndExamine(); + return true; case InputAction.SelectDblLeft: PickAndStoreSelection(useImmediately: true); return true; + case InputAction.SelectionExamine: + _items.ExamineSelectedOrEnterMode( + _selection.SelectedObjectId ?? 0u); + return true; case InputAction.UseSelected: UseCurrentSelection(); return true; @@ -181,6 +188,25 @@ internal sealed class SelectionInteractionController }); } + /// + /// Retail SmartBox right-click path: + /// UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820 chooses + /// sr_Examine, then + /// RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 pulses, selects, + /// and calls ClientUISystem::ExamineObject. Empty space is a no-op, + /// and this path does not consume a left-click target mode. + /// + public void PickSelectAndExamine() + { + uint? picked = _query.PickAtCursor(includeSelf: false); + if (picked is not uint guid) + return; + + _query.BeginLightingPulse(guid); + _selection.Select(guid, SelectionChangeSource.World); + _items.ExamineSelectedOrEnterMode(guid); + } + public void UseCurrentSelection() { if (_selection.SelectedObjectId is not uint selected) diff --git a/src/AcDream.UI.Abstractions/Input/ActivationType.cs b/src/AcDream.UI.Abstractions/Input/ActivationType.cs index 14f40dae..4530e6df 100644 --- a/src/AcDream.UI.Abstractions/Input/ActivationType.cs +++ b/src/AcDream.UI.Abstractions/Input/ActivationType.cs @@ -21,6 +21,12 @@ public enum ActivationType Hold, /// Mouse-button double-click within retail's chord window. DoubleClick, + /// + /// A complete pointer click: button down and up without crossing the + /// retail-observed three-pixel drag threshold. Keyboard chords bound to a + /// click action fire on key-down, preserving configurable action bindings. + /// + Click, /// 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/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index fd729585..cb99222c 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -39,7 +39,8 @@ public sealed class InputDispatcher : IDisposable private InputScope? _combatScope; private readonly HashSet _heldHoldChords = new(); private readonly HashSet _automationHeldActions = new(); - private readonly bool[] _sourceAttached = new bool[5]; + private readonly Dictionary _mouseClickTravel = new(); + private readonly bool[] _sourceAttached = new bool[6]; private bool _attachStarted; private int _disposeRequested; private int _active; @@ -51,13 +52,15 @@ public sealed class InputDispatcher : IDisposable private MouseButton? _lastMouseDownButton; private long _lastMouseDownTickMs; private const long DoubleClickThresholdMs = 500; + private const float ClickDragThresholdPixels = 3f; /// K.3 modal-rebind hook: when non-null, the next non-modifier /// chord is reported via this callback INSTEAD of firing actions. Esc /// cancels (callback receives default(KeyChord)). private Action? _captureCallback; - /// Fires every time a binding matches a press / release / hold tick. + /// Fires every time a binding matches a press, release, hold, + /// complete click, or double-click. /// Multicast — every subscriber gets every event in subscription order. public event Action? Fired; @@ -110,6 +113,8 @@ public sealed class InputDispatcher : IDisposable _sourceAttached[3] = true; _mouse.MouseUp += OnMouseUp; _sourceAttached[4] = true; + _mouse.MouseMove += OnMouseMove; + _sourceAttached[5] = true; _mouse.Scroll += OnScroll; Volatile.Write(ref _active, 1); } @@ -140,6 +145,7 @@ public sealed class InputDispatcher : IDisposable _captureCallback = null; _heldHoldChords.Clear(); _automationHeldActions.Clear(); + _mouseClickTravel.Clear(); } public void Dispose() @@ -479,6 +485,9 @@ public sealed class InputDispatcher : IDisposable var press = FindActive(chord, ActivationType.Press); if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + var click = FindActive(chord, ActivationType.Click); + if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click); + var hold = FindActive(chord, ActivationType.Hold); if (hold is not null) { @@ -532,6 +541,7 @@ public sealed class InputDispatcher : IDisposable private void OnMouseDown(MouseButton button, ModifierMask mods) { if (Volatile.Read(ref _active) == 0) return; + _mouseClickTravel.Remove(button); if (_mouse.WantCaptureMouse) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); @@ -545,6 +555,9 @@ public sealed class InputDispatcher : IDisposable _heldHoldChords.Add(chord); } + if (FindActive(chord, ActivationType.Click) is not null) + _mouseClickTravel[button] = 0f; + // Double-click recognition. Same button within DoubleClickThresholdMs // -> additionally fire ActivationType.DoubleClick for any matching // binding. Press has already fired for the second click (same as a @@ -568,6 +581,7 @@ public sealed class InputDispatcher : IDisposable { if (Volatile.Read(ref _active) == 0) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); + bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel); var release = FindActive(chord, ActivationType.Release); if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); @@ -585,6 +599,35 @@ public sealed class InputDispatcher : IDisposable var hold = FindActive(held, ActivationType.Hold); if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); } + + if (wasClickCandidate + && !_mouse.WantCaptureMouse + && travel <= ClickDragThresholdPixels + && FindActive(chord, ActivationType.Click) is { } click) + { + Fired?.Invoke(click.Action, ActivationType.Click); + } + } + + private void OnMouseMove(float dx, float dy) + { + if (Volatile.Read(ref _active) == 0 || _mouseClickTravel.Count == 0) + return; + + if (_mouse.WantCaptureMouse) + { + _mouseClickTravel.Clear(); + return; + } + + float distance = MathF.Sqrt((dx * dx) + (dy * dy)); + foreach (MouseButton button in _mouseClickTravel.Keys.ToArray()) + { + if (_mouse.IsHeld(button)) + _mouseClickTravel[button] += distance; + else + _mouseClickTravel.Remove(button); + } } private void OnScroll(float delta) @@ -632,7 +675,8 @@ public sealed class InputDispatcher : IDisposable case 1: _keyboard.KeyUp -= OnKeyUp; break; case 2: _mouse.MouseDown -= OnMouseDown; break; case 3: _mouse.MouseUp -= OnMouseUp; break; - case 4: _mouse.Scroll -= OnScroll; break; + case 4: _mouse.MouseMove -= OnMouseMove; break; + case 5: _mouse.Scroll -= OnScroll; break; default: throw new ArgumentOutOfRangeException(nameof(index)); } _sourceAttached[index] = false; diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index ae9dd5c6..68ae52f4 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input; /// public sealed class KeyBindings { - private const int CurrentSchemaVersion = 4; + private const int CurrentSchemaVersion = 5; private readonly List _bindings = new(); @@ -324,7 +324,8 @@ public sealed class KeyBindings InputAction.SelectLeft)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1), - InputAction.SelectRight)); + InputAction.SelectRight, + ActivationType.Click)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), InputAction.SelectMid)); @@ -367,9 +368,9 @@ public sealed class KeyBindings InputAction.AcdreamToggleFlyMode)); // K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the - // SelectRight Press binding above — Press fires on click, - // AcdreamRmbOrbitHold fires on hold/release transitions so the - // chase camera can free-orbit while the user drags the mouse. + // SelectRight Click binding above resolves only after a stationary + // release. AcdreamRmbOrbitHold fires on hold/release transitions so + // the chase camera can free-orbit while the user drags the mouse. // Without this, RMB-orbit silently broke when K.1c flipped the // default keymap from AcdreamCurrentDefaults to RetailDefaults. b.Add(new( @@ -435,6 +436,7 @@ public sealed class KeyBindings var chord = new KeyChord(silkKey, mods, device); action = MigrateLegacyQuickSlotIntent(version, action, chord, activation); activation = MigrateCombatAttackActivation(version, action, activation); + activation = MigrateSelectRightActivation(version, action, activation); InputScope scope = defaults.ForAction(action) .Select(binding => binding.Scope) .DefaultIfEmpty(InputScope.Game) @@ -562,6 +564,21 @@ public sealed class KeyBindings : activation; } + /// + /// Schema v4 fired SelectRight on mouse-down. Retail's SmartBox resolves + /// right-click examination on mouse-up only when the pointer did not turn + /// into a drag, so preserve the user's chord while upgrading its gesture. + /// + private static ActivationType MigrateSelectRightActivation( + int version, + InputAction action, + ActivationType activation) + => version < 5 + && action == InputAction.SelectRight + && activation == ActivationType.Press + ? ActivationType.Click + : activation; + private static ModifierMask ParseModifiers(JsonElement bindingEl) { if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None; diff --git a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs index a840d884..0d591f13 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs @@ -70,6 +70,21 @@ public sealed class GameplayInputActionRouterTests harness.Targets.Calls); } + [Fact] + public void Click_PassesGateToSelection() + { + var harness = new Harness("selection"); + harness.Router.Attach(); + + harness.Actions.Raise( + InputAction.SelectRight, + ActivationType.Click); + + Assert.Equal( + ["pointer", "combat", "retained", "selection"], + harness.Targets.Calls); + } + [Fact] public void Attach_SeedsAndTracksCombatScopes() { diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index db49235e..001de805 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -223,6 +223,65 @@ public sealed class SelectionInteractionControllerTests Assert.Null(h.Selection.SelectedObjectId); } + [Fact] + public void RightClickPulsesSelectsAndExaminesPickedWorldObject() + { + var h = new Harness(); + h.Query.Picked = Target; + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); + + Assert.False(h.Query.LastIncludeSelf); + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); + Assert.Equal(new[] { Target }, h.Examines); + } + + [Fact] + public void RightClickEmptyWorldIsANoOp() + { + var h = new Harness(); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); + + Assert.Equal(new[] { "pick" }, h.Query.Events); + Assert.Null(h.Selection.SelectedObjectId); + Assert.Empty(h.Examines); + Assert.Empty(h.Toasts); + } + + [Fact] + public void RightClickExaminesWithoutConsumingLeftClickTargetMode() + { + var h = new Harness(); + h.Query.Picked = Target; + h.Items.InteractionState.EnterUse(); + + h.Controller.HandleInputAction(InputAction.SelectRight); + + Assert.Equal(InteractionModeKind.Use, h.Items.InteractionState.Current.Kind); + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Equal(new[] { Target }, h.Examines); + } + + [Fact] + public void ExamineActionUsesSelectionOrEntersTargetMode() + { + var selected = new Harness(); + selected.Selection.Select(Target, SelectionChangeSource.World); + + Assert.True(selected.Controller.HandleInputAction( + InputAction.SelectionExamine)); + Assert.Equal(new[] { Target }, selected.Examines); + + var empty = new Harness(); + Assert.True(empty.Controller.HandleInputAction( + InputAction.SelectionExamine)); + Assert.Equal( + InteractionModeKind.Examine, + empty.Items.InteractionState.Current.Kind); + } + [Fact] public void ClosestTargetInputMutatesSelectionOnce() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherClickTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherClickTests.cs new file mode 100644 index 00000000..e9b52d79 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherClickTests.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.UI.Abstractions.Tests.Input; + +public sealed class InputDispatcherClickTests +{ + [Fact] + public void MouseClickFiresOnReleaseAfterStationaryPress() + { + var (dispatcher, mouse, fired) = BuildMouse(); + + mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None); + Assert.Empty(fired); + + mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None); + + Assert.Equal( + [(InputAction.SelectRight, ActivationType.Click)], + fired); + dispatcher.Dispose(); + } + + [Fact] + public void MouseClickDoesNotFireAfterCrossingRetailDragThreshold() + { + var (dispatcher, mouse, fired) = BuildMouse(); + + mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None); + mouse.EmitMouseMove(4f, 0f); + mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None); + + Assert.Empty(fired); + dispatcher.Dispose(); + } + + [Fact] + public void MouseClickAllowsMovementAtRetailDragThreshold() + { + var (dispatcher, mouse, fired) = BuildMouse(); + + mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None); + mouse.EmitMouseMove(3f, 0f); + mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None); + + Assert.Single(fired); + dispatcher.Dispose(); + } + + [Fact] + public void CapturedMouseNeverStartsWorldClick() + { + var (dispatcher, mouse, fired) = BuildMouse(); + mouse.WantCaptureMouse = true; + + mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None); + mouse.WantCaptureMouse = false; + mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None); + + Assert.Empty(fired); + dispatcher.Dispose(); + } + + [Fact] + public void KeyboardRebindOfClickActionFiresOnKeyDown() + { + var keyboard = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var bindings = new KeyBindings(); + bindings.Add(new Binding( + new KeyChord(Key.Q, ModifierMask.None), + InputAction.SelectRight, + ActivationType.Click)); + var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, bindings); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (action, activation) => fired.Add((action, activation)); + dispatcher.Attach(); + + keyboard.EmitKeyDown(Key.Q, ModifierMask.None); + + Assert.Equal( + [(InputAction.SelectRight, ActivationType.Click)], + fired); + dispatcher.Dispose(); + } + + private static ( + InputDispatcher Dispatcher, + FakeMouseSource Mouse, + List<(InputAction, ActivationType)> Fired) BuildMouse() + { + var keyboard = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var bindings = new KeyBindings(); + bindings.Add(new Binding( + new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Right), + ModifierMask.None, + Device: 1), + InputAction.SelectRight, + ActivationType.Click)); + var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, bindings); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (action, activation) => fired.Add((action, activation)); + dispatcher.Attach(); + return (dispatcher, mouse, fired); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs index 3f272f5c..51328e4e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs @@ -11,6 +11,7 @@ public sealed class InputDispatcherLifetimeTests [InlineData(3)] [InlineData(4)] [InlineData(5)] + [InlineData(6)] public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd) { var source = new ThrowingSources { FailAdd = failAdd }; @@ -112,6 +113,7 @@ public sealed class InputDispatcherLifetimeTests private Action? _keyUp; private Action? _mouseDown; private Action? _mouseUp; + private Action? _mouseMove; private Action? _scroll; public int FailAdd { get; init; } @@ -153,9 +155,11 @@ public sealed class InputDispatcherLifetimeTests add => Add(ref _mouseUp, value!); remove => Remove(ref _mouseUp, value!); } -#pragma warning disable CS0067 - public event Action? MouseMove; -#pragma warning restore CS0067 + public event Action? MouseMove + { + add => Add(ref _mouseMove, value!); + remove => Remove(ref _mouseMove, value!); + } public event Action? Scroll { add => Add(ref _scroll, value!); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs index 1e6f1d3d..d37de98b 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs @@ -258,6 +258,37 @@ public class KeyBindingsJsonTests } } + [Fact] + public void LoadOrDefault_migratesV4SelectRightPressBindingToClick() + { + var path = TempFile(); + try + { + const string json = """ + { + "version": 4, + "actions": { + "SelectRight": [ + { "key": "-1002", "device": 1 } + ] + } + } + """; + File.WriteAllText(path, json); + + var loaded = KeyBindings.LoadOrDefault(path); + Binding binding = Assert.Single( + loaded.ForAction(InputAction.SelectRight)); + + Assert.Equal((Key)(-1002), binding.Chord.Key); + Assert.Equal(ActivationType.Click, binding.Activation); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public void LoadOrDefault_unknown_action_name_skipped_silently() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs index a1134471..0bbd9666 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs @@ -108,6 +108,19 @@ public class KeyBindingsRetailTests Assert.Equal(InputAction.EscapeKey, hit!.Value.Action); } + [Fact] + public void SelectRightIsAReleaseCompletedClick() + { + var bindings = KeyBindings.RetailDefaults(); + Binding binding = Assert.Single(bindings.ForAction(InputAction.SelectRight)); + + Assert.Equal(1, binding.Chord.Device); + Assert.Equal( + InputDispatcher.MouseButtonToKey(MouseButton.Right), + binding.Chord.Key); + Assert.Equal(ActivationType.Click, binding.Activation); + } + [Theory] [InlineData(Key.Delete, InputAction.CombatLowAttack)] [InlineData(Key.End, InputAction.CombatMediumAttack)]