feat(ui): complete retail quick-slot input

Port global toolbar use/select/create actions, migrate the collapsed Ctrl-number bindings, and route them through a focused retained-UI controller. Preserve shortcut aliases as aliases across inventory and paperdoll drops with retail's neutral/accept/reject drag states, preventing physical item moves such as unwielding an equipped helmet.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 08:59:49 +02:00
parent 6fcc510d5d
commit e65119f0c6
24 changed files with 650 additions and 69 deletions

View file

@ -8,12 +8,10 @@ namespace AcDream.UI.Abstractions.Input;
/// debug bindings that have no retail equivalent.
///
/// <para>
/// 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 (<c>UseQuickSlot_*</c>,
/// <c>Combat*</c>, <c>UseSpellSlot_*</c>) have NO subscribers in K.1 —
/// the chord fires the event but nothing acts on it. Phase L panels
/// will subscribe.
/// K.1a defined the enum and K.1c flipped the bindings table to the full
/// retail preset. Runtime controllers subscribe by subsystem; actions whose
/// owning panel has not landed yet (for example <c>UseSpellSlot_*</c>) may
/// intentionally remain undispatched.
/// </para>
/// </summary>
public enum InputAction
@ -142,12 +140,21 @@ public enum InputAction
UseQuickSlot_7,
UseQuickSlot_8,
UseQuickSlot_9,
SelectQuickSlot_1,
SelectQuickSlot_2,
SelectQuickSlot_3,
SelectQuickSlot_4,
SelectQuickSlot_5,
SelectQuickSlot_6,
SelectQuickSlot_7,
SelectQuickSlot_8,
SelectQuickSlot_9,
UseQuickSlot_14,
UseQuickSlot_15,
UseQuickSlot_16,
UseQuickSlot_17,
UseQuickSlot_18,
/// <summary>Drop-target shortcut creation (retail 0 / drag-drop).</summary>
/// <summary>Create a toolbar shortcut to the selected object (retail 0).</summary>
CreateShortcut,
// ── Chat ──────────────────────────────────────────────

View file

@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary>
public sealed class KeyBindings
{
private const int CurrentSchemaVersion = 1;
private const int CurrentSchemaVersion = 2;
private readonly List<Binding> _bindings = new();
@ -212,13 +212,18 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT));
// ── QuickslotCommands ──────────────────────────────────
// Number1..9 → UseQuickSlot_1..9 with bare-mod and Ctrl-mod chords.
// Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives
// distinct action-id ranges: bare 1..9 USE slots 0..8, while
// Ctrl+1..9 SELECT those slots. The keymap repeats the display name
// UseQuickSlot_N for both bindings, so our semantic action layer must
// preserve the differing intent explicitly.
for (int i = 1; i <= 9; i++)
{
var k = (Key)((int)Key.Number0 + i); // Number1..Number9
var action = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
b.Add(new(new KeyChord(k, ModifierMask.None), action));
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), action));
var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1);
b.Add(new(new KeyChord(k, ModifierMask.None), useAction));
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction));
}
// Alt+5..9 → UseQuickSlot_14..18.
for (int i = 5; i <= 9; i++)
@ -377,9 +382,9 @@ public sealed class KeyBindings
using var stream = File.OpenRead(path);
var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
// version is currently advisory — read so future migrations
// can branch on it; the field's presence is non-fatal.
_ = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0;
// Missing/older versions are valid and drive narrow migrations;
// the field's presence remains non-fatal.
int version = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0;
var defaults = RetailDefaults();
var loaded = new KeyBindings();
@ -413,7 +418,9 @@ public sealed class KeyBindings
{
device = (byte)dEl.GetInt32();
}
loaded.Add(new(new KeyChord(silkKey, mods, device), action, activation));
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
loaded.Add(new(chord, action, activation));
}
}
}
@ -481,6 +488,34 @@ public sealed class KeyBindings
File.WriteAllText(path, json);
}
/// <summary>
/// Schema v1 repeated <c>UseQuickSlot_N</c> for bare and Ctrl chords,
/// losing retail's use-vs-select distinction. Migrate only the exact old
/// default Ctrl+matching-number shape; arbitrary user rebindings remain
/// attached to the action the user chose.
/// </summary>
private static InputAction MigrateLegacyQuickSlotIntent(
int version,
InputAction action,
KeyChord chord,
ActivationType activation)
{
if (version >= 2
|| activation != ActivationType.Press
|| chord.Device != 0
|| chord.Modifiers != ModifierMask.Ctrl)
return action;
int offset = (int)action - (int)InputAction.UseQuickSlot_1;
if ((uint)offset >= 9u)
return action;
var expectedKey = (Key)((int)Key.Number1 + offset);
return chord.Key == expectedKey
? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset)
: action;
}
private static ModifierMask ParseModifiers(JsonElement bindingEl)
{
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;