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,120 @@
package com.ts3client.hotkey;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HotkeyEngineTest {
private static final HotkeyKey CTRL = HotkeyKey.keyboard(37);
private static final HotkeyKey K = HotkeyKey.keyboard(45);
private static final HotkeyKey MOUSE4 = HotkeyKey.mouse(8);
private final List<String> fired = new ArrayList<>();
private final Hotkeys hotkeys = new Hotkeys();
private final HotkeyEngine engine = new HotkeyEngine(hotkeys,
(hotkey, active) -> fired.add(hotkey.action + (active ? ":on" : ":off")));
private Hotkey bind(HotkeyAction action, Hotkey.Trigger trigger, HotkeyKey... keys) {
Hotkey h = new Hotkey(action, new HotkeyCombo(List.of(keys)));
h.trigger = trigger;
hotkeys.add(h);
return h;
}
@Test
void firesOnKeyDownOnlyWhenEveryKeyIsHeld() {
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, CTRL, K);
engine.onInput(K, true);
assertTrue(fired.isEmpty(), "one key of the combination is not enough");
engine.onInput(CTRL, true);
assertEquals(List.of("MIC_TOGGLE:on"), fired);
// Releasing and re-pressing arms it again, but only once per completion.
engine.onInput(CTRL, false);
engine.onInput(CTRL, true);
assertEquals(List.of("MIC_TOGGLE:on", "MIC_TOGGLE:on"), fired);
}
@Test
void keyUpTriggerFiresOnRelease() {
bind(HotkeyAction.SPEAKER_TOGGLE, Hotkey.Trigger.KEY_UP, MOUSE4);
engine.onInput(MOUSE4, true);
assertTrue(fired.isEmpty());
engine.onInput(MOUSE4, false);
assertEquals(List.of("SPEAKER_TOGGLE:on"), fired);
}
@Test
void momentaryActionReportsPressAndRelease() {
bind(HotkeyAction.PTT_ACTIVATE, Hotkey.Trigger.KEY_UP, MOUSE4);
engine.onInput(MOUSE4, true);
engine.onInput(MOUSE4, false);
// The trigger setting does not apply: push-to-talk always spans the hold.
assertEquals(List.of("PTT_ACTIVATE:on", "PTT_ACTIVATE:off"), fired);
}
@Test
void disabledAndUnsupportedBindingsStaySilent() {
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K).enabled = false;
bind(HotkeyAction.RECORDING_START, Hotkey.Trigger.KEY_DOWN, MOUSE4);
engine.onInput(K, true);
engine.onInput(MOUSE4, true);
assertTrue(fired.isEmpty());
}
@Test
void recordingCapturesTheCombinationInsteadOfFiring() {
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K);
List<HotkeyCombo> recorded = new ArrayList<>();
engine.record(new HotkeyEngine.Recorder() {
@Override
public void onRecording(HotkeyCombo combo) {
}
@Override
public void onRecorded(HotkeyCombo combo) {
recorded.add(combo);
}
});
engine.onInput(CTRL, true);
engine.onInput(K, true);
engine.onInput(K, false);
engine.onInput(CTRL, false);
assertTrue(fired.isEmpty(), "bindings must not fire while recording");
assertEquals(1, recorded.size());
assertEquals(List.of(CTRL, K), recorded.get(0).keys());
}
@Test
void actionsCarryTeamSpeaksTreeGrouping() {
assertEquals("Sounds", HotkeyAction.SOUNDPACK_ACTIVATE.group());
assertEquals("Microphone", HotkeyAction.MIC_TOGGLE.group());
// TS3 leaves a handful of actions ungrouped, listed straight under their category.
assertEquals("", HotkeyAction.NICKNAME_CHANGE.group());
assertEquals("", HotkeyAction.BRING_TO_FRONT.group());
Hotkey pack = new Hotkey(HotkeyAction.SOUNDPACK_ACTIVATE, new HotkeyCombo(List.of(K)));
pack.argument = "Default Sound Pack (Male)";
assertEquals("Sounds / Activate Soundpack / Default Sound Pack (Male)", pack.path());
assertEquals("Bring Client to Front",
new Hotkey(HotkeyAction.BRING_TO_FRONT, new HotkeyCombo(List.of(K))).path());
}
@Test
void combinationsSurviveARoundTripThroughText() {
HotkeyCombo combo = new HotkeyCombo(List.of(CTRL, MOUSE4));
assertEquals("Keyboard:37+Mouse:8", combo.toString());
assertEquals(combo, HotkeyCombo.parse(combo.toString()));
}
}