Three input methods for the gimbal, all sharing the widget's velocity model: hold to move, release to stop. Keyboard in the applet. WASD and the arrow keys feed the same tick timer the pointer does, through a new vectorFromKeys helper. Opposing keys cancel and a diagonal is normalised to length 1, so two keys do not move faster than one. Auto-repeat is discarded, because it arrives as a press/release cycle and would make the motion stutter. The held keys are cleared when the popup loses focus: otherwise the matching release never arrives and the camera keeps panning on its own. The buttons and zoom slider are set to Qt.NoFocus so the arrow keys still reach the pad after one of them is clicked. Gamepad support, as a separate process. Qt 6 removed QtGamepad and ships no replacement, so the applet cannot read a controller itself. bin/linkctl-gamepad reads /dev/input/js* directly using only the standard library: the event struct is eight bytes and needs no third-party package. Defaults target a DualSense on hid_playstation, but axis and button numbering comes from the driver rather than the controller and varies between kernels and connection modes, so --monitor prints live events for mapping and every number is overridable. Opening a joystick replays the current state of every button with JS_EVENT_INIT set; those are filtered, or a held button would fire its mapped action at startup. --wait reconnects a wireless pad that sleeps. Directional global shortcuts. Meta+Alt plus the arrow keys nudge the gimbal. These are discrete steps by necessity: kglobalaccel fires once per keypress and exposes no press-and-hold, so this cannot be smooth motion and the README says so and points at the widget or a gamepad instead. NUDGE sets the degrees. Tested: 145 checks across five suites, all passing, shellcheck clean, QML free of syntax errors. 13 new node checks cover the key vector, including the cancellation and diagonal-normalisation cases. 36 new python checks cover event decoding, the INIT flag, deadzone rescaling and command construction. A new FIFO-driven suite stands in for /dev/input/js* and plays scripted events through the real select/read/decode/tick path, confirming diagonals emit both axes, a centred stick goes quiet, a deadzone deflection moves nothing, a real button press fires, the initial-state burst does not, and disconnect is handled. Not verified: no physical controller, camera or Plasma session was available, so the synthetic events cannot confirm a real DualSense reports the assumed axis and button numbers.
145 lines
4.3 KiB
JavaScript
145 lines
4.3 KiB
JavaScript
/*
|
|
* Pure helpers for the joystick applet.
|
|
*
|
|
* Kept free of QML types so they can be unit-tested with node; see
|
|
* tests/test_joystick_logic.js. `.pragma library` means this file holds no
|
|
* per-instance state and cannot touch the QML context, which is exactly the
|
|
* constraint that keeps it testable.
|
|
*/
|
|
.pragma library
|
|
|
|
// Quote a value for /bin/sh. Config values (binary path, device, preset name)
|
|
// all reach a shell, so this is a boundary that has to hold.
|
|
function shq(s) {
|
|
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
}
|
|
|
|
// The invocation prefix shared by every command the applet runs.
|
|
function baseCmd(linkctl, device) {
|
|
var c = shq(linkctl || "linkctl") + " --force --json";
|
|
if (device && device !== "") {
|
|
c += " --device " + shq(device);
|
|
}
|
|
return c;
|
|
}
|
|
|
|
/*
|
|
* Build the shell command for one joystick tick.
|
|
*
|
|
* nx/ny are the normalised stick position, ny positive upwards. Returns ""
|
|
* when the deflection on both axes is too small to be worth a command, so the
|
|
* caller can skip the tick entirely.
|
|
*
|
|
* Each axis needs its own linkctl call because the relative commands are
|
|
* single-axis, so a diagonal push produces two, chained with ";".
|
|
*/
|
|
function tickCommand(base, nx, ny, stepMax, invertPan, invertTilt) {
|
|
var x = invertPan ? -nx : nx;
|
|
var y = invertTilt ? -ny : ny;
|
|
var dx = Math.abs(x) * stepMax;
|
|
var dy = Math.abs(y) * stepMax;
|
|
var parts = [];
|
|
// Per-axis threshold, so a mostly-horizontal push does not also creep
|
|
// vertically. Below 0.5 degrees the camera's 0.1 degree resolution makes
|
|
// the step pointless anyway.
|
|
if (dx >= 0.5) {
|
|
parts.push(base + (x > 0 ? " right " : " left ") + dx.toFixed(1));
|
|
}
|
|
if (dy >= 0.5) {
|
|
parts.push(base + (y > 0 ? " up " : " down ") + dy.toFixed(1));
|
|
}
|
|
return parts.join(" ; ");
|
|
}
|
|
|
|
/*
|
|
* Turn a set of held direction keys into a stick vector.
|
|
*
|
|
* `held` has boolean left/right/up/down members. Opposing keys cancel, and a
|
|
* diagonal is normalised to length 1 so holding two keys does not move the
|
|
* camera faster than holding one.
|
|
*
|
|
* Key codes are deliberately not mapped here: this file is a `.pragma
|
|
* library` and so cannot reach the Qt namespace for Qt.Key_*. The QML side
|
|
* resolves a key code to a direction name and passes the result in.
|
|
*/
|
|
function vectorFromKeys(held) {
|
|
var x = 0;
|
|
var y = 0;
|
|
if (held.left) {
|
|
x -= 1;
|
|
}
|
|
if (held.right) {
|
|
x += 1;
|
|
}
|
|
if (held.up) {
|
|
y += 1;
|
|
}
|
|
if (held.down) {
|
|
y -= 1;
|
|
}
|
|
var len = Math.sqrt(x * x + y * y);
|
|
if (len > 1) {
|
|
x /= len;
|
|
y /= len;
|
|
}
|
|
return { nx: x, ny: y };
|
|
}
|
|
|
|
/*
|
|
* Merge every JSON object found in linkctl's stdout into a flat update.
|
|
*
|
|
* A diagonal tick runs two linkctl calls, and each reports only the axis it
|
|
* changed, so the results have to be merged rather than replaced. Only keys
|
|
* actually present with the right type are returned, which keeps a null from
|
|
* an unrelated field from clobbering a known value.
|
|
*/
|
|
function absorb(stdout) {
|
|
var update = {};
|
|
var lines = String(stdout).split("\n");
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var t = lines[i].trim();
|
|
if (t.length === 0 || t.charAt(0) !== "{") {
|
|
continue;
|
|
}
|
|
var o;
|
|
try {
|
|
o = JSON.parse(t);
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
if (typeof o.pan === "number") {
|
|
update.pan = o.pan;
|
|
}
|
|
if (typeof o.tilt === "number") {
|
|
update.tilt = o.tilt;
|
|
}
|
|
if (typeof o.zoom === "number") {
|
|
update.zoom = o.zoom;
|
|
}
|
|
if (typeof o.tracking === "boolean") {
|
|
update.tracking = o.tracking;
|
|
}
|
|
if (typeof o.state === "string") {
|
|
update.state = o.state;
|
|
}
|
|
}
|
|
return update;
|
|
}
|
|
|
|
// Pull a human-readable reason out of a failed linkctl run.
|
|
function errorMessage(stdout, stderr, exitCode) {
|
|
var err = String(stderr || "").trim();
|
|
try {
|
|
var o = JSON.parse(String(stdout || "").trim());
|
|
if (o && o.error) {
|
|
return String(o.error);
|
|
}
|
|
} catch (e) {
|
|
// not JSON, fall through to stderr
|
|
}
|
|
if (err !== "") {
|
|
return err;
|
|
}
|
|
return "linkctl exited " + exitCode;
|
|
}
|