Files
insta360-link-tools/kde/install-shortcuts.sh
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

288 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# install-shortcuts.sh - register global hotkeys for the Insta360 Link under
# KDE Plasma.
#
# Plasma binds global shortcuts to application entries, so for each action
# this installs a .desktop file and the matching entry in kglobalshortcutsrc.
# The shortcuts then work from anywhere in the session, on Wayland as well as
# X11, which OBS's own hotkeys do not under Wayland.
#
# Usage:
# ./install-shortcuts.sh --dry-run show what would change, touch nothing
# ./install-shortcuts.sh install
# ./install-shortcuts.sh --list show what is currently registered
# ./install-shortcuts.sh --uninstall remove everything it installed
#
# Edit the BINDINGS table below to change the keys or the actions. Re-running
# the script is safe and applies the new table.
set -euo pipefail
# Degrees moved by one press of a directional shortcut.
NUDGE=${NUDGE:-5}
# id | key sequence | linkctl-macro arguments | human label
#
# Meta+Alt is used because Plasma leaves that combination largely free.
# Anything Plasma already owns is reported as a conflict rather than stolen.
BINDINGS=(
"tracking-toggle|Meta+Alt+T|tracking toggle|Insta360: Toggle AI tracking"
"tracking-on|Meta+Alt+1|tracking on|Insta360: AI tracking on"
"tracking-off|Meta+Alt+0|tracking off|Insta360: AI tracking off"
"preset-home|Meta+Alt+H|preset load home|Insta360: Load home preset"
"center|Meta+Alt+C|center|Insta360: Recenter gimbal"
# Directional nudges. kglobalaccel fires once per keypress and gives no
# press-and-hold, so each of these is a discrete step rather than smooth
# motion. For continuous movement use the Plasma widget or a gamepad.
"pan-left|Meta+Alt+Left|left $NUDGE|Insta360: Pan left"
"pan-right|Meta+Alt+Right|right $NUDGE|Insta360: Pan right"
"tilt-up|Meta+Alt+Up|up $NUDGE|Insta360: Tilt up"
"tilt-down|Meta+Alt+Down|down $NUDGE|Insta360: Tilt down"
)
# 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-"
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly SCRIPT_DIR
MACRO=${MACRO:-$SCRIPT_DIR/../bin/linkctl-macro}
DRY_RUN=0
MODE=install
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
info() { printf '%s\n' "$*"; }
run() {
if (( DRY_RUN )); then
printf ' would run: %s\n' "$*"
else
"$@"
fi
}
# Like run(), but discards the command's own chatter. Kept separate so that a
# redirect never swallows the dry-run description.
run_quiet() {
if (( DRY_RUN )); then
printf ' would run: %s\n' "$*"
else
"$@" >/dev/null 2>&1
fi
}
# Plasma 6 ships the *6 tools, Plasma 5 the *5 ones.
detect_plasma() {
if command -v kwriteconfig6 >/dev/null 2>&1; then
KWRITE=kwriteconfig6; KREAD=kreadconfig6
SYCOCA=kbuildsycoca6; PLASMA=6
elif command -v kwriteconfig5 >/dev/null 2>&1; then
KWRITE=kwriteconfig5; KREAD=kreadconfig5
SYCOCA=kbuildsycoca5; PLASMA=5
else
die "neither kwriteconfig6 nor kwriteconfig5 found; is this a KDE Plasma session?"
fi
}
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 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=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
Name=$label
Comment=Insta360 Link control via linkctl
Exec=$MACRO $args
Icon=camera-web
Terminal=false
NoDisplay=$nodisplay
Categories=AudioVideo;
X-KDE-Shortcuts=$keys
X-KDE-GlobalAccel-CommandShortcut=true
StartupNotify=false
EOF
info " wrote $path"
}
# Warn if some other action already owns this key sequence.
check_conflict() {
local id=$1 keys=$2 me existing
me=$(desktop_name "$id")
existing=$(grep -rl --include=kglobalshortcutsrc -F "$keys" \
"${XDG_CONFIG_HOME:-$HOME/.config}" 2>/dev/null || true)
[[ -z $existing ]] && return 0
local owner
owner=$(grep -B100 -F "$keys" "$existing" 2>/dev/null \
| grep -oE '^\[[^]]+\](\[[^]]+\])?' | tail -1 || true)
if [[ -n $owner && $owner != *"$me"* ]]; then
printf ' note: %s may already be bound by %s\n' "$keys" "$owner"
fi
}
stop_daemon() {
# Write while the daemon is down: it rewrites kglobalshortcutsrc on exit
# and would otherwise discard these entries.
if (( PLASMA == 6 )); then
run systemctl --user stop plasma-kglobalaccel.service 2>/dev/null \
|| run kquitapp6 kglobalacceld 2>/dev/null || true
else
run kquitapp5 kglobalaccel5 2>/dev/null || true
fi
}
start_daemon() {
if (( PLASMA == 6 )); then
if ! run systemctl --user start plasma-kglobalaccel.service 2>/dev/null; then
run kstart kglobalacceld 2>/dev/null \
|| info " could not restart kglobalacceld; log out and back in to apply"
fi
else
run kstart5 kglobalaccel5 2>/dev/null \
|| info " could not restart kglobalaccel5; log out and back in to apply"
fi
}
do_install() {
[[ -x $MACRO ]] || die "linkctl-macro not executable at $MACRO (set MACRO=/path)"
MACRO=$(cd -- "$(dirname -- "$MACRO")" && pwd)/$(basename -- "$MACRO")
info "Plasma $PLASMA detected; using $KWRITE"
info "macro script: $MACRO"
(( DRY_RUN )) && info "(dry run: nothing will be changed)"
run mkdir -p "$APPDIR"
info "Checking for conflicts:"
local entry id keys args label
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
check_conflict "$id" "$keys"
done
info "Writing application entries:"
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
write_desktop "$id" "$args" "$label" "$keys"
done
info "Stopping the global shortcut daemon:"
stop_daemon
sleep 1
info "Registering shortcuts:"
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
# _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"
info " $keys -> linkctl $args"
done
info "Rebuilding the application cache:"
run_quiet "$SYCOCA" --noincremental \
|| info " $SYCOCA reported a problem (usually harmless)"
info "Restarting the global shortcut daemon:"
start_daemon
if (( DRY_RUN )); then
info "Dry run complete."
else
info ""
info "Done. Check them under System Settings > Keyboard > Shortcuts,"
info "searching for 'Insta360'. Test one with: $MACRO tracking toggle"
fi
}
do_uninstall() {
info "Removing shortcuts and application entries:"
stop_daemon
sleep 1
local entry id keys args label path
for entry in "${BINDINGS[@]}"; do
IFS='|' read -r id keys args label <<< "$entry"
run "$KWRITE" --file kglobalshortcutsrc \
--group services --group "$(desktop_name "$id")" \
--key _launch --delete
path=$(desktop_file "$id")
[[ -e $path ]] && run rm -f "$path" && info " removed $path"
done
run_quiet "$SYCOCA" --noincremental || true
start_daemon
info "Done."
}
do_list() {
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)
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
case $1 in
--dry-run|-n) DRY_RUN=1; shift ;;
--uninstall) MODE=uninstall; shift ;;
--list|-l) MODE=list; shift ;;
-h|--help)
# Print the header comment block, stopping at the first code line.
awk 'NR>1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' \
"${BASH_SOURCE[0]}"
exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
detect_plasma
case $MODE in
install) do_install ;;
uninstall) do_uninstall ;;
list) do_list ;;
esac