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.
339 lines
11 KiB
QML
339 lines
11 KiB
QML
/*
|
|
* Insta360 Link Joystick - a Plasma 6 applet.
|
|
*
|
|
* The joystick is a velocity control: displacement sets a rate, and a timer
|
|
* issues relative pan/tilt steps while the stick is held off-centre.
|
|
*
|
|
* It uses relative moves (`linkctl left/right/up/down`) rather than absolute
|
|
* ones on purpose. `linkctl status` reports no pan/tilt at all while the
|
|
* camera is idle, so there is no reliable position to drive an absolute
|
|
* target from, and relative steps cannot drift out of sync with the camera.
|
|
* linkctl clamps each step to the device's own range, so holding the stick at
|
|
* a limit is harmless.
|
|
*/
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import org.kde.plasma.plasmoid
|
|
import org.kde.plasma.components as PlasmaComponents
|
|
import org.kde.plasma.plasma5support as Plasma5Support
|
|
import org.kde.kirigami as Kirigami
|
|
import "logic.js" as Logic
|
|
|
|
PlasmoidItem {
|
|
id: root
|
|
|
|
readonly property string linkctl: Plasmoid.configuration.linkctlPath || "linkctl"
|
|
readonly property string device: Plasmoid.configuration.device
|
|
readonly property int stepMax: Plasmoid.configuration.maxStep
|
|
readonly property int tickMs: Plasmoid.configuration.tickInterval
|
|
readonly property bool invertPan: Plasmoid.configuration.invertPan
|
|
readonly property bool invertTilt: Plasmoid.configuration.invertTilt
|
|
|
|
property real panDeg: NaN
|
|
property real tiltDeg: NaN
|
|
property real zoomFactor: 1.0
|
|
property int trackingState: -1 // -1 unknown, 0 off, 1 on
|
|
property string statusText: "Not queried yet"
|
|
property bool busy: false
|
|
|
|
// Held movement keys. Separate booleans rather than one object, because
|
|
// mutating a member of a `var` object does not re-evaluate bindings.
|
|
property bool keyLeft: false
|
|
property bool keyRight: false
|
|
property bool keyUp: false
|
|
property bool keyDown: false
|
|
|
|
readonly property var keyVec: Logic.vectorFromKeys({
|
|
left: root.keyLeft, right: root.keyRight,
|
|
up: root.keyUp, down: root.keyDown
|
|
})
|
|
readonly property bool keyEngaged: root.keyVec.nx !== 0 || root.keyVec.ny !== 0
|
|
|
|
// The pointer wins while it is down, so a drag is never fought over by a
|
|
// key left held down underneath it.
|
|
readonly property real inNx: stick.held ? stick.nx : root.keyVec.nx
|
|
readonly property real inNy: stick.held ? stick.ny : root.keyVec.ny
|
|
readonly property bool inEngaged: stick.engaged || root.keyEngaged
|
|
|
|
// WASD and the arrow keys. Returns "" for anything that is not a
|
|
// movement key, so the handler can leave it unaccepted.
|
|
function dirForKey(key) {
|
|
switch (key) {
|
|
case Qt.Key_A:
|
|
case Qt.Key_Left:
|
|
return "left";
|
|
case Qt.Key_D:
|
|
case Qt.Key_Right:
|
|
return "right";
|
|
case Qt.Key_W:
|
|
case Qt.Key_Up:
|
|
return "up";
|
|
case Qt.Key_S:
|
|
case Qt.Key_Down:
|
|
return "down";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function setKey(dir, down) {
|
|
switch (dir) {
|
|
case "left": root.keyLeft = down; break;
|
|
case "right": root.keyRight = down; break;
|
|
case "up": root.keyUp = down; break;
|
|
case "down": root.keyDown = down; break;
|
|
}
|
|
}
|
|
|
|
function clearKeys() {
|
|
root.keyLeft = false;
|
|
root.keyRight = false;
|
|
root.keyUp = false;
|
|
root.keyDown = false;
|
|
}
|
|
|
|
function baseCmd() {
|
|
return Logic.baseCmd(root.linkctl, root.device);
|
|
}
|
|
|
|
function absorb(stdout) {
|
|
const u = Logic.absorb(stdout);
|
|
if (u.pan !== undefined) {
|
|
root.panDeg = u.pan;
|
|
}
|
|
if (u.tilt !== undefined) {
|
|
root.tiltDeg = u.tilt;
|
|
}
|
|
if (u.zoom !== undefined) {
|
|
root.zoomFactor = u.zoom;
|
|
}
|
|
if (u.tracking !== undefined) {
|
|
root.trackingState = u.tracking ? 1 : 0;
|
|
}
|
|
if (u.state !== undefined) {
|
|
root.statusText = u.state === "active" ? "Active" : "Idle";
|
|
}
|
|
}
|
|
|
|
Plasma5Support.DataSource {
|
|
id: sh
|
|
engine: "executable"
|
|
connectedSources: []
|
|
|
|
onNewData: (source, data) => {
|
|
const code = data["exit code"];
|
|
const out = data.stdout || "";
|
|
const err = (data.stderr || "").trim();
|
|
disconnectSource(source);
|
|
root.busy = false;
|
|
if (code === 0) {
|
|
root.absorb(out);
|
|
} else {
|
|
root.statusText = Logic.errorMessage(out, err, code);
|
|
}
|
|
}
|
|
|
|
function exec(cmd) {
|
|
root.busy = true;
|
|
connectSource(cmd);
|
|
}
|
|
}
|
|
|
|
function refresh() {
|
|
sh.exec(root.baseCmd() + " status");
|
|
}
|
|
|
|
function refreshTracking() {
|
|
sh.exec(root.baseCmd() + " tracking status");
|
|
}
|
|
|
|
function action(args) {
|
|
sh.exec(root.baseCmd() + " " + args);
|
|
}
|
|
|
|
Component.onCompleted: refresh()
|
|
|
|
fullRepresentation: ColumnLayout {
|
|
id: panel
|
|
Layout.minimumWidth: Kirigami.Units.gridUnit * 14
|
|
Layout.minimumHeight: Kirigami.Units.gridUnit * 22
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
// Keyboard control lives here rather than on the pad so the keys work
|
|
// as soon as the popup opens, without having to click the pad first.
|
|
focus: true
|
|
Component.onCompleted: forceActiveFocus()
|
|
|
|
Keys.onPressed: (event) => {
|
|
// Auto-repeat would otherwise look like a rapid press/release
|
|
// cycle and make the motion stutter.
|
|
if (event.isAutoRepeat) {
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
const d = root.dirForKey(event.key);
|
|
if (d !== "") {
|
|
root.setKey(d, true);
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
Keys.onReleased: (event) => {
|
|
if (event.isAutoRepeat) {
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
const d = root.dirForKey(event.key);
|
|
if (d !== "") {
|
|
root.setKey(d, false);
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
|
|
// Losing focus mid-press means the release never arrives, which would
|
|
// leave the camera panning on its own.
|
|
onActiveFocusChanged: {
|
|
if (!activeFocus) {
|
|
root.clearKeys();
|
|
}
|
|
}
|
|
|
|
PlasmaComponents.Label {
|
|
Layout.fillWidth: true
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: {
|
|
if (isNaN(root.panDeg) && isNaN(root.tiltDeg)) {
|
|
return root.statusText;
|
|
}
|
|
const p = isNaN(root.panDeg) ? "?" : root.panDeg.toFixed(1);
|
|
const t = isNaN(root.tiltDeg) ? "?" : root.tiltDeg.toFixed(1);
|
|
return "Pan " + p + "° Tilt " + t + "°";
|
|
}
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Joystick {
|
|
id: stick
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
|
Layout.preferredHeight: Layout.preferredWidth
|
|
deadzone: Plasmoid.configuration.deadzone / 100.0
|
|
|
|
// Re-read the true position once motion stops, so the readout is
|
|
// the camera's own value rather than an accumulation of steps.
|
|
onReleased: root.refresh()
|
|
}
|
|
|
|
PlasmaComponents.Label {
|
|
Layout.fillWidth: true
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: "Drag, or use WASD / arrow keys"
|
|
opacity: 0.6
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
|
|
Timer {
|
|
// Drives both input methods. Only runs while something is asking
|
|
// for movement, so an idle popup costs nothing.
|
|
interval: root.tickMs
|
|
repeat: true
|
|
running: root.inEngaged
|
|
triggeredOnStart: true
|
|
onTriggered: {
|
|
if (root.busy) {
|
|
return; // let the previous step finish, skip this one
|
|
}
|
|
const cmd = Logic.tickCommand(root.baseCmd(), root.inNx, root.inNy,
|
|
root.stepMax, root.invertPan,
|
|
root.invertTilt);
|
|
if (cmd !== "") {
|
|
sh.exec(cmd);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh the readout once keyboard movement stops, matching what the
|
|
// pad does on release.
|
|
Connections {
|
|
target: root
|
|
function onKeyEngagedChanged() {
|
|
if (!root.keyEngaged) {
|
|
root.refresh();
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
PlasmaComponents.Label {
|
|
text: "Zoom"
|
|
}
|
|
PlasmaComponents.Slider {
|
|
id: zoomSlider
|
|
Layout.fillWidth: true
|
|
// Keep arrow keys with the panel instead of nudging zoom.
|
|
focusPolicy: Qt.NoFocus
|
|
from: 1.0
|
|
to: 4.0
|
|
stepSize: 0.1
|
|
value: root.zoomFactor
|
|
// Only act when the drag ends: one command per gesture.
|
|
onPressedChanged: {
|
|
if (!pressed) {
|
|
root.action("zoom " + value.toFixed(1));
|
|
}
|
|
}
|
|
}
|
|
PlasmaComponents.Label {
|
|
text: zoomSlider.value.toFixed(1) + "x"
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
PlasmaComponents.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
Layout.fillWidth: true
|
|
icon.name: "go-home"
|
|
text: "Home"
|
|
onClicked: root.action("preset load "
|
|
+ Logic.shq(Plasmoid.configuration.homePreset))
|
|
}
|
|
PlasmaComponents.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
Layout.fillWidth: true
|
|
icon.name: "crosshairs"
|
|
text: "Center"
|
|
onClicked: root.action("center")
|
|
}
|
|
}
|
|
|
|
PlasmaComponents.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
Layout.fillWidth: true
|
|
icon.name: root.trackingState === 1 ? "view-visible" : "view-hidden"
|
|
text: root.trackingState === 1 ? "Tracking: on"
|
|
: root.trackingState === 0 ? "Tracking: off"
|
|
: "Tracking: unknown"
|
|
onClicked: root.action("tracking toggle")
|
|
}
|
|
|
|
PlasmaComponents.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
Layout.fillWidth: true
|
|
icon.name: "view-refresh"
|
|
text: "Refresh"
|
|
onClicked: {
|
|
root.refresh();
|
|
root.refreshTracking();
|
|
}
|
|
}
|
|
|
|
Item {
|
|
Layout.fillHeight: true
|
|
}
|
|
}
|
|
}
|