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)]