Files
insta360-link-tools/plasmoid/package/contents/ui/main.qml
T
brad eb303db316 Add keyboard, gamepad and directional-shortcut control
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.
2026-10-07 22:16:52 -07:00

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
}
}
}