Add keyboard, gamepad and directional-shortcut control for the gimbal #2

Merged
branderson merged 1 commits from joystick-keyboard-and-gamepad into main 2026-10-07 22:27:07 -07:00
Collaborator

Summary generated by LLM agent

What

Three ways to drive the gimbal, all sharing the widget's existing velocity model: hold to move, release to stop.

Keyboard in the applet

WASD and the arrow keys feed the same tick timer the pointer already drives, via a new vectorFromKeys helper in logic.js. Opposing keys cancel rather than fighting, and a diagonal is normalised to length 1 so holding two keys does not move the camera faster than holding one.

Three details that are easy to get wrong and are covered by tests:

Auto-repeat is discarded. It arrives as a rapid press/release cycle, which would otherwise make the motion stutter as the timer stopped and restarted.

Held keys are cleared when the popup loses focus. Without that the matching release never arrives, and the camera carries on panning by itself with no visible cause.

The buttons and zoom slider are set to Qt.NoFocus. Otherwise clicking one hands it keyboard focus and the arrow keys start nudging zoom instead of reaching the pad.

Game controller

Qt 6 removed QtGamepad and ships no replacement, so the applet cannot read a controller itself. bin/linkctl-gamepad is a separate process that reads /dev/input/js* directly, using only the Python standard library: the joystick event struct is eight bytes, so this needs no third-party package and nothing to install.

Defaults target a DualSense on the in-tree hid_playstation driver, with the left stick on axes 0 and 1 and buttons 0, 1 and 2 bound to recenter, home preset and tracking toggle.

Axis and button numbering comes from the driver, not the controller. It varies between kernel versions and connection modes, and a DualSense commonly enumerates differently over Bluetooth than over USB. So --monitor prints live events for discovering the real numbers, and every axis, button and threshold is overridable. The README treats --monitor as a required first step rather than a debugging aid, because the defaults are an educated guess that no hardware here could confirm.

One non-obvious behaviour worth calling out: opening a joystick device makes the kernel replay the current state of every axis and button with JS_EVENT_INIT set. A controller sitting with any button held would therefore fire that button's mapped action the instant the daemon started. Those events are filtered, and the loop test pins that down specifically.

--wait tolerates the controller being absent and reconnects when it returns, which is what a wireless pad that sleeps needs. contrib/linkctl-gamepad.service runs it that way; the ExecStart path needs editing before use.

Directional global shortcuts

Meta+Alt plus the arrow keys nudge the gimbal, with NUDGE setting the degrees per press.

These are discrete steps rather than smooth motion, and that is a hard limit rather than a shortcut taken: kglobalaccel fires once per keypress and exposes no press-and-hold, so a held key cannot pan continuously. The README states this where the bindings are listed and points at the widget or a gamepad for continuous movement, so the behaviour is not mistaken for a bug.

Testing

145 checks across five suites, all passing. shellcheck clean on all four shell scripts, and all QML free of syntax errors under qmllint (the KDE QML modules are not installable in this environment, so their imports are unresolvable there and every remaining warning is of that kind).

New coverage:

  • 13 node checks on the key vector, including that opposing keys cancel, that cancelling one axis leaves the other intact, that all four keys at once is a standstill rather than a crash, and that a diagonal is normalised to length 1 and still emits a command on both axes.
  • 36 python checks on the gamepad helpers: event decoding, the JS_EVENT_INIT flag being stripped from the type yet still reported, axis clamping for drivers that report -32768, deadzone rescaling starting speed from zero at the edge instead of jumping, command construction, inversion, and argument parsing.
  • A new FIFO-driven suite, tests/test_gamepad_loop.sh, which substitutes a named pipe for /dev/input/js* and plays scripted kernel events through the real select/read/decode/tick/dispatch path. It confirms a diagonal emits both axes, a centred stick goes quiet, a deflection inside the deadzone moves nothing at all, a real button press fires its action, the initial-state burst does not, and a disconnect is reported and waited out.

That last suite is the only thing covering the read loop rather than its pieces, and it is what makes the JS_EVENT_INIT filtering a tested property instead of a comment.

Not verified

No physical game controller, camera or running Plasma session was available. The synthetic events exercise every line of the loop but cannot confirm that a real DualSense reports the axis and button numbers the defaults assume; that is what --monitor is for.

The keyboard handling is likewise unexercised in a live popup. If the arrow keys turn out to adjust zoom rather than move the camera, the Qt.NoFocus policy is not taking effect on that Plasma build and is worth reporting, since the fix would be a focus scope instead.

**Summary generated by LLM agent** ## What Three ways to drive the gimbal, all sharing the widget's existing velocity model: hold to move, release to stop. ## Keyboard in the applet WASD and the arrow keys feed the same tick timer the pointer already drives, via a new `vectorFromKeys` helper in `logic.js`. Opposing keys cancel rather than fighting, and a diagonal is normalised to length 1 so holding two keys does not move the camera faster than holding one. Three details that are easy to get wrong and are covered by tests: Auto-repeat is discarded. It arrives as a rapid press/release cycle, which would otherwise make the motion stutter as the timer stopped and restarted. Held keys are cleared when the popup loses focus. Without that the matching release never arrives, and the camera carries on panning by itself with no visible cause. The buttons and zoom slider are set to `Qt.NoFocus`. Otherwise clicking one hands it keyboard focus and the arrow keys start nudging zoom instead of reaching the pad. ## Game controller Qt 6 removed QtGamepad and ships no replacement, so the applet cannot read a controller itself. `bin/linkctl-gamepad` is a separate process that reads `/dev/input/js*` directly, using only the Python standard library: the joystick event struct is eight bytes, so this needs no third-party package and nothing to install. Defaults target a DualSense on the in-tree `hid_playstation` driver, with the left stick on axes 0 and 1 and buttons 0, 1 and 2 bound to recenter, home preset and tracking toggle. **Axis and button numbering comes from the driver, not the controller.** It varies between kernel versions and connection modes, and a DualSense commonly enumerates differently over Bluetooth than over USB. So `--monitor` prints live events for discovering the real numbers, and every axis, button and threshold is overridable. The README treats `--monitor` as a required first step rather than a debugging aid, because the defaults are an educated guess that no hardware here could confirm. One non-obvious behaviour worth calling out: opening a joystick device makes the kernel replay the current state of every axis and button with `JS_EVENT_INIT` set. A controller sitting with any button held would therefore fire that button's mapped action the instant the daemon started. Those events are filtered, and the loop test pins that down specifically. `--wait` tolerates the controller being absent and reconnects when it returns, which is what a wireless pad that sleeps needs. `contrib/linkctl-gamepad.service` runs it that way; the `ExecStart` path needs editing before use. ## Directional global shortcuts `Meta+Alt` plus the arrow keys nudge the gimbal, with `NUDGE` setting the degrees per press. These are discrete steps rather than smooth motion, and that is a hard limit rather than a shortcut taken: `kglobalaccel` fires once per keypress and exposes no press-and-hold, so a held key cannot pan continuously. The README states this where the bindings are listed and points at the widget or a gamepad for continuous movement, so the behaviour is not mistaken for a bug. ## Testing 145 checks across five suites, all passing. `shellcheck` clean on all four shell scripts, and all QML free of syntax errors under `qmllint` (the KDE QML modules are not installable in this environment, so their imports are unresolvable there and every remaining warning is of that kind). New coverage: - 13 node checks on the key vector, including that opposing keys cancel, that cancelling one axis leaves the other intact, that all four keys at once is a standstill rather than a crash, and that a diagonal is normalised to length 1 and still emits a command on both axes. - 36 python checks on the gamepad helpers: event decoding, the `JS_EVENT_INIT` flag being stripped from the type yet still reported, axis clamping for drivers that report -32768, deadzone rescaling starting speed from zero at the edge instead of jumping, command construction, inversion, and argument parsing. - A new FIFO-driven suite, `tests/test_gamepad_loop.sh`, which substitutes a named pipe for `/dev/input/js*` and plays scripted kernel events through the real `select`/read/decode/tick/dispatch path. It confirms a diagonal emits both axes, a centred stick goes quiet, a deflection inside the deadzone moves nothing at all, a real button press fires its action, the initial-state burst does not, and a disconnect is reported and waited out. That last suite is the only thing covering the read loop rather than its pieces, and it is what makes the `JS_EVENT_INIT` filtering a tested property instead of a comment. ## Not verified No physical game controller, camera or running Plasma session was available. The synthetic events exercise every line of the loop but cannot confirm that a real DualSense reports the axis and button numbers the defaults assume; that is what `--monitor` is for. The keyboard handling is likewise unexercised in a live popup. If the arrow keys turn out to adjust zoom rather than move the camera, the `Qt.NoFocus` policy is not taking effect on that Plasma build and is worth reporting, since the fix would be a focus scope instead.
dejima-agent added 1 commit 2026-10-07 22:16:58 -07:00
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.
branderson approved these changes 2026-10-07 22:27:03 -07:00
branderson merged commit 3cd99e7bf5 into main 2026-10-07 22:27:07 -07:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: branderson/insta360-link-tools#2