Global hotkeys, in TeamSpeak's own shape

Bindings are captured system-wide rather than only while the window has
focus: X11's RECORD extension where the X server sees every key, and a
/dev/input reader as the Wayland fallback. Any key can act as a modifier,
mouse buttons included, as TS3 allows.

The action catalogue, its three categories and the "advanced actions"
split are reverse-engineered from the original client; actions this
client cannot perform are listed but greyed out. Push-to-talk becomes one
of these hotkeys, so the old focus-bound pushToTalkKey setting is gone and
the Voice Activation button edits that binding instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 17:08:28 +00:00
parent 3e2c06248f
commit 7e4b9671fe
25 changed files with 2858 additions and 55 deletions

View File

@@ -0,0 +1,79 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.util.ArrayList;
import java.util.List;
/**
* 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
* Wayland the X server sees only what is aimed at X clients, so reading evdev is the
* only way to catch every key — and when that is not permitted either, the returned
* hook is simply not running and says why.
*/
public final class DesktopInputHooks {
private DesktopInputHooks() {
}
/** @return a hook, started when it could be; check {@link GlobalInputHook#isRunning()} */
public static GlobalInputHook start(GlobalInputHook.Listener listener) {
List<String> reasons = new ArrayList<>();
for (GlobalInputHook hook : candidates()) {
hook.start(listener);
if (hook.isRunning()) return hook;
reasons.add(hook.getClass().getSimpleName() + ": " + hook.unavailableReason());
hook.close();
}
return new Unavailable(reasons.isEmpty()
? "no global input backend for this session"
: String.join("; ", reasons));
}
private static List<GlobalInputHook> candidates() {
List<GlobalInputHook> hooks = new ArrayList<>();
if (isWayland()) {
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
} else {
if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
}
return hooks;
}
private static boolean isWayland() {
return System.getenv("WAYLAND_DISPLAY") != null
|| "wayland".equalsIgnoreCase(System.getenv("XDG_SESSION_TYPE"));
}
/** Stands in when no backend could start, so callers need no null checks. */
private record Unavailable(String reason) implements GlobalInputHook {
@Override
public void start(Listener listener) {
}
@Override
public boolean isRunning() {
return false;
}
@Override
public String unavailableReason() {
return reason;
}
@Override
public String keyName(HotkeyKey key) {
return X11KeyNamer.name(key);
}
@Override
public void close() {
}
}
}

View File

@@ -0,0 +1,151 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.HotkeyKey;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
/**
* System-wide input hook that reads the kernel's evdev devices directly, for sessions
* where {@link XRecordInputHook} cannot see everything — Wayland above all, where the X
* server is only told about events aimed at X clients.
*
* <p>It needs read access to {@code /dev/input/event*}, which normally means membership
* of the {@code input} group; without it the hook reports itself unavailable and the
* client carries on without global hotkeys.
*
* <p>Key codes are reported as X keycodes (the kernel code plus 8) and buttons as X
* button numbers, so bindings mean the same thing whichever backend recorded them.
*/
public final class EvdevInputHook implements GlobalInputHook {
private static final File INPUT_DIR = new File("/dev/input");
/** {@code struct input_event} on 64-bit Linux: two 8-byte time fields, then type/code/value. */
private static final int EVENT_SIZE = 24;
private static final int EV_KEY = 1;
/** Kernel key codes below this are keyboard keys; from here up they are BTN_* buttons. */
private static final int BTN_MISC = 0x100;
private static final int BTN_LEFT = 0x110;
private final List<InputStream> streams = new ArrayList<>();
private final List<Thread> threads = new ArrayList<>();
private volatile Listener listener;
private volatile boolean running;
private volatile String unavailable = "not started";
public static boolean isSupported() {
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
if (devices == null) return false;
for (File device : devices) {
if (device.canRead()) return true;
}
return false;
}
@Override
public void start(Listener listener) {
this.listener = listener;
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
if (devices == null || devices.length == 0) {
unavailable = "no /dev/input devices";
return;
}
for (File device : devices) {
try {
InputStream in = new FileInputStream(device);
streams.add(in);
Thread t = new Thread(() -> read(in), "ts3j-hotkeys-evdev-" + device.getName());
t.setDaemon(true);
threads.add(t);
} catch (Exception ignored) {
// Devices we may not read are simply skipped.
}
}
if (streams.isEmpty()) {
unavailable = "no readable /dev/input device — add your user to the \"input\" group";
return;
}
running = true;
unavailable = "";
for (Thread t : threads) t.start();
}
private void read(InputStream in) {
byte[] buffer = new byte[EVENT_SIZE * 16];
ByteBuffer view = ByteBuffer.wrap(buffer).order(ByteOrder.nativeOrder());
while (running) {
try {
int read = in.read(buffer);
if (read < 0) return;
for (int offset = 0; offset + EVENT_SIZE <= read; offset += EVENT_SIZE) {
int type = view.getShort(offset + 16) & 0xffff;
int code = view.getShort(offset + 18) & 0xffff;
int value = view.getInt(offset + 20);
// 2 is auto-repeat, which must not look like a fresh press.
if (type == EV_KEY && value != 2) dispatch(code, value == 1);
}
} catch (Exception e) {
return;
}
}
}
private void dispatch(int code, boolean pressed) {
Listener l = listener;
if (l == null) return;
HotkeyKey key = code < BTN_MISC ? HotkeyKey.keyboard(code + 8) : mouse(code);
if (key != null) l.onInput(key, pressed);
}
/** Maps the kernel's BTN_* codes onto the X button numbering. */
private static HotkeyKey mouse(int code) {
return switch (code) {
case BTN_LEFT -> HotkeyKey.mouse(1);
case BTN_LEFT + 1 -> HotkeyKey.mouse(3); // BTN_RIGHT
case BTN_LEFT + 2 -> HotkeyKey.mouse(2); // BTN_MIDDLE
case BTN_LEFT + 3 -> HotkeyKey.mouse(8); // BTN_SIDE, "mouse 4"
case BTN_LEFT + 4 -> HotkeyKey.mouse(9); // BTN_EXTRA, "mouse 5"
case BTN_LEFT + 5 -> HotkeyKey.mouse(10); // BTN_FORWARD
case BTN_LEFT + 6 -> HotkeyKey.mouse(11); // BTN_BACK
case BTN_LEFT + 7 -> HotkeyKey.mouse(12); // BTN_TASK
default -> null; // joysticks, lid switches, …
};
}
@Override
public boolean isRunning() {
return running;
}
@Override
public String unavailableReason() {
return running ? "" : unavailable;
}
@Override
public String keyName(HotkeyKey key) {
// The kernel knows scancodes, not layouts; let X name the key when it can.
return X11KeyNamer.name(key);
}
@Override
public void close() {
running = false;
listener = null;
// Closing the descriptor is what breaks the readers out of their blocking read.
for (InputStream in : streams) {
try {
in.close();
} catch (Exception ignored) {
}
}
streams.clear();
threads.clear();
}
}

View File

@@ -0,0 +1,47 @@
package com.ts3client.hotkey.desktop;
import java.util.Map;
/** Turns X keysym names into the labels users expect on a hotkey button. */
final class KeyNames {
private KeyNames() {
}
private static final Map<String, String> SPECIAL = Map.ofEntries(
Map.entry("Control_L", "Left Ctrl"),
Map.entry("Control_R", "Right Ctrl"),
Map.entry("Shift_L", "Left Shift"),
Map.entry("Shift_R", "Right Shift"),
Map.entry("Alt_L", "Left Alt"),
Map.entry("Alt_R", "Right Alt"),
Map.entry("ISO_Level3_Shift", "AltGr"),
Map.entry("Super_L", "Left Super"),
Map.entry("Super_R", "Right Super"),
Map.entry("Meta_L", "Left Meta"),
Map.entry("Meta_R", "Right Meta"),
Map.entry("Prior", "Page Up"),
Map.entry("Next", "Page Down"),
Map.entry("Return", "Enter"),
Map.entry("space", "Space"),
Map.entry("BackSpace", "Backspace"),
Map.entry("Escape", "Esc"),
Map.entry("Caps_Lock", "Caps Lock"),
Map.entry("Num_Lock", "Num Lock"),
Map.entry("Scroll_Lock", "Scroll Lock"),
Map.entry("Menu", "Menu"),
Map.entry("Print", "Print Screen"));
/**
* @param keysym the X name of the keysym, e.g. {@code a}, {@code Control_L}, {@code KP_Add}
* @return a display name, or {@code null} when there is nothing sensible to show
*/
static String pretty(String keysym) {
if (keysym == null || keysym.isBlank()) return null;
String special = SPECIAL.get(keysym);
if (special != null) return special;
if (keysym.startsWith("KP_")) return "Numpad " + pretty(keysym.substring(3));
if (keysym.length() == 1) return keysym.toUpperCase();
return keysym.replace('_', ' ');
}
}

View File

@@ -0,0 +1,47 @@
package com.ts3client.hotkey.desktop;
import com.ts3client.hotkey.HotkeyKey;
import java.lang.foreign.MemorySegment;
import java.util.HashMap;
import java.util.Map;
/**
* Names keycodes using the X keyboard layout, so a hotkey button shows "A" rather than
* "Key 38". Bindings themselves stay layout-independent; only the label comes from here.
*
* <p>Keeps one long-lived display connection of its own, opened on first use, so it can
* be asked from any thread without racing the input hooks' own connections.
*/
final class X11KeyNamer {
private X11KeyNamer() {
}
private static final Map<Integer, String> CACHE = new HashMap<>();
private static MemorySegment display;
private static boolean tried;
static synchronized String name(HotkeyKey key) {
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
if (!tried) {
tried = true;
try {
if (Xlib.isCoreAvailable() && System.getenv("DISPLAY") != null) {
display = Xlib.openDisplay();
}
} catch (Throwable ignored) {
display = null;
}
}
if (display == null || display.equals(MemorySegment.NULL)) return null;
return CACHE.computeIfAbsent(key.code(), code -> {
try {
long sym = Xlib.keysym(display, code);
return sym == 0 ? null : KeyNames.pretty(Xlib.keysymName(sym));
} catch (Throwable t) {
return null;
}
});
}
}

View File

@@ -0,0 +1,212 @@
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;
/**
* System-wide input hook built on X11's RECORD extension: it taps the core key and
* button events the server delivers to every client, so hotkeys work no matter which
* window has focus, and the keystroke still reaches that window untouched.
*
* <p>Two connections are needed, as RECORD demands: a control one that creates and
* later tears the context down, and a data one that blocks inside
* {@code XRecordEnableContext} handing us events.
*
* <p>Under Wayland the X server only ever sees events aimed at X clients, so this hook
* is not truly global there; {@link EvdevInputHook} is the way out.
*/
public final class XRecordInputHook implements GlobalInputHook {
private static final MethodHandle CALLBACK;
static {
try {
CALLBACK = MethodHandles.lookup().findStatic(XRecordInputHook.class, "onRecorded",
MethodType.methodType(void.class, MemorySegment.class, MemorySegment.class));
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
/** The running hook, for the static upcall to find its way back. */
private static volatile XRecordInputHook current;
private Arena arena;
private MemorySegment control = MemorySegment.NULL;
private MemorySegment data = MemorySegment.NULL;
private long context;
private Thread thread;
private volatile Listener listener;
private volatile boolean running;
private volatile String unavailable = "not started";
public static boolean isSupported() {
return Xlib.isAvailable() && System.getenv("DISPLAY") != null;
}
@Override
public void start(Listener listener) {
this.listener = listener;
try {
open();
} catch (Throwable t) {
unavailable = describe(t);
closeQuietly();
}
}
private void open() {
arena = Arena.ofShared();
control = Xlib.openDisplay();
if (control.equals(MemorySegment.NULL)) {
throw new IllegalStateException("cannot open the X display");
}
if (!Xlib.queryRecordVersion(control, arena)) {
throw new IllegalStateException("the X server has no RECORD extension");
}
MemorySegment range = Xlib.allocRange();
if (range.equals(MemorySegment.NULL)) throw new IllegalStateException("XRecordAllocRange failed");
MemorySegment r = range.reinterpret(64);
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_FIRST, (byte) Xlib.KEY_PRESS);
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_LAST, (byte) Xlib.BUTTON_RELEASE);
MemorySegment clients = arena.allocate(ValueLayout.JAVA_LONG);
clients.set(ValueLayout.JAVA_LONG, 0, Xlib.ALL_CLIENTS);
MemorySegment ranges = arena.allocate(ValueLayout.ADDRESS);
ranges.set(ValueLayout.ADDRESS, 0, range);
context = Xlib.createContext(control, clients, 1, ranges, 1);
if (context == 0) throw new IllegalStateException("XRecordCreateContext failed");
// The context id is allocated client-side and the request is not a round trip:
// without this the second connection can enable a context the server has yet to
// create, which it answers with BadContext.
Xlib.sync(control);
data = Xlib.openDisplay();
if (data.equals(MemorySegment.NULL)) {
throw new IllegalStateException("cannot open the second X display connection");
}
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
.upcallStub(CALLBACK, Xlib.INTERCEPT_PROC, arena);
current = this;
running = true;
unavailable = "";
thread = new Thread(() -> {
try {
// Blocks until close() disables the context from the control connection.
Xlib.enableContext(data, context, stub);
} catch (Throwable ignored) {
} finally {
running = false;
}
}, "ts3j-hotkeys-xrecord");
thread.setDaemon(true);
thread.start();
}
/** Upcall target: one recorded protocol datum. */
@SuppressWarnings("unused")
private static void onRecorded(MemorySegment closure, MemorySegment recorded) {
XRecordInputHook hook = current;
MemorySegment d = recorded.reinterpret(48);
try {
if (hook == null || d.get(ValueLayout.JAVA_INT, Xlib.INTERCEPT_CATEGORY) != Xlib.FROM_SERVER) {
return;
}
long length = d.get(ValueLayout.JAVA_LONG, Xlib.INTERCEPT_DATA_LEN);
MemorySegment event = d.get(ValueLayout.ADDRESS, Xlib.INTERCEPT_DATA);
// data_len counts 4-byte units; a core event is always 32 bytes.
if (event.equals(MemorySegment.NULL) || length < 2) return;
event = event.reinterpret(32);
hook.dispatch(event.get(ValueLayout.JAVA_BYTE, 0) & 0x7f,
event.get(ValueLayout.JAVA_BYTE, 1) & 0xff);
} catch (Throwable ignored) {
} finally {
Xlib.freeData(recorded);
}
}
private void dispatch(int type, int detail) {
Listener l = listener;
if (l == null) return;
switch (type) {
case Xlib.KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true);
case Xlib.KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false);
case Xlib.BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true);
case Xlib.BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false);
default -> {
}
}
}
@Override
public boolean isRunning() {
return running;
}
@Override
public String unavailableReason() {
return running ? "" : unavailable;
}
@Override
public String keyName(HotkeyKey key) {
return X11KeyNamer.name(key);
}
@Override
public void close() {
listener = null;
running = false;
try {
if (!control.equals(MemorySegment.NULL) && context != 0) {
Xlib.disableContext(control, context);
Xlib.flush(control);
}
if (thread != null) thread.join(1000);
} catch (Throwable ignored) {
}
closeQuietly();
}
private synchronized void closeQuietly() {
if (current == this) current = null;
try {
if (!control.equals(MemorySegment.NULL) && context != 0) Xlib.freeContext(control, context);
} catch (Throwable ignored) {
}
context = 0;
for (MemorySegment display : new MemorySegment[]{data, control}) {
try {
if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display);
} catch (Throwable ignored) {
}
}
data = control = MemorySegment.NULL;
// The arena owns the upcall stub: freeing it while the recording thread could
// still call into it would take the JVM down, so a stuck thread keeps it alive.
if (arena != null && (thread == null || !thread.isAlive())) {
try {
arena.close();
} catch (Throwable ignored) {
}
arena = null;
}
thread = null;
}
private static String describe(Throwable t) {
String message = t.getMessage();
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
}
}

View File

@@ -0,0 +1,207 @@
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 libX11} and the RECORD extension in {@code libXtst}, with just
* the calls {@link XRecordInputHook} needs.
*
* <p>RECORD lets a client watch the core input events the server delivers to everyone
* else without intercepting them, which is exactly what a global hotkey needs: the
* keystroke still reaches the focused application.
*
* <p>Loading is lazy and failure is expected — with no X11 around, {@link #isAvailable()}
* returns {@code false} and the caller falls back to another backend.
*/
final class Xlib {
private Xlib() {
}
static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
/** Core event types (X.h) as they appear in the recorded protocol data. */
static final int KEY_PRESS = 2;
static final int KEY_RELEASE = 3;
static final int BUTTON_PRESS = 4;
static final int BUTTON_RELEASE = 5;
/** {@code XRecordAllClients}: record every client, present and future. */
static final long ALL_CLIENTS = 3L;
/** {@code XRecordInterceptData.category} values we care about. */
static final int FROM_SERVER = 0;
/**
* {@code XRecordRange}: only {@code device_events} is filled in. Its two bytes sit
* after the request/reply and delivered-event ranges, at a fixed LP64 offset.
*/
static final long RANGE_DEVICE_EVENTS_FIRST = 18;
static final long RANGE_DEVICE_EVENTS_LAST = 19;
/** {@code XRecordInterceptData}: the protocol bytes and the reason we were called. */
static final long INTERCEPT_CATEGORY = 24;
static final long INTERCEPT_DATA = 32;
static final long INTERCEPT_DATA_LEN = 40;
private static final Linker LINKER = Linker.nativeLinker();
private static final String[] X11_NAMES = {"libX11.so.6", "libX11.so"};
private static final String[] XTST_NAMES = {"libXtst.so.6", "libXtst.so"};
/** libX11 alone: enough to open a display and name keys. */
private static final class Core {
static final SymbolLookup LIB = load(X11_NAMES, "libX11");
static final MethodHandle X_OPEN_DISPLAY =
downcall(LIB, "XOpenDisplay", FunctionDescriptor.of(PTR, PTR));
static final MethodHandle X_CLOSE_DISPLAY =
downcall(LIB, "XCloseDisplay", FunctionDescriptor.of(INT, PTR));
static final MethodHandle X_FLUSH =
downcall(LIB, "XFlush", FunctionDescriptor.of(INT, PTR));
static final MethodHandle X_SYNC =
downcall(LIB, "XSync", FunctionDescriptor.of(INT, PTR, INT));
static final MethodHandle XKB_KEYCODE_TO_KEYSYM =
downcall(LIB, "XkbKeycodeToKeysym", FunctionDescriptor.of(LONG, PTR, INT, INT, INT));
static final MethodHandle X_KEYSYM_TO_STRING =
downcall(LIB, "XKeysymToString", FunctionDescriptor.of(PTR, LONG));
}
/** The RECORD extension, which ships separately in libXtst. */
private static final class Record {
static final SymbolLookup LIB = load(XTST_NAMES, "libXtst");
static final MethodHandle QUERY_VERSION =
downcall(LIB, "XRecordQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR));
static final MethodHandle ALLOC_RANGE =
downcall(LIB, "XRecordAllocRange", FunctionDescriptor.of(PTR));
static final MethodHandle CREATE_CONTEXT =
downcall(LIB, "XRecordCreateContext",
FunctionDescriptor.of(LONG, PTR, INT, PTR, INT, PTR, INT));
static final MethodHandle ENABLE_CONTEXT =
downcall(LIB, "XRecordEnableContext", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR));
static final MethodHandle DISABLE_CONTEXT =
downcall(LIB, "XRecordDisableContext", FunctionDescriptor.of(INT, PTR, LONG));
static final MethodHandle FREE_CONTEXT =
downcall(LIB, "XRecordFreeContext", FunctionDescriptor.of(INT, PTR, LONG));
static final MethodHandle FREE_DATA =
downcall(LIB, "XRecordFreeData", FunctionDescriptor.ofVoid(PTR));
}
private static SymbolLookup load(String[] names, String what) {
IllegalArgumentException last = null;
for (String name : names) {
try {
return SymbolLookup.libraryLookup(name, Arena.global());
} catch (IllegalArgumentException e) {
last = e;
}
}
throw (last != null) ? last : new IllegalArgumentException(what + " not found");
}
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
FunctionDescriptor descriptor) {
return LINKER.downcallHandle(
lookup.find(symbol).orElseThrow(() ->
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
descriptor);
}
/** The C signature of {@code XRecordInterceptProc}. */
static final FunctionDescriptor INTERCEPT_PROC = FunctionDescriptor.ofVoid(PTR, PTR);
/** Whether libX11 loaded, which is all that naming keys needs. */
static boolean isCoreAvailable() {
try {
return Core.LIB != null;
} catch (Throwable t) {
return false;
}
}
static boolean isAvailable() {
try {
return Core.LIB != null && Record.LIB != null;
} catch (Throwable t) {
return false;
}
}
static MemorySegment openDisplay() {
return (MemorySegment) call(Core.X_OPEN_DISPLAY, MemorySegment.NULL);
}
static void closeDisplay(MemorySegment display) {
call(Core.X_CLOSE_DISPLAY, display);
}
static void flush(MemorySegment display) {
call(Core.X_FLUSH, display);
}
/** Flushes and waits for the server to have processed everything sent so far. */
static void sync(MemorySegment display) {
call(Core.X_SYNC, display, 0);
}
/** @return whether the server has the RECORD extension */
static boolean queryRecordVersion(MemorySegment display, Arena arena) {
MemorySegment major = arena.allocate(INT);
MemorySegment minor = arena.allocate(INT);
return (int) call(Record.QUERY_VERSION, display, major, minor) != 0;
}
static MemorySegment allocRange() {
return (MemorySegment) call(Record.ALLOC_RANGE);
}
static long createContext(MemorySegment display, MemorySegment clients, int clientCount,
MemorySegment ranges, int rangeCount) {
return (long) call(Record.CREATE_CONTEXT, display, 0, clients, clientCount, ranges, rangeCount);
}
static int enableContext(MemorySegment display, long context, MemorySegment callback) {
return (int) call(Record.ENABLE_CONTEXT, display, context, callback, MemorySegment.NULL);
}
static void disableContext(MemorySegment display, long context) {
call(Record.DISABLE_CONTEXT, display, context);
}
static void freeContext(MemorySegment display, long context) {
call(Record.FREE_CONTEXT, display, context);
}
static void freeData(MemorySegment data) {
call(Record.FREE_DATA, data);
}
/** The unshifted keysym of a keycode in the first group, or 0 when unbound. */
static long keysym(MemorySegment display, int keycode) {
return (long) call(Core.XKB_KEYCODE_TO_KEYSYM, display, keycode, 0, 0);
}
/** The keysym's X name ("a", "Control_L", "F5"), or {@code null}. */
static String keysymName(long keysym) {
MemorySegment name = (MemorySegment) call(Core.X_KEYSYM_TO_STRING, keysym);
if (name == null || name.equals(MemorySegment.NULL)) return null;
return name.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.US_ASCII);
}
private static Object call(MethodHandle handle, Object... args) {
try {
return handle.invokeWithArguments(args);
} catch (Throwable t) {
throw new IllegalStateException("X11 call failed", t);
}
}
}