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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user