Fix KDE global shortcuts never binding under Plasma 6, and add a joystick widget #1

Merged
branderson merged 1 commits from plasma6-shortcut-fix-and-joystick into main 2026-10-07 21:57:44 -07:00
11 changed files with 887 additions and 19 deletions
+44 -7
View File
@@ -10,6 +10,7 @@ Three pieces, usable independently:
| `bin/linkctl-macro` | A wrapper that makes `linkctl` safe to bind to a hotkey: finds the binary in a desktop session's sparse `PATH`, passes `--force`, and reports the result as a desktop notification. |
| `kde/install-shortcuts.sh` | Registers KDE Plasma global shortcuts, so the keys work from anywhere including under Wayland. |
| `obs/obs-linkctl.lua` | OBS hotkeys, buttons, and scene automation: a scene switch can set tracking or load a preset on its own. |
| `plasmoid/` | A Plasma 6 widget with a virtual joystick, zoom slider, and Home/Center/Tracking buttons. |
## Prerequisites
@@ -37,9 +38,36 @@ Default bindings, editable in the `BINDINGS` table at the top of that script:
| `Meta+Alt+H` | `preset load home` |
| `Meta+Alt+C` | recenter gimbal |
Check them afterwards with `./kde/install-shortcuts.sh --list`, or in System
Settings under Keyboard, Shortcuts, searching for "Insta360". Reverse the whole
thing with `--uninstall`.
Check them afterwards with `./kde/install-shortcuts.sh --list`, which shows the
registered key, the default from the desktop entry, and flags any value Plasma
cannot parse. Or look in System Settings under Keyboard, Shortcuts, searching
for "Insta360". Reverse the whole thing with `--uninstall`.
The entries are visible in the launcher by default, because Plasma skips
`NoDisplay` services when building the Shortcuts list and they would otherwise
not appear there at all. `HIDE_FROM_MENU=1 ./kde/install-shortcuts.sh` hides
them at that cost.
## Joystick widget
```sh
./plasmoid/install.sh
```
Then right-click a panel or the desktop, Add Widgets, and search for
"Insta360". Pass `--restart` when reinstalling over a running copy, since
Plasma otherwise keeps the old one loaded.
The joystick is a velocity control: how far you push sets how fast the camera
moves, and it stops when you let go. Degrees per step, step interval, deadzone
and axis inversion are all in the widget's settings, along with the `linkctl`
path and an optional device.
It drives the gimbal with *relative* moves rather than absolute ones, because
`linkctl status` reports no pan or tilt at all while the camera is idle, so
there is no trustworthy position to aim from. Relative steps work either way
and cannot drift out of sync, and linkctl clamps each one to the camera's own
range, so holding the stick against a limit is harmless.
## OBS
@@ -88,9 +116,11 @@ case where the gimbal is parked facing down.
```sh
lua tests/test_obs_linkctl.lua # pure helpers: quoting, parsing, commands
lua tests/test_obs_lifecycle.lua # the OBS script against a mock OBS API
node tests/test_joystick_logic.js # the widget's command building and parsing
bin/linkctl-macro --print tracking on # show the command without running it
./kde/install-shortcuts.sh --dry-run # show the KDE changes, apply nothing
shellcheck bin/linkctl-macro kde/install-shortcuts.sh
./kde/install-shortcuts.sh --list # diagnose what is actually registered
shellcheck bin/linkctl-macro kde/install-shortcuts.sh plasmoid/install.sh
```
The lifecycle suite drives the script the way OBS does, so it catches broken
@@ -99,10 +129,17 @@ captured too early) without needing OBS or a camera.
## Troubleshooting
**Shortcut does nothing.** Run the action directly first:
**Shortcut does nothing, and System Settings says "No default shortcuts".**
Run `./kde/install-shortcuts.sh --list`. A `_launch` value containing commas
is the Plasma 5 format: Plasma 6 passes that value straight to
`QKeySequence::fromString`, so `Meta+Alt+T,none,Some Name` parses to nothing
and binds no key. Re-running the installer rewrites it as a bare key sequence.
Versions of this repo before that fix wrote the Plasma 5 form.
**Shortcut still does nothing.** Run the action directly:
`bin/linkctl-macro tracking on`. A failure notification names the cause. If
that works but the key does not, re-run the installer with `SHOW_IN_MENU=1`;
some Plasma builds only resolve launcher-visible application entries.
that works but the key does not, check for a conflict in System Settings, and
confirm `--list` shows the entry as `present`.
**`tracking` fails with exit 12.** AI tracking is experimental in `linkctl` and
goes through the camera's vendor extension unit. Pan, tilt, zoom and presets use
+42 -12
View File
@@ -31,9 +31,10 @@ BINDINGS=(
"center|Meta+Alt+C|center|Insta360: Recenter gimbal"
)
# Set to 1 if the shortcuts fail to register; some Plasma builds only resolve
# application entries that are visible in the launcher.
SHOW_IN_MENU=${SHOW_IN_MENU:-0}
# The entries are visible by default: KApplicationTrader skips NoDisplay
# services, so hiding them also hides them from the Shortcuts page in System
# Settings. Set HIDE_FROM_MENU=1 to keep the launcher clean at that cost.
HIDE_FROM_MENU=${HIDE_FROM_MENU:-0}
readonly APPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
readonly PREFIX="linkctl-"
@@ -81,15 +82,19 @@ desktop_file() { printf '%s/%s%s.desktop\n' "$APPDIR" "$PREFIX" "$1"; }
desktop_name() { printf '%s%s.desktop\n' "$PREFIX" "$1"; }
write_desktop() {
local id=$1 args=$2 label=$3 path
local id=$1 args=$2 label=$3 keys=$4 path
path=$(desktop_file "$id")
if (( DRY_RUN )); then
printf ' would write %s\n' "$path"
printf ' Exec=%s %s\n' "$MACRO" "$args"
printf ' X-KDE-Shortcuts=%s\n' "$keys"
return
fi
local nodisplay=true
(( SHOW_IN_MENU )) && nodisplay=false
local nodisplay=false
(( HIDE_FROM_MENU )) && nodisplay=true
# X-KDE-Shortcuts is where kglobalacceld reads an entry's *default*
# shortcut from, so declaring it here is what makes the binding show up in
# System Settings instead of "No default shortcuts".
cat > "$path" <<EOF
[Desktop Entry]
Type=Application
@@ -100,6 +105,9 @@ Icon=camera-web
Terminal=false
NoDisplay=$nodisplay
Categories=AudioVideo;
X-KDE-Shortcuts=$keys
X-KDE-GlobalAccel-CommandShortcut=true
StartupNotify=false
EOF
info " wrote $path"
}
@@ -162,7 +170,7 @@ do_install() {
info "Writing application entries:"
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
write_desktop "$id" "$args" "$label"
write_desktop "$id" "$args" "$label" "$keys"
done
info "Stopping the global shortcut daemon:"
@@ -172,10 +180,14 @@ do_install() {
info "Registering shortcuts:"
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
# Value is: active shortcut, default shortcut, display name.
# _launch is a bare key sequence. Plasma 5 stored a
# "keys,default,name" triplet here and Plasma 6 does not: it passes
# the value straight to QKeySequence::fromString, so a triplet parses
# to nothing and the shortcut silently never binds. Alternates are
# separated by a tab, and "none" means unbound.
run "$KWRITE" --file kglobalshortcutsrc \
--group services --group "$(desktop_name "$id")" \
--key _launch "$keys,none,$label"
--key _launch "$keys"
info " $keys -> linkctl $args"
done
@@ -214,15 +226,33 @@ do_uninstall() {
}
do_list() {
local entry id keys args label value
printf '%-22s %-14s %s\n' ACTION KEY REGISTERED
local entry id keys args label value path dflt problems=0
printf '%-20s %-12s %-14s %-12s %s\n' ACTION KEY _launch DEFAULT ENTRY
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
value=$("$KREAD" --file kglobalshortcutsrc \
--group services --group "$(desktop_name "$id")" \
--key _launch 2>/dev/null || true)
printf '%-22s %-14s %s\n' "$id" "$keys" "${value:-<not set>}"
path=$(desktop_file "$id")
if [[ -r $path ]]; then
dflt=$(sed -n 's/^X-KDE-Shortcuts=//p' "$path" | head -1)
else
dflt=""
fi
# A comma here means a Plasma 5 triplet, which Plasma 6 cannot parse.
if [[ $value == *,* ]]; then
problems=1
value="$value <-- INVALID"
fi
printf '%-20s %-12s %-14s %-12s %s\n' \
"$id" "$keys" "${value:-<unset>}" "${dflt:-<none>}" \
"$([[ -r $path ]] && echo present || echo MISSING)"
done
if (( problems )); then
echo
echo "A _launch value containing commas is the Plasma 5 format and never"
echo "binds under Plasma 6. Re-run this script to rewrite them."
fi
}
while [[ $# -gt 0 ]]; do
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# install.sh - install the Insta360 Link Joystick applet into Plasma.
#
# Usage:
# ./install.sh install, or upgrade if already present
# ./install.sh --uninstall remove it
# ./install.sh --restart also restart plasmashell (needed to pick up
# changes to an already-running applet)
#
# After installing: right-click the desktop or panel, Add Widgets, and search
# for "Insta360".
set -euo pipefail
readonly APPLET_ID="com.github.insta360linktools.joystick"
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly SCRIPT_DIR
readonly PKG="$SCRIPT_DIR/package"
RESTART=0
MODE=install
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
while [[ $# -gt 0 ]]; do
case $1 in
--uninstall) MODE=uninstall; shift ;;
--restart) RESTART=1; shift ;;
-h|--help)
awk 'NR>1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' \
"${BASH_SOURCE[0]}"
exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
if command -v kpackagetool6 >/dev/null 2>&1; then
KPT=kpackagetool6
elif command -v kpackagetool5 >/dev/null 2>&1; then
KPT=kpackagetool5
else
die "neither kpackagetool6 nor kpackagetool5 found; is this a KDE Plasma session?"
fi
restart_shell() {
(( RESTART )) || return 0
echo "Restarting plasmashell:"
if systemctl --user restart plasma-plasmashell.service 2>/dev/null; then
echo " restarted via systemd"
elif command -v kquitapp6 >/dev/null 2>&1; then
kquitapp6 plasmashell 2>/dev/null || true
sleep 2
(setsid plasmashell >/dev/null 2>&1 &)
echo " restarted"
else
echo " could not restart; log out and back in to pick up changes"
fi
}
case $MODE in
install)
[[ -f $PKG/metadata.json ]] || die "no package found at $PKG"
# Fail early on malformed metadata rather than leaving a half-installed
# applet that Plasma silently refuses to load.
python3 -c "import json,sys; json.load(open('$PKG/metadata.json'))" \
2>/dev/null || die "metadata.json is not valid JSON"
if "$KPT" --type Plasma/Applet --list 2>/dev/null | grep -qx "$APPLET_ID"; then
echo "Already installed; upgrading:"
"$KPT" --type Plasma/Applet --upgrade "$PKG"
else
echo "Installing:"
"$KPT" --type Plasma/Applet --install "$PKG"
fi
restart_shell
echo
echo "Done. Right-click a panel or the desktop, Add Widgets, search 'Insta360'."
;;
uninstall)
echo "Removing:"
"$KPT" --type Plasma/Applet --remove "$APPLET_ID"
restart_shell
echo "Done."
;;
esac
@@ -0,0 +1,10 @@
import QtQuick
import org.kde.plasma.configuration
ConfigModel {
ConfigCategory {
name: "General"
icon: "camera-web"
source: "configGeneral.qml"
}
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
<entry name="linkctlPath" type="String">
<label>Path to the linkctl binary</label>
<default>linkctl</default>
</entry>
<entry name="device" type="String">
<label>Video device, blank to auto-detect</label>
<default></default>
</entry>
<entry name="homePreset" type="String">
<label>Preset loaded by the Home button</label>
<default>home</default>
</entry>
<entry name="maxStep" type="Int">
<label>Degrees per step at full deflection</label>
<default>6</default>
</entry>
<entry name="tickInterval" type="Int">
<label>Milliseconds between steps while held</label>
<default>160</default>
</entry>
<entry name="deadzone" type="Int">
<label>Deadzone as a percentage of the radius</label>
<default>15</default>
</entry>
<entry name="invertPan" type="Bool">
<label>Invert the horizontal axis</label>
<default>false</default>
</entry>
<entry name="invertTilt" type="Bool">
<label>Invert the vertical axis</label>
<default>false</default>
</entry>
</group>
</kcfg>
+140
View File
@@ -0,0 +1,140 @@
/*
* A circular virtual joystick.
*
* Reports displacement as a normalised vector: nx and ny in -1..1, with ny
* positive upwards. Emits nothing itself; the parent samples nx/ny on a timer
* while `held` is true, which is what turns a position into a velocity.
*/
import QtQuick
import org.kde.kirigami as Kirigami
Item {
id: pad
property real nx: 0
property real ny: 0
property bool held: false
// Displacement below this fraction of the radius counts as centred, so
// resting a finger slightly off-centre does not creep the camera.
property real deadzone: 0.15
readonly property real magnitude: Math.sqrt(nx * nx + ny * ny)
readonly property bool engaged: held && magnitude > deadzone
implicitWidth: Kirigami.Units.gridUnit * 9
implicitHeight: implicitWidth
signal released()
function reset() {
nx = 0;
ny = 0;
}
// Base
Rectangle {
id: base
anchors.centerIn: parent
width: Math.min(pad.width, pad.height)
height: width
radius: width / 2
color: Kirigami.Theme.alternateBackgroundColor
border.width: 1
border.color: Kirigami.Theme.separatorColor ?? Kirigami.Theme.textColor
// Deadzone ring, drawn so the inactive area is visible rather than
// something the user has to discover.
Rectangle {
anchors.centerIn: parent
width: base.width * pad.deadzone
height: width
radius: width / 2
color: "transparent"
border.width: 1
border.color: Kirigami.Theme.separatorColor ?? Kirigami.Theme.textColor
opacity: 0.4
}
// Crosshair
Rectangle {
anchors.centerIn: parent
width: base.width * 0.9
height: 1
color: Kirigami.Theme.textColor
opacity: 0.15
}
Rectangle {
anchors.centerIn: parent
width: 1
height: base.height * 0.9
color: Kirigami.Theme.textColor
opacity: 0.15
}
}
readonly property real knobSize: base.width * 0.28
// Travel available to the knob centre.
readonly property real travel: (base.width - knobSize) / 2
Rectangle {
id: knob
width: pad.knobSize
height: pad.knobSize
radius: width / 2
color: pad.engaged ? Kirigami.Theme.highlightColor
: Kirigami.Theme.backgroundColor
border.width: 2
border.color: Kirigami.Theme.highlightColor
x: base.x + base.width / 2 - width / 2 + pad.nx * pad.travel
y: base.y + base.height / 2 - height / 2 - pad.ny * pad.travel
Behavior on x {
enabled: !pad.held
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
Behavior on y {
enabled: !pad.held
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
}
MouseArea {
anchors.fill: parent
preventStealing: true
function update(mx, my) {
const cx = base.x + base.width / 2;
const cy = base.y + base.height / 2;
let dx = mx - cx;
let dy = my - cy;
const len = Math.sqrt(dx * dx + dy * dy);
// Clamp into the circle so a corner drag cannot exceed full scale.
if (len > pad.travel && len > 0) {
dx *= pad.travel / len;
dy *= pad.travel / len;
}
pad.nx = pad.travel > 0 ? dx / pad.travel : 0;
pad.ny = pad.travel > 0 ? -dy / pad.travel : 0;
}
onPressed: (mouse) => {
pad.held = true;
update(mouse.x, mouse.y);
}
onPositionChanged: (mouse) => {
if (pad.held) {
update(mouse.x, mouse.y);
}
}
onReleased: {
pad.held = false;
pad.reset();
pad.released();
}
onCanceled: {
pad.held = false;
pad.reset();
pad.released();
}
}
}
@@ -0,0 +1,73 @@
import QtQuick
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
Kirigami.FormLayout {
id: page
// The cfg_* aliases are the contract with Plasma's config system: each
// name must match an entry in config/main.xml.
property alias cfg_linkctlPath: linkctlPath.text
property alias cfg_device: device.text
property alias cfg_homePreset: homePreset.text
property alias cfg_maxStep: maxStep.value
property alias cfg_tickInterval: tickInterval.value
property alias cfg_deadzone: deadzone.value
property alias cfg_invertPan: invertPan.checked
property alias cfg_invertTilt: invertTilt.checked
QQC2.TextField {
id: linkctlPath
Kirigami.FormData.label: "linkctl binary:"
placeholderText: "linkctl"
}
QQC2.TextField {
id: device
Kirigami.FormData.label: "Video device:"
placeholderText: "auto-detect"
}
QQC2.TextField {
id: homePreset
Kirigami.FormData.label: "Home preset:"
placeholderText: "home"
}
Item {
Kirigami.FormData.isSection: true
}
QQC2.SpinBox {
id: maxStep
Kirigami.FormData.label: "Degrees per step:"
from: 1
to: 30
}
QQC2.SpinBox {
id: tickInterval
Kirigami.FormData.label: "Step interval (ms):"
from: 60
to: 1000
stepSize: 20
}
QQC2.SpinBox {
id: deadzone
Kirigami.FormData.label: "Deadzone (%):"
from: 0
to: 50
}
QQC2.CheckBox {
id: invertPan
Kirigami.FormData.label: "Invert:"
text: "Horizontal axis"
}
QQC2.CheckBox {
id: invertTilt
text: "Vertical axis"
}
}
+110
View File
@@ -0,0 +1,110 @@
/*
* 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(" ; ");
}
/*
* 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;
}
+218
View File
@@ -0,0 +1,218 @@
/*
* 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
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 {
Layout.minimumWidth: Kirigami.Units.gridUnit * 14
Layout.minimumHeight: Kirigami.Units.gridUnit * 22
spacing: Kirigami.Units.smallSpacing
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()
}
Timer {
// Drives the stick. Only runs while it is meaningfully displaced.
interval: root.tickMs
repeat: true
running: stick.engaged
triggeredOnStart: true
onTriggered: {
if (root.busy) {
return; // let the previous step finish, skip this one
}
const cmd = Logic.tickCommand(root.baseCmd(), stick.nx, stick.ny,
root.stepMax, root.invertPan,
root.invertTilt);
if (cmd !== "") {
sh.exec(cmd);
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlasmaComponents.Label {
text: "Zoom"
}
PlasmaComponents.Slider {
id: zoomSlider
Layout.fillWidth: true
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 {
Layout.fillWidth: true
icon.name: "go-home"
text: "Home"
onClicked: root.action("preset load "
+ Logic.shq(Plasmoid.configuration.homePreset))
}
PlasmaComponents.Button {
Layout.fillWidth: true
icon.name: "crosshairs"
text: "Center"
onClicked: root.action("center")
}
}
PlasmaComponents.Button {
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 {
Layout.fillWidth: true
icon.name: "view-refresh"
text: "Refresh"
onClicked: {
root.refresh();
root.refreshTracking();
}
}
Item {
Layout.fillHeight: true
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"KPackageStructure": "Plasma/Applet",
"KPlugin": {
"Authors": [
{
"Name": "Insta360 Link tools"
}
],
"Category": "Multimedia",
"Description": "Virtual joystick and quick controls for an Insta360 Link webcam",
"Icon": "camera-web",
"Id": "com.github.insta360linktools.joystick",
"License": "MIT",
"Name": "Insta360 Link Joystick",
"Version": "1.0"
},
"X-Plasma-API-Minimum-Version": "6.0"
}
+105
View File
@@ -0,0 +1,105 @@
/*
* Tests for the joystick applet's pure helpers.
* Run: node tests/test_joystick_logic.js
*
* logic.js is a QML .js library, so it is loaded by stripping the `.pragma`
* line (meaningless to node) and evaluating the rest. That keeps one copy of
* the logic: the file under test is the file the applet ships.
*/
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const src = fs.readFileSync(
path.join(__dirname, "../plasmoid/package/contents/ui/logic.js"), "utf8");
const ctx = { Math, JSON, String };
vm.createContext(ctx);
vm.runInContext(src.replace(/^\s*\.pragma\s+library\s*$/m, ""), ctx);
let checks = 0, failures = 0;
function eq(got, want, label) {
checks++;
if (got !== want) {
failures++;
console.log(`FAIL ${label}\n got: ${got}\n want: ${want}`);
}
}
function deep(got, want, label) {
eq(JSON.stringify(got), JSON.stringify(want), label);
}
// --- shq: the shell boundary -------------------------------------------
eq(ctx.shq("home"), "'home'", "shq plain");
eq(ctx.shq("my desk"), "'my desk'", "shq space");
eq(ctx.shq("it's"), "'it'\\''s'", "shq embedded quote");
eq(ctx.shq("a; rm -rf /"), "'a; rm -rf /'", "shq neutralises separator");
eq(ctx.shq("$(whoami)"), "'$(whoami)'", "shq neutralises substitution");
eq(ctx.shq("`id`"), "'`id`'", "shq neutralises backticks");
// A preset name chosen to break out of the quoting must stay inert.
eq(ctx.shq("x'; touch /tmp/pwned; '"), "'x'\\''; touch /tmp/pwned; '\\'''",
"shq contains an escape attempt");
// --- baseCmd -----------------------------------------------------------
eq(ctx.baseCmd("linkctl", ""), "'linkctl' --force --json", "baseCmd plain");
eq(ctx.baseCmd("", ""), "'linkctl' --force --json", "baseCmd defaults binary");
eq(ctx.baseCmd("/usr/local/bin/linkctl", "/dev/video0"),
"'/usr/local/bin/linkctl' --force --json --device '/dev/video0'",
"baseCmd with device");
// --- tickCommand -------------------------------------------------------
const B = "'linkctl' --force --json";
eq(ctx.tickCommand(B, 0, 0, 6, false, false), "", "centred stick does nothing");
eq(ctx.tickCommand(B, 0.01, 0.01, 6, false, false), "",
"tiny deflection does nothing");
eq(ctx.tickCommand(B, 1, 0, 6, false, false), B + " right 6.0", "full right");
eq(ctx.tickCommand(B, -1, 0, 6, false, false), B + " left 6.0", "full left");
eq(ctx.tickCommand(B, 0, 1, 6, false, false), B + " up 6.0", "full up");
eq(ctx.tickCommand(B, 0, -1, 6, false, false), B + " down 6.0", "full down");
eq(ctx.tickCommand(B, 0.5, 0, 6, false, false), B + " right 3.0",
"half deflection halves the step");
// Diagonal: two single-axis calls chained.
eq(ctx.tickCommand(B, 1, 1, 6, false, false),
B + " right 6.0 ; " + B + " up 6.0", "diagonal chains both axes");
// A mostly-horizontal push must not creep vertically.
eq(ctx.tickCommand(B, 1, 0.05, 6, false, false), B + " right 6.0",
"small off-axis component is dropped");
// Inversion
eq(ctx.tickCommand(B, 1, 0, 6, true, false), B + " left 6.0", "invert pan");
eq(ctx.tickCommand(B, 0, 1, 6, false, true), B + " down 6.0", "invert tilt");
eq(ctx.tickCommand(B, 1, 1, 6, true, true), B + " left 6.0 ; " + B + " down 6.0",
"invert both");
// --- absorb ------------------------------------------------------------
deep(ctx.absorb('{"pan":12.3,"tilt":null}'), { pan: 12.3 },
"absorb takes pan, ignores null tilt");
// The diagonal case: two objects, each carrying one axis.
deep(ctx.absorb('{"pan":12.3,"tilt":null}\n{"pan":null,"tilt":-4}'),
{ pan: 12.3, tilt: -4 }, "absorb merges both axes across lines");
deep(ctx.absorb('{"tracking":true}'), { tracking: true }, "absorb tracking true");
deep(ctx.absorb('{"tracking":false}'), { tracking: false },
"absorb tracking false is kept, not treated as absent");
deep(ctx.absorb('{"state":"inactive","pan":null}'), { state: "inactive" },
"absorb state");
deep(ctx.absorb('{"pan":0,"tilt":0}'), { pan: 0, tilt: 0 },
"absorb keeps zero values");
deep(ctx.absorb(""), {}, "absorb empty");
deep(ctx.absorb("not json at all"), {}, "absorb ignores non-json");
deep(ctx.absorb('{"pan":1}\nbroken {\n{"tilt":2}'), { pan: 1, tilt: 2 },
"absorb survives a malformed line between good ones");
deep(ctx.absorb('Pan: 12.3°'), {}, "absorb ignores human-readable output");
// --- errorMessage ------------------------------------------------------
eq(ctx.errorMessage('{"error":"camera not found"}', "", 3), "camera not found",
"errorMessage prefers the json error");
eq(ctx.errorMessage("", "permission denied", 6), "permission denied",
"errorMessage falls back to stderr");
eq(ctx.errorMessage("", "", 7), "linkctl exited 7",
"errorMessage falls back to the exit code");
eq(ctx.errorMessage("not json", "stderr text", 8), "stderr text",
"errorMessage uses stderr when stdout is not json");
console.log(`\n${checks} checks, ${failures} failures`);
process.exit(failures === 0 ? 0 : 1);