Capture global hotkeys on Windows through raw input
A message-only window registers the keyboard and mouse with RIDEV_INPUTSINK, so every key and button arrives as WM_INPUT whether or not the client is in front. Raw input only observes, where a WH_KEYBOARD_LL hook sits in the path of the system input queue and can swallow a keystroke; it also reports the side buttons and both edges of every key, which push-to-talk needs. Keyboard codes stay in each platform's own numbering — X keycodes on X11, set-1 scan codes with the E0/E1 escape folded into the high byte on Windows — since that is what the input API reports and the key-naming call expects. Bindings are per-machine either way, as TeamSpeak's own per-OS keydefs are. Mouse buttons are unified on the X numbering, so "Mouse 4" means the same thing on both. The RAWINPUT decoding lives in its own class so it can be tested off Windows; the window and its pump have not been run on Windows yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,9 +94,14 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
actions. Actions this client does not implement are listed but greyed out.
|
actions. Actions this client does not implement are listed but greyed out.
|
||||||
- Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation**
|
- Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation**
|
||||||
edits that binding.
|
edits that binding.
|
||||||
- Capture uses **XInput2 raw events**, as TeamSpeak's own Linux client does: no
|
- On Linux, capture uses **XInput2 raw events**, as TeamSpeak's own client does: no
|
||||||
privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept
|
privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept
|
||||||
as a fallback for servers without XInput2.
|
as a fallback for servers without XInput2.
|
||||||
|
- On **Windows**, capture uses **raw input** (`RIDEV_INPUTSINK`) through a message-only
|
||||||
|
window: it observes rather than intercepts, so unlike a low-level keyboard hook it
|
||||||
|
cannot swallow a keystroke or stall the system input queue, and it reports the side
|
||||||
|
buttons and both edges of every key. Bindings are stored as scan codes, so they follow
|
||||||
|
the physical key rather than the layout.
|
||||||
- On Wayland the hooks run inside Xwayland, so they see every X11 application, and keys
|
- On Wayland the hooks run inside Xwayland, so they see every X11 application, and keys
|
||||||
aimed at native Wayland windows only where the compositor forwards them — under KWin
|
aimed at native Wayland windows only where the compositor forwards them — under KWin
|
||||||
that is *System Settings → Window Management → Legacy X11 App Support*, which is why
|
that is *System Settings → Window Management → Legacy X11 App Support*, which is why
|
||||||
@@ -206,6 +211,9 @@ desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
|
|||||||
├── XInput2InputHook global key/button capture via XInput2 raw events
|
├── XInput2InputHook global key/button capture via XInput2 raw events
|
||||||
├── XRecordInputHook the same via X11's RECORD extension, as a fallback
|
├── XRecordInputHook the same via X11's RECORD extension, as a fallback
|
||||||
├── EvdevInputHook /dev/input fallback for Wayland sessions
|
├── EvdevInputHook /dev/input fallback for Wayland sessions
|
||||||
|
├── WindowsInputHook the same on Windows: raw input into a message-only window
|
||||||
|
├── RawInput RAWINPUT layout and decoding, split out to be testable
|
||||||
|
├── X11KeyNamer/WindowsKeyNamer layout-aware key labels per platform
|
||||||
└── DesktopInputHooks picks the backend that suits the session
|
└── DesktopInputHooks picks the backend that suits the session
|
||||||
|
|
||||||
swing/ com.ts3client
|
swing/ com.ts3client
|
||||||
@@ -244,4 +252,10 @@ swing/ com.ts3client
|
|||||||
whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
|
whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
|
||||||
sound, hardware ("local") microphone mute and the channel-traversal variants
|
sound, hardware ("local") microphone mute and the channel-traversal variants
|
||||||
beyond "Switch to Channel".
|
beyond "Switch to Channel".
|
||||||
|
- Global hotkeys cover Linux and Windows; **macOS has no backend**, so hotkeys there are
|
||||||
|
inert (a `CGEventTap`, which needs Accessibility permission, is the way in). The
|
||||||
|
Windows backend is written but has not been run on Windows — only its decoding is
|
||||||
|
covered by tests.
|
||||||
|
- When no backend can start, push-to-talk does not work at all, not even with the window
|
||||||
|
focused; a focus-scoped fallback hook would restore the pre-hotkey behaviour.
|
||||||
- No file transfer, avatars, or server/channel administration UI yet.
|
- No file transfer, avatars, or server/channel administration UI yet.
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
package com.ts3client.hotkey;
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One physical key or mouse button, identified the way the X server numbers them:
|
* One physical key or mouse button. Keys are identified by their position rather than by
|
||||||
* keyboards by keycode (Linux evdev code + 8) and mice by button number, so a binding
|
* the character they produce, so a binding is independent of the keyboard layout in force
|
||||||
* is independent of the keyboard layout in force when it was recorded.
|
* when it was recorded.
|
||||||
|
*
|
||||||
|
* <p>The keyboard numbering is whatever the platform's own is — X keycodes (Linux evdev
|
||||||
|
* code + 8) on X11, set-1 scan codes with the {@code E0}/{@code E1} escape in the high
|
||||||
|
* byte on Windows — because that is what its input API reports and its key-naming call
|
||||||
|
* expects. Bindings are therefore per-machine, which is also how TeamSpeak stores them.
|
||||||
|
* Mouse buttons are unified on the X numbering everywhere, side buttons included.
|
||||||
*
|
*
|
||||||
* @param device which input device the code belongs to
|
* @param device which input device the code belongs to
|
||||||
* @param code X keycode for {@link Device#KEYBOARD}, X button number for {@link Device#MOUSE}
|
* @param code the platform's key code for {@link Device#KEYBOARD}, an X-style button
|
||||||
|
* number for {@link Device#MOUSE}
|
||||||
*/
|
*/
|
||||||
public record HotkeyKey(HotkeyKey.Device device, int code) {
|
public record HotkeyKey(HotkeyKey.Device device, int code) {
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* Picks the global input hook that suits the running session and starts it.
|
* Picks the global input hook that suits the running session and starts it.
|
||||||
*
|
*
|
||||||
* <p>On X11, RECORD is the polite choice: no extra privileges, no devices to open. On
|
* <p>Windows has one answer, raw input. On X11 it is XInput2, with RECORD behind it for
|
||||||
* Wayland the X server sees only what is aimed at X clients, so reading evdev is the
|
* servers that lack it: neither needs privileges or devices to open. On Wayland the X
|
||||||
* only way to catch every key — and when that is not permitted either, the returned
|
* server sees only what the compositor forwards, so reading evdev is the surest way to
|
||||||
* hook is simply not running and says why.
|
* catch every key — and when that is not permitted either, the returned hook is simply
|
||||||
|
* not running and says why.
|
||||||
*/
|
*/
|
||||||
public final class DesktopInputHooks {
|
public final class DesktopInputHooks {
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ public final class DesktopInputHooks {
|
|||||||
|
|
||||||
private static List<GlobalInputHook> candidates() {
|
private static List<GlobalInputHook> candidates() {
|
||||||
List<GlobalInputHook> hooks = new ArrayList<>();
|
List<GlobalInputHook> hooks = new ArrayList<>();
|
||||||
|
if (Win32.isWindows()) {
|
||||||
|
if (WindowsInputHook.isSupported()) hooks.add(new WindowsInputHook());
|
||||||
|
return hooks;
|
||||||
|
}
|
||||||
if (isWayland()) {
|
if (isWayland()) {
|
||||||
// Evdev first: it is the only backend guaranteed to see keys aimed at native
|
// Evdev first: it is the only backend guaranteed to see keys aimed at native
|
||||||
// Wayland windows. Failing that, Xwayland still covers every X11 app, and on
|
// Wayland windows. Failing that, Xwayland still covers every X11 app, and on
|
||||||
@@ -79,7 +84,9 @@ public final class DesktopInputHooks {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String keyName(HotkeyKey key) {
|
public String keyName(HotkeyKey key) {
|
||||||
return X11KeyNamer.name(key);
|
// Bindings recorded earlier still deserve their real names in the dialog,
|
||||||
|
// even with no hook running to have produced them.
|
||||||
|
return Win32.isWindows() ? WindowsKeyNamer.name(key) : X11KeyNamer.name(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout of a {@code RAWINPUT} structure and the reading of the two kinds we ask for,
|
||||||
|
* split out from {@link WindowsInputHook} so the decoding can be tested anywhere.
|
||||||
|
*
|
||||||
|
* <p>Keyboards report a set-1 scan code, which is what a binding stores: it names the
|
||||||
|
* physical key, so it survives a layout change exactly as an X keycode does on Linux.
|
||||||
|
* Mice are renumbered to the X button numbering the rest of the client already speaks,
|
||||||
|
* so {@link HotkeyKey#fallbackName()} and the saved bindings mean the same thing on
|
||||||
|
* both platforms.
|
||||||
|
*/
|
||||||
|
final class RawInput {
|
||||||
|
|
||||||
|
private RawInput() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code RAWINPUTHEADER.dwType}. */
|
||||||
|
static final int TYPE_MOUSE = 0;
|
||||||
|
static final int TYPE_KEYBOARD = 1;
|
||||||
|
|
||||||
|
/** Field offsets in {@code RAWINPUT} under x64, past the 24-byte header. */
|
||||||
|
static final long HEADER_TYPE = 0;
|
||||||
|
static final long HEADER_SIZE = 24;
|
||||||
|
static final long KEYBOARD_MAKE_CODE = 24;
|
||||||
|
static final long KEYBOARD_FLAGS = 26;
|
||||||
|
static final long KEYBOARD_VKEY = 30;
|
||||||
|
static final long MOUSE_BUTTON_FLAGS = 28;
|
||||||
|
|
||||||
|
/** Both event kinds fit comfortably; the larger, {@code RAWMOUSE}, needs 48 bytes. */
|
||||||
|
static final long BUFFER_SIZE = 64;
|
||||||
|
|
||||||
|
/** {@code RAWKEYBOARD.Flags}. */
|
||||||
|
static final int RI_KEY_BREAK = 0x01;
|
||||||
|
static final int RI_KEY_E0 = 0x02;
|
||||||
|
static final int RI_KEY_E1 = 0x04;
|
||||||
|
|
||||||
|
/** {@code VK_NO_VKEY}: the filler half of an escaped sequence, carrying no key. */
|
||||||
|
private static final int VKEY_NONE = 0xFF;
|
||||||
|
|
||||||
|
/** {@code RAWMOUSE.usButtonFlags}, in down/up pairs. */
|
||||||
|
private static final int[] BUTTON_FLAGS = {
|
||||||
|
0x0001, 0x0002, 1, // left
|
||||||
|
0x0010, 0x0020, 2, // middle
|
||||||
|
0x0004, 0x0008, 3, // right
|
||||||
|
0x0040, 0x0080, 8, // X1, "Mouse 4"
|
||||||
|
0x0100, 0x0200, 9, // X2, "Mouse 5"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int RI_MOUSE_WHEEL = 0x0400;
|
||||||
|
private static final int RI_MOUSE_HWHEEL = 0x0800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scan code identifying a key, with the escape prefix folded into the high byte
|
||||||
|
* so that e.g. right Ctrl ({@code E0 1D}) stays distinct from left ({@code 1D}).
|
||||||
|
*
|
||||||
|
* @return the code, or -1 when the event names no key of its own
|
||||||
|
*/
|
||||||
|
static int scanCode(int makeCode, int flags, int vkey) {
|
||||||
|
if (vkey == VKEY_NONE) return -1;
|
||||||
|
int code = makeCode & 0xFF;
|
||||||
|
if (code == 0) return -1;
|
||||||
|
if ((flags & RI_KEY_E0) != 0) code |= 0xE000;
|
||||||
|
else if ((flags & RI_KEY_E1) != 0) code |= 0xE100;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isRelease(int flags) {
|
||||||
|
return (flags & RI_KEY_BREAK) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One button transition, in the order the flags word packs them. */
|
||||||
|
record ButtonEvent(HotkeyKey key, boolean pressed) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks a mouse event, which may carry several transitions at once.
|
||||||
|
*
|
||||||
|
* @param buttonFlags {@code usButtonFlags}
|
||||||
|
* @param wheelDelta {@code usButtonData}, read as a signed delta when a wheel bit is set
|
||||||
|
*/
|
||||||
|
static List<ButtonEvent> buttons(int buttonFlags, short wheelDelta) {
|
||||||
|
List<ButtonEvent> events = new ArrayList<>();
|
||||||
|
for (int i = 0; i < BUTTON_FLAGS.length; i += 3) {
|
||||||
|
HotkeyKey key = HotkeyKey.mouse(BUTTON_FLAGS[i + 2]);
|
||||||
|
if ((buttonFlags & BUTTON_FLAGS[i]) != 0) events.add(new ButtonEvent(key, true));
|
||||||
|
if ((buttonFlags & BUTTON_FLAGS[i + 1]) != 0) events.add(new ButtonEvent(key, false));
|
||||||
|
}
|
||||||
|
// A wheel notch has no release of its own; X reports it as a button tap, and the
|
||||||
|
// hotkey engine expects the same shape, so synthesise both edges.
|
||||||
|
if ((buttonFlags & RI_MOUSE_WHEEL) != 0 && wheelDelta != 0) {
|
||||||
|
tap(events, wheelDelta > 0 ? 4 : 5);
|
||||||
|
}
|
||||||
|
if ((buttonFlags & RI_MOUSE_HWHEEL) != 0 && wheelDelta != 0) {
|
||||||
|
tap(events, wheelDelta > 0 ? 7 : 6);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tap(List<ButtonEvent> events, int button) {
|
||||||
|
events.add(new ButtonEvent(HotkeyKey.mouse(button), true));
|
||||||
|
events.add(new ButtonEvent(HotkeyKey.mouse(button), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
|
import java.lang.foreign.Linker;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.SymbolLookup;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw binding to {@code user32} and {@code kernel32}, with just the calls
|
||||||
|
* {@link WindowsInputHook} needs to own a message-only window and read raw input from it.
|
||||||
|
*
|
||||||
|
* <p>Raw input is the polite way to watch the whole machine on Windows: registering with
|
||||||
|
* {@code RIDEV_INPUTSINK} delivers every key and button even while another application is
|
||||||
|
* in the foreground, and — unlike a {@code WH_KEYBOARD_LL} hook — it observes rather than
|
||||||
|
* intercepts, so nothing can be swallowed and nothing serialises the system input queue.
|
||||||
|
*
|
||||||
|
* <p>Loading is lazy and failure is expected off Windows, where {@link #isAvailable()}
|
||||||
|
* returns {@code false} and the caller falls back to another backend.
|
||||||
|
*/
|
||||||
|
final class Win32 {
|
||||||
|
|
||||||
|
private Win32() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
|
||||||
|
private static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
|
||||||
|
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
|
||||||
|
|
||||||
|
/** Window messages we care about. */
|
||||||
|
static final int WM_DESTROY = 0x0002;
|
||||||
|
static final int WM_CLOSE = 0x0010;
|
||||||
|
static final int WM_INPUT = 0x00FF;
|
||||||
|
|
||||||
|
/** {@code HWND_MESSAGE}: parent for a window that only ever receives messages. */
|
||||||
|
static final long HWND_MESSAGE = -3L;
|
||||||
|
|
||||||
|
/** {@code RIDEV_INPUTSINK}: deliver input even when the window is not in front. */
|
||||||
|
static final int RIDEV_INPUTSINK = 0x00000100;
|
||||||
|
|
||||||
|
/** HID usages for the two devices we register (usage page 1, "generic desktop"). */
|
||||||
|
static final int USAGE_PAGE_GENERIC = 0x01;
|
||||||
|
static final int USAGE_MOUSE = 0x02;
|
||||||
|
static final int USAGE_KEYBOARD = 0x06;
|
||||||
|
|
||||||
|
/** {@code RID_INPUT}: ask {@code GetRawInputData} for the payload, not the header. */
|
||||||
|
static final int RID_INPUT = 0x10000003;
|
||||||
|
|
||||||
|
/** {@code WNDCLASSEXW} size and the field offsets we fill, under x64. */
|
||||||
|
static final long WNDCLASS_SIZE = 80;
|
||||||
|
static final long WNDCLASS_CBSIZE = 0;
|
||||||
|
static final long WNDCLASS_WNDPROC = 8;
|
||||||
|
static final long WNDCLASS_HINSTANCE = 24;
|
||||||
|
static final long WNDCLASS_CLASSNAME = 64;
|
||||||
|
|
||||||
|
/** {@code RAWINPUTDEVICE}: usage page, usage, flags, target window. */
|
||||||
|
static final long RAWINPUTDEVICE_SIZE = 16;
|
||||||
|
static final long RAWINPUTDEVICE_USAGE_PAGE = 0;
|
||||||
|
static final long RAWINPUTDEVICE_USAGE = 2;
|
||||||
|
static final long RAWINPUTDEVICE_FLAGS = 4;
|
||||||
|
static final long RAWINPUTDEVICE_TARGET = 8;
|
||||||
|
|
||||||
|
/** {@code MSG} is 48 bytes under x64. */
|
||||||
|
static final long MSG_SIZE = 48;
|
||||||
|
|
||||||
|
/** The C signature of a {@code WNDPROC}. */
|
||||||
|
static final FunctionDescriptor WND_PROC =
|
||||||
|
FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG);
|
||||||
|
|
||||||
|
private static final Linker LINKER = Linker.nativeLinker();
|
||||||
|
|
||||||
|
private static final class Libs {
|
||||||
|
static final SymbolLookup USER32 = SymbolLookup.libraryLookup("user32.dll", Arena.global());
|
||||||
|
static final SymbolLookup KERNEL32 = SymbolLookup.libraryLookup("kernel32.dll", Arena.global());
|
||||||
|
|
||||||
|
static final MethodHandle GET_MODULE_HANDLE =
|
||||||
|
downcall(KERNEL32, "GetModuleHandleW", FunctionDescriptor.of(PTR, PTR));
|
||||||
|
static final MethodHandle REGISTER_CLASS =
|
||||||
|
downcall(USER32, "RegisterClassExW", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle UNREGISTER_CLASS =
|
||||||
|
downcall(USER32, "UnregisterClassW", FunctionDescriptor.of(INT, PTR, PTR));
|
||||||
|
static final MethodHandle CREATE_WINDOW =
|
||||||
|
downcall(USER32, "CreateWindowExW", FunctionDescriptor.of(PTR,
|
||||||
|
INT, PTR, PTR, INT, INT, INT, INT, INT, PTR, PTR, PTR, PTR));
|
||||||
|
static final MethodHandle DESTROY_WINDOW =
|
||||||
|
downcall(USER32, "DestroyWindow", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle DEF_WINDOW_PROC =
|
||||||
|
downcall(USER32, "DefWindowProcW", FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG));
|
||||||
|
static final MethodHandle GET_MESSAGE =
|
||||||
|
downcall(USER32, "GetMessageW", FunctionDescriptor.of(INT, PTR, PTR, INT, INT));
|
||||||
|
static final MethodHandle DISPATCH_MESSAGE =
|
||||||
|
downcall(USER32, "DispatchMessageW", FunctionDescriptor.of(LONG, PTR));
|
||||||
|
static final MethodHandle POST_MESSAGE =
|
||||||
|
downcall(USER32, "PostMessageW", FunctionDescriptor.of(INT, PTR, INT, LONG, LONG));
|
||||||
|
static final MethodHandle POST_QUIT_MESSAGE =
|
||||||
|
downcall(USER32, "PostQuitMessage", FunctionDescriptor.ofVoid(INT));
|
||||||
|
static final MethodHandle REGISTER_RAW_INPUT =
|
||||||
|
downcall(USER32, "RegisterRawInputDevices", FunctionDescriptor.of(INT, PTR, INT, INT));
|
||||||
|
static final MethodHandle GET_RAW_INPUT_DATA =
|
||||||
|
downcall(USER32, "GetRawInputData",
|
||||||
|
FunctionDescriptor.of(INT, PTR, INT, PTR, PTR, INT));
|
||||||
|
static final MethodHandle GET_KEY_NAME_TEXT =
|
||||||
|
downcall(USER32, "GetKeyNameTextW", FunctionDescriptor.of(INT, INT, PTR, INT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
|
||||||
|
FunctionDescriptor descriptor) {
|
||||||
|
return LINKER.downcallHandle(
|
||||||
|
lookup.find(symbol).orElseThrow(() ->
|
||||||
|
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
|
||||||
|
descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isWindows() {
|
||||||
|
return System.getProperty("os.name", "").toLowerCase().startsWith("windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isAvailable() {
|
||||||
|
if (!isWindows()) return false;
|
||||||
|
try {
|
||||||
|
return Libs.USER32 != null && Libs.KERNEL32 != null;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allocates a null-terminated UTF-16 string, as every {@code ...W} entry point wants. */
|
||||||
|
static MemorySegment wide(Arena arena, String text) {
|
||||||
|
return arena.allocateFrom(text, StandardCharsets.UTF_16LE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MemorySegment moduleHandle() {
|
||||||
|
return (MemorySegment) call(Libs.GET_MODULE_HANDLE, MemorySegment.NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the class atom, or 0 when registration failed */
|
||||||
|
static int registerClass(MemorySegment wndClass) {
|
||||||
|
return (int) call(Libs.REGISTER_CLASS, wndClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void unregisterClass(MemorySegment className, MemorySegment instance) {
|
||||||
|
call(Libs.UNREGISTER_CLASS, className, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a message-only window: no pixels, but it has a queue and can be a target. */
|
||||||
|
static MemorySegment createMessageWindow(MemorySegment className, MemorySegment instance) {
|
||||||
|
return (MemorySegment) call(Libs.CREATE_WINDOW, 0, className, MemorySegment.NULL, 0,
|
||||||
|
0, 0, 0, 0, MemorySegment.ofAddress(HWND_MESSAGE),
|
||||||
|
MemorySegment.NULL, instance, MemorySegment.NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void destroyWindow(MemorySegment window) {
|
||||||
|
call(Libs.DESTROY_WINDOW, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
static long defWindowProc(MemorySegment window, int message, long wParam, long lParam) {
|
||||||
|
return (long) call(Libs.DEF_WINDOW_PROC, window, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 1 for a message, 0 for {@code WM_QUIT}, -1 on error */
|
||||||
|
static int getMessage(MemorySegment message) {
|
||||||
|
return (int) call(Libs.GET_MESSAGE, message, MemorySegment.NULL, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void dispatchMessage(MemorySegment message) {
|
||||||
|
call(Libs.DISPATCH_MESSAGE, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void postMessage(MemorySegment window, int message, long wParam, long lParam) {
|
||||||
|
call(Libs.POST_MESSAGE, window, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void postQuitMessage(int exitCode) {
|
||||||
|
call(Libs.POST_QUIT_MESSAGE, exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether the devices were registered for background delivery */
|
||||||
|
static boolean registerRawInputDevices(MemorySegment devices, int count) {
|
||||||
|
return (int) call(Libs.REGISTER_RAW_INPUT, devices, count, (int) RAWINPUTDEVICE_SIZE) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return bytes written into {@code buffer}, or -1 on failure */
|
||||||
|
static int getRawInputData(MemorySegment handle, MemorySegment buffer, MemorySegment size) {
|
||||||
|
return (int) call(Libs.GET_RAW_INPUT_DATA, handle, RID_INPUT, buffer, size,
|
||||||
|
(int) RawInput.HEADER_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout's name for a key.
|
||||||
|
*
|
||||||
|
* @param lParam scan code in bits 16..23, the extended flag in bit 24, as the API wants
|
||||||
|
*/
|
||||||
|
static String keyName(int lParam, Arena arena) {
|
||||||
|
MemorySegment buffer = arena.allocate(128);
|
||||||
|
int length = (int) call(Libs.GET_KEY_NAME_TEXT, lParam, buffer, 64);
|
||||||
|
if (length <= 0) return null;
|
||||||
|
return buffer.getString(0, StandardCharsets.UTF_16LE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object call(MethodHandle handle, Object... args) {
|
||||||
|
try {
|
||||||
|
return handle.invokeWithArguments(args);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw new IllegalStateException("Win32 call failed", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.lang.invoke.MethodType;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide input hook for Windows, built on raw input: a message-only window
|
||||||
|
* registers the keyboard and mouse with {@code RIDEV_INPUTSINK}, so every key and button
|
||||||
|
* on the machine arrives as {@code WM_INPUT} whether or not this application is in front,
|
||||||
|
* and the event still reaches the foreground window untouched.
|
||||||
|
*
|
||||||
|
* <p>Chosen over a {@code WH_KEYBOARD_LL} hook because raw input only observes: it cannot
|
||||||
|
* swallow a keystroke, it does not put this process in the path of the system input queue
|
||||||
|
* — where a slow callback stalls typing everywhere — and it reports the side buttons and
|
||||||
|
* both edges of every key, which push-to-talk needs.
|
||||||
|
*
|
||||||
|
* <p>A window's messages belong to the thread that created it, so the window is created
|
||||||
|
* on the pump thread and {@link #start} waits to hear whether that succeeded.
|
||||||
|
*/
|
||||||
|
public final class WindowsInputHook implements GlobalInputHook {
|
||||||
|
|
||||||
|
private static final String WINDOW_CLASS = "Ts3jHotkeyInputSink";
|
||||||
|
|
||||||
|
/** How long to wait for the pump thread to stand its window up before giving up. */
|
||||||
|
private static final long STARTUP_TIMEOUT_SECONDS = 5;
|
||||||
|
|
||||||
|
private static final MethodHandle WND_PROC;
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
WND_PROC = MethodHandles.lookup().findStatic(WindowsInputHook.class, "onMessage",
|
||||||
|
MethodType.methodType(long.class, MemorySegment.class, int.class,
|
||||||
|
long.class, long.class));
|
||||||
|
} catch (ReflectiveOperationException e) {
|
||||||
|
throw new ExceptionInInitializerError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The running hook, for the static window procedure to find its way back. */
|
||||||
|
private static volatile WindowsInputHook current;
|
||||||
|
|
||||||
|
private volatile MemorySegment window = MemorySegment.NULL;
|
||||||
|
private Thread thread;
|
||||||
|
private volatile Listener listener;
|
||||||
|
private volatile boolean running;
|
||||||
|
private volatile String unavailable = "not started";
|
||||||
|
|
||||||
|
public static boolean isSupported() {
|
||||||
|
return Win32.isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
CountDownLatch ready = new CountDownLatch(1);
|
||||||
|
thread = new Thread(() -> pump(ready), "ts3j-hotkeys-rawinput");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
try {
|
||||||
|
if (!ready.await(STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
unavailable = "the raw input window did not come up";
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
unavailable = "interrupted while starting";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns the window for its whole life: creates it, pumps it, tears it down. */
|
||||||
|
private void pump(CountDownLatch ready) {
|
||||||
|
boolean signalled = false;
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
MemorySegment className = Win32.wide(arena, WINDOW_CLASS);
|
||||||
|
MemorySegment instance = Win32.moduleHandle();
|
||||||
|
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
|
||||||
|
.upcallStub(WND_PROC, Win32.WND_PROC, arena);
|
||||||
|
|
||||||
|
MemorySegment wndClass = arena.allocate(Win32.WNDCLASS_SIZE);
|
||||||
|
wndClass.set(ValueLayout.JAVA_INT, Win32.WNDCLASS_CBSIZE, (int) Win32.WNDCLASS_SIZE);
|
||||||
|
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_WNDPROC, stub);
|
||||||
|
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_HINSTANCE, instance);
|
||||||
|
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_CLASSNAME, className);
|
||||||
|
// A leftover registration from an earlier run in this process is harmless:
|
||||||
|
// CreateWindowEx only needs the class to exist.
|
||||||
|
Win32.registerClass(wndClass);
|
||||||
|
|
||||||
|
MemorySegment hwnd = Win32.createMessageWindow(className, instance);
|
||||||
|
if (hwnd.equals(MemorySegment.NULL)) {
|
||||||
|
unavailable = "cannot create the raw input window";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window = hwnd;
|
||||||
|
|
||||||
|
if (!Win32.registerRawInputDevices(rawInputDevices(arena, hwnd), 2)) {
|
||||||
|
unavailable = "RegisterRawInputDevices failed";
|
||||||
|
Win32.destroyWindow(hwnd);
|
||||||
|
window = MemorySegment.NULL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = this;
|
||||||
|
running = true;
|
||||||
|
unavailable = "";
|
||||||
|
ready.countDown();
|
||||||
|
signalled = true;
|
||||||
|
|
||||||
|
MemorySegment message = arena.allocate(Win32.MSG_SIZE);
|
||||||
|
while (running) {
|
||||||
|
int result = Win32.getMessage(message);
|
||||||
|
// 0 is WM_QUIT, -1 an error; either way the window is finished.
|
||||||
|
if (result <= 0) break;
|
||||||
|
Win32.dispatchMessage(message);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
unavailable = describe(t);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
window = MemorySegment.NULL;
|
||||||
|
if (current == this) current = null;
|
||||||
|
if (!signalled) ready.countDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keyboard and the mouse, both asked for in the background. */
|
||||||
|
private static MemorySegment rawInputDevices(Arena arena, MemorySegment hwnd) {
|
||||||
|
MemorySegment devices = arena.allocate(Win32.RAWINPUTDEVICE_SIZE * 2);
|
||||||
|
int[] usages = {Win32.USAGE_KEYBOARD, Win32.USAGE_MOUSE};
|
||||||
|
for (int i = 0; i < usages.length; i++) {
|
||||||
|
long base = i * Win32.RAWINPUTDEVICE_SIZE;
|
||||||
|
devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE_PAGE,
|
||||||
|
(short) Win32.USAGE_PAGE_GENERIC);
|
||||||
|
devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE,
|
||||||
|
(short) usages[i]);
|
||||||
|
devices.set(ValueLayout.JAVA_INT, base + Win32.RAWINPUTDEVICE_FLAGS,
|
||||||
|
Win32.RIDEV_INPUTSINK);
|
||||||
|
devices.set(ValueLayout.ADDRESS, base + Win32.RAWINPUTDEVICE_TARGET, hwnd);
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upcall target: the window procedure. */
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static long onMessage(MemorySegment hwnd, int message, long wParam, long lParam) {
|
||||||
|
WindowsInputHook hook = current;
|
||||||
|
try {
|
||||||
|
if (message == Win32.WM_INPUT && hook != null) {
|
||||||
|
hook.readInput(lParam);
|
||||||
|
} else if (message == Win32.WM_DESTROY) {
|
||||||
|
Win32.postQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
// WM_INPUT must reach DefWindowProc too, so the system can release the event.
|
||||||
|
return Win32.defWindowProc(hwnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies one {@code RAWINPUT} out of the system and turns it into hotkey events. */
|
||||||
|
private void readInput(long handle) {
|
||||||
|
Listener l = listener;
|
||||||
|
if (l == null) return;
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
MemorySegment buffer = arena.allocate(RawInput.BUFFER_SIZE);
|
||||||
|
MemorySegment size = arena.allocate(ValueLayout.JAVA_INT);
|
||||||
|
size.set(ValueLayout.JAVA_INT, 0, (int) RawInput.BUFFER_SIZE);
|
||||||
|
if (Win32.getRawInputData(MemorySegment.ofAddress(handle), buffer, size) <= 0) return;
|
||||||
|
|
||||||
|
switch (buffer.get(ValueLayout.JAVA_INT, RawInput.HEADER_TYPE)) {
|
||||||
|
case RawInput.TYPE_KEYBOARD -> {
|
||||||
|
int makeCode = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_MAKE_CODE) & 0xFFFF;
|
||||||
|
int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_FLAGS) & 0xFFFF;
|
||||||
|
int vkey = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_VKEY) & 0xFFFF;
|
||||||
|
int code = RawInput.scanCode(makeCode, flags, vkey);
|
||||||
|
if (code >= 0) l.onInput(HotkeyKey.keyboard(code), !RawInput.isRelease(flags));
|
||||||
|
}
|
||||||
|
case RawInput.TYPE_MOUSE -> {
|
||||||
|
int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS) & 0xFFFF;
|
||||||
|
short data = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS + 2);
|
||||||
|
for (RawInput.ButtonEvent e : RawInput.buttons(flags, data)) {
|
||||||
|
l.onInput(e.key(), e.pressed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String unavailableReason() {
|
||||||
|
return running ? "" : unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String keyName(HotkeyKey key) {
|
||||||
|
return WindowsKeyNamer.name(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
listener = null;
|
||||||
|
running = false;
|
||||||
|
MemorySegment hwnd = window;
|
||||||
|
try {
|
||||||
|
// Closing has to happen on the pump thread; ask it to, then let it unwind.
|
||||||
|
if (!hwnd.equals(MemorySegment.NULL)) Win32.postMessage(hwnd, Win32.WM_CLOSE, 0, 0);
|
||||||
|
if (thread != null) thread.join(2000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
thread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(Throwable t) {
|
||||||
|
String message = t.getMessage();
|
||||||
|
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names scan codes through the active Windows layout, so a hotkey button shows "A"
|
||||||
|
* rather than "Key 30" — the counterpart to {@link X11KeyNamer}.
|
||||||
|
*
|
||||||
|
* <p>{@code GetKeyNameTextW} wants the scan code where a {@code WM_KEYDOWN} would carry
|
||||||
|
* it: bits 16..23, with bit 24 marking the {@code E0} escape.
|
||||||
|
*/
|
||||||
|
final class WindowsKeyNamer {
|
||||||
|
|
||||||
|
private WindowsKeyNamer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<Integer, String> CACHE = new HashMap<>();
|
||||||
|
|
||||||
|
static synchronized String name(HotkeyKey key) {
|
||||||
|
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
|
||||||
|
if (!Win32.isAvailable()) return null;
|
||||||
|
return CACHE.computeIfAbsent(key.code(), code -> {
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
int lParam = (code & 0xFF) << 16;
|
||||||
|
if ((code & 0xE000) == 0xE000) lParam |= 1 << 24;
|
||||||
|
String name = Win32.keyName(lParam, arena);
|
||||||
|
return (name == null || name.isBlank()) ? null : name;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Decoding of Windows raw input, which needs no Windows to check. */
|
||||||
|
class RawInputTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainKeyKeepsItsScanCode() {
|
||||||
|
// 'A' is scan code 0x1E, no escape.
|
||||||
|
assertEquals(0x1E, RawInput.scanCode(0x1E, 0, 0x41));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void escapedKeysStayDistinctFromTheirUnescapedTwins() {
|
||||||
|
// Left Ctrl is 1D; right Ctrl is E0 1D, and the two must not collide.
|
||||||
|
int left = RawInput.scanCode(0x1D, 0, 0xA2);
|
||||||
|
int right = RawInput.scanCode(0x1D, RawInput.RI_KEY_E0, 0xA3);
|
||||||
|
assertEquals(0x1D, left);
|
||||||
|
assertEquals(0xE01D, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pauseUsesTheOtherEscape() {
|
||||||
|
assertEquals(0xE11D, RawInput.scanCode(0x1D, RawInput.RI_KEY_E1, 0x13));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fillerHalfOfAnEscapedSequenceIsIgnored() {
|
||||||
|
assertEquals(-1, RawInput.scanCode(0x2A, RawInput.RI_KEY_E0, 0xFF));
|
||||||
|
assertEquals(-1, RawInput.scanCode(0, 0, 0x41));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void breakFlagMarksTheRelease() {
|
||||||
|
assertFalse(RawInput.isRelease(0));
|
||||||
|
assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK));
|
||||||
|
assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK | RawInput.RI_KEY_E0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sideButtonsMapToTheNumbersUsersKnow() {
|
||||||
|
// X1 down: "Mouse 4" everywhere else in the client, X button 8.
|
||||||
|
List<RawInput.ButtonEvent> down = RawInput.buttons(0x0040, (short) 0);
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(8), true)), down);
|
||||||
|
assertEquals("Mouse 4", HotkeyKey.mouse(8).fallbackName());
|
||||||
|
|
||||||
|
List<RawInput.ButtonEvent> up = RawInput.buttons(0x0200, (short) 0);
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(9), false)), up);
|
||||||
|
assertEquals("Mouse 5", HotkeyKey.mouse(9).fallbackName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void primaryButtonsUseTheXNumbering() {
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), true)),
|
||||||
|
RawInput.buttons(0x0001, (short) 0));
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(2), true)),
|
||||||
|
RawInput.buttons(0x0010, (short) 0));
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)),
|
||||||
|
RawInput.buttons(0x0004, (short) 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneEventCanCarrySeveralTransitions() {
|
||||||
|
// Left up and right down in the same report.
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), false),
|
||||||
|
new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)),
|
||||||
|
RawInput.buttons(0x0002 | 0x0004, (short) 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wheelBecomesATapSoTheEngineSeesBothEdges() {
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(4), true),
|
||||||
|
new RawInput.ButtonEvent(HotkeyKey.mouse(4), false)),
|
||||||
|
RawInput.buttons(0x0400, (short) 120));
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(5), true),
|
||||||
|
new RawInput.ButtonEvent(HotkeyKey.mouse(5), false)),
|
||||||
|
RawInput.buttons(0x0400, (short) -120));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mouseMovementAloneReportsNothing() {
|
||||||
|
assertEquals(List.of(), RawInput.buttons(0, (short) 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user