From 6253d1799a28e15529e809ce425574c588d59fb0 Mon Sep 17 00:00:00 2001 From: Colin Date: Thu, 29 Feb 2024 01:26:38 +0000 Subject: [PATCH] port sxmo_hook_inputhandler.sh -> sane-input-handler this one can run outside the SXMO environment. major thing missing at the moment is that rofi doesn't get volume control inputs because bonsai out-competes it for exclusive control. --- hosts/common/programs/assorted.nix | 6 + hosts/common/programs/default.nix | 2 + .../programs/sane-input-handler/default.nix | 120 +++++++++ .../sane-input-handler/sane-input-handler | 228 ++++++++++++++++++ hosts/common/programs/wvkbd.nix | 20 ++ 5 files changed, 376 insertions(+) create mode 100644 hosts/common/programs/sane-input-handler/default.nix create mode 100755 hosts/common/programs/sane-input-handler/sane-input-handler create mode 100644 hosts/common/programs/wvkbd.nix diff --git a/hosts/common/programs/assorted.nix b/hosts/common/programs/assorted.nix index 4c2231e64..0342f2e99 100644 --- a/hosts/common/programs/assorted.nix +++ b/hosts/common/programs/assorted.nix @@ -719,11 +719,17 @@ in "/sys/kernel" ]; + procps = {}; + + psmisc = {}; + pstree.sandbox.method = "landlock"; pstree.sandbox.extraPaths = [ "/proc" ]; + pulseaudio = {}; + pulsemixer.sandbox.method = "landlock"; pulsemixer.sandbox.whitelistAudio = true; diff --git a/hosts/common/programs/default.nix b/hosts/common/programs/default.nix index 5f4fdba90..c67adbf62 100644 --- a/hosts/common/programs/default.nix +++ b/hosts/common/programs/default.nix @@ -81,6 +81,7 @@ ./rhythmbox.nix ./ripgrep.nix ./rofi + ./sane-input-handler ./sane-scripts.nix ./sfeed.nix ./signal-desktop.nix @@ -108,6 +109,7 @@ ./wireplumber.nix ./wireshark.nix ./wob + ./wvkbd.nix ./xarchiver.nix ./xdg-desktop-portal.nix ./xdg-desktop-portal-gtk.nix diff --git a/hosts/common/programs/sane-input-handler/default.nix b/hosts/common/programs/sane-input-handler/default.nix new file mode 100644 index 000000000..37fcf8202 --- /dev/null +++ b/hosts/common/programs/sane-input-handler/default.nix @@ -0,0 +1,120 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.sane.programs.sane-input-handler; + doExec = inputName: transitions: { + type = "exec"; + command = [ + "setsid" + "-f" + "sane-input-handler" + inputName + ]; + inherit transitions; + }; + onDelay = ms: transitions: { + type = "delay"; + delay_duration = ms * 1000000; + inherit transitions; + }; + onEvent = eventName: transitions: { + type = "event"; + event_name = eventName; + inherit transitions; + }; + friendlyToBonsai = { trigger ? null, terminal ? false, timeout ? {}, power_pressed ? {}, power_released ? {}, voldown_pressed ? {}, voldown_released ? {}, volup_pressed ? {}, volup_released ? {} }@args: + if trigger != null then [ + (doExec trigger (friendlyToBonsai (builtins.removeAttrs args ["trigger"]))) + ] else let + events = [ ] + ++ (lib.optional (timeout != {}) (onDelay (timeout.ms or 400) (friendlyToBonsai (builtins.removeAttrs timeout ["ms"])))) + ++ (lib.optional (power_pressed != {}) (onEvent "power_pressed" (friendlyToBonsai power_pressed))) + ++ (lib.optional (power_released != {}) (onEvent "power_released" (friendlyToBonsai power_released))) + ++ (lib.optional (voldown_pressed != {}) (onEvent "voldown_pressed" (friendlyToBonsai voldown_pressed))) + ++ (lib.optional (voldown_released != {}) (onEvent "voldown_released" (friendlyToBonsai voldown_released))) + ++ (lib.optional (volup_pressed != {}) (onEvent "volup_pressed" (friendlyToBonsai volup_pressed))) + ++ (lib.optional (volup_released != {}) (onEvent "volup_released" (friendlyToBonsai volup_released))) + ; + in assert terminal -> events == []; events; + + # trigger ${button}_hold_N every `holdTime` ms until ${button} is released + recurseHold = button: { count ? 1, maxHolds ? 5, prefix ? "", holdTime ? 600, ... }@opts: lib.optionalAttrs (count <= maxHolds) { + "${button}_released".terminal = true; # end the hold -> back to root state + timeout = { + ms = holdTime; + trigger = "${prefix}${button}_hold_${builtins.toString count}"; + } // (recurseHold button (opts // { count = count+1; })); + }; + + # trigger volup_tap_N or voldown_tap_N on every tap. + # if a volume button is held, then switch into `recurseHold`'s handling instead + volumeActions = { count ? 1, maxTaps ? 5, prefix ? "", timeout ? 600, ... }@opts: lib.optionalAttrs (count != maxTaps) { + volup_pressed = (recurseHold "volup" opts) // { + volup_released = { + trigger = "${prefix}volup_tap_${builtins.toString count}"; + timeout.ms = timeout; + } // (volumeActions (opts // { count = count+1; })); + }; + voldown_pressed = (recurseHold "voldown" opts) // { + voldown_released = { + trigger = "${prefix}voldown_tap_${builtins.toString count}"; + timeout.ms = timeout; + } // (volumeActions (opts // { count = count+1; })); + }; + }; +in +{ + sane.programs.sane-input-handler = { + packageUnwrapped = pkgs.static-nix-shell.mkBash { + pname = "sane-input-handler"; + srcRoot = ./.; + pkgs = { + inherit (pkgs) coreutils playerctl procps psmisc pulseaudio util-linux wvkbd; + sway = config.sane.programs.sway.package.sway-unwrapped; + }; + }; + suggestedPrograms = [ "bonsai" "playerctl" "procps" "psmisc" "pulseaudio" "sway" "wvkbd" ]; + }; + + sane.programs.bonsai.config.transitions = lib.mkIf cfg.enabled (friendlyToBonsai { + # map sequences of "events" to an argument to pass to sane-input-handler + + # map: power (short), power (short) x2, power (long) + power_pressed.timeout.ms = 900; # press w/o release. this is a long timeout because it's tied to the "kill window" action. + power_pressed.timeout.trigger = "powerhold"; + power_pressed.power_released.timeout.trigger = "powerbutton_one"; + power_pressed.power_released.timeout.ms = 300; + power_pressed.power_released.power_pressed.trigger = "powerbutton_two"; + + # map: volume taps and holds + volup_pressed = (recurseHold "volup" {}) // { + # this either becomes volup_hold_* (via recurseHold, above) or: + # - a short volup_tap_1 followed by: + # - a *finalized* volup_1 (i.e. end of action) + # - more taps/holds, in which case we prefix it with `modal_` + # to denote that we very explicitly entered this state. + # + # it's clunky: i do it this way so that voldown can map to keyboard/terminal in unlock mode + # but trigger media controls in screenoff + # in a way which *still* allows media controls if explicitly entered into via a tap on volup first + volup_released = (volumeActions { prefix = "modal_"; }) // { + trigger = "volup_tap_1"; + timeout.ms = 300; + timeout.trigger = "volup_1"; + }; + }; + voldown_pressed = (volumeActions {}).voldown_pressed // { + trigger = "voldown_start"; + }; + }); + + sane.programs.sway.config.extra_lines = lib.mkIf cfg.enabled '' + # TODO: `bindsym` at this level CONSUMES the event globally; + # need some way to allow rofi to see these events when it is in focus + bindsym --locked --no-repeat XF86PowerOff exec bonsaictl -e power_pressed + bindsym --locked --release XF86PowerOff exec bonsaictl -e power_released + bindsym --locked --no-repeat XF86AudioRaiseVolume exec bonsaictl -e volup_pressed + bindsym --locked --release XF86AudioRaiseVolume exec bonsaictl -e volup_released + bindsym --locked --no-repeat XF86AudioLowerVolume exec bonsaictl -e voldown_pressed + bindsym --locked --release XF86AudioLowerVolume exec bonsaictl -e voldown_released + ''; +} diff --git a/hosts/common/programs/sane-input-handler/sane-input-handler b/hosts/common/programs/sane-input-handler/sane-input-handler new file mode 100755 index 000000000..827dfa8ed --- /dev/null +++ b/hosts/common/programs/sane-input-handler/sane-input-handler @@ -0,0 +1,228 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p coreutils -p playerctl -p procps -p psmisc -p pulseaudio -p sway -p util-linux -p wvkbd + +# input map considerations +# - using compound actions causes delays. +# e.g. if volup->volup is a distinct action from volup, then single-volup action is forced to wait the maximum button delay. +# - actions which are to be responsive should therefore have a dedicated key. +# - a dedicated "kill" combo is important for unresponsive fullscreen apps, because appmenu doesn't show in those +# - although better may be to force appmenu to show over FS apps +# - bonsai mappings are static, so buttons can't benefit from non-compounding unless they're mapped accordingly for all lock states +# - this limitation could be removed, but with work +# +# example of a design which considers these things: +# - when unlocked: +# - volup toggle -> app menu +# - voldown press -> keyboard +# - voldown hold -> terminal +# - power x2 -> screenoff +# - hold power -> kill app +# - when locked: +# - volup tap -> volume up +# - volup hold -> media seek forward +# - voldown tap -> volume down +# - voldown hold -> media seek backward +# - power x1 -> screen on +# - power x2 -> play/pause media +# some trickiness allows for media controls in unlocked mode: +# - volup tap -> enter media mode +# - i.e. in this state, vol tap/hold is mapped to volume/seek +# - if, after entering media mode, no more taps occur, then we trigger the default app-menu action +# limitations/downsides: +# - power mappings means phone is artificially slow to unlock. +# - media controls when unlocked have quirks: +# - mashing voldown to decrease the volume will leave you with a toggled keyboard. +# - seeking backward isn't possible except by first tapping volup. + + +# increments to use for volume adjustment +VOL_INCR=5 +KEYBOARD="${KEYBOARD:-wvkbd-mobintl}" + +action="$1" + +isTouchOn() { + # success if all touch inputs have their events enabled + swaymsg -t get_inputs --raw \ + | jq --exit-status '. | map(select(.type == "touch")) | all(.libinput.send_events == "enabled")' \ + > /dev/null +} +isScreenOn() { + # success if all outputs have power + swaymsg -t get_outputs --raw \ + | jq --exit-status '. | all(.power)' \ + > /dev/null +} + +isAllOn() { + isTouchOn && isScreenOn +} + +isInhibited() { + pidof rofi +} + + +ignore() { + true +} +inhibited() { + true +} +unmapped() { + true +} + +openDesktop() { + # open a .desktop file via the xdg-desktop-portal + gdbus call --session --timeout 10 \ + --dest org.freedesktop.portal.Desktop \ + --object-path /org/freedesktop/portal/desktop \ + --method org.freedesktop.portal.DynamicLauncher.Launch \ + "$1" {} +} + +allOn() { + swaymsg -- output '*' power true + swaymsg -- input type:touch events enabled +} +allOff() { + swaymsg -- output '*' power false + swaymsg -- input type:touch events disabled +} + +toggleKeyboard() { + local kbpid=$(pidof "$KEYBOARD") + if [ -z "$kbpid" ] || ! ( env kill -s RTMIN+0 "$kbpid" ); then + echo "sane-input-handler: failed to toggle keyboard; launching manually: $KEYBOARD" + # manually launch the keyboard, as a fallback + "$KEYBOARD" & + fi +} + +handleWith() { + state= + if [ -n "$_isInhibited" ]; then + state="inhibited+" + fi + if [ -n "$_isAllOn" ]; then + state="${state}on" + else + state="${state}off" + fi + echo "sane-input-handler: state=$state action=$action: handleWith: $@" + "$@" + exit 0 +} + +dispatchDefault() { + case "$action" in + "powerbutton_one") + # power once => unlock + handleWith allOn + ;; + "powerbutton_two") + # power twice => screenoff + handleWith allOff + ;; + # powerbutton_three: intentional no-op because overloading the kill-window handler is risky + + volup_tap*|modal_volup_tap*) + handleWith pactl set-sink-volume @DEFAULT_SINK@ +"$VOL_INCR%" + ;; + voldown_tap*|modal_voldown_tap*) + handleWith pactl set-sink-volume @DEFAULT_SINK@ -"$VOL_INCR%" + ;; + + volup_hold*|modal_volup_hold*) + handleWith playerctl position 30+ + ;; + voldown_hold*|modal_voldown_hold*) + handleWith playerctl position 10- + ;; + esac +} + +dispatchOff() { + case "$action" in + "powerbutton_two") + # power twice => toggle media player + handleWith playerctl play-pause + ;; + "powerhold") + # power toggle during deep sleep often gets misread as power hold, so treat same + handleWith allOn + ;; + esac +} + +dispatchOn() { + case "$action" in + # powerbutton_one: intentional default to no-op + # powerbutton_two: intentional default to screenoff + "powerhold") + # power thrice: kill active window + handleWith swaymsg kill + ;; + + "volup_tap_1") + # swallow: this could be the start to a media control (multi taps / holds), + # or it could be just a single tap -> release, handled next/below + handleWith ignore + ;; + "volup_1") + # volume up once: system menu + handleWith openDesktop rofi.desktop + ;; + + "voldown_start") + # volume down once: toggle keyboard + handleWith toggleKeyboard + ;; + "voldown_hold_2") + # hold voldown to launch terminal + # note we already triggered the keyboard; that's fine: usually keyboard + terminal go together :) + # voldown_hold_1 frequently triggers during short taps meant only to reveal the keyboard, + # so prefer a longer hold duration + handleWith openDesktop xdg-terminal-exec.desktop + ;; + "voldown_tap_1") + # swallow, to prevent keyboard from also triggering media controls + handleWith ignore + ;; + voldown_hold_*) + # swallow, to prevent terminal from also triggering media controls + handleWith ignore + ;; + esac +} + +dispatchInhibited() { + case "$action" in + "powerhold") + # power thrice: escape hatch in case rofi has hung + handleWith killall -9 rofi + ;; + *) + # eat everything else (and let rofi consume it) + handleWith inhibited + ;; + esac +} + +_isAllOn="$(isAllOn && echo 1 || true)" +_isInhibited="$(isInhibited && echo 1 || true)" + +if [ -n "$_isInhibited" ]; then + dispatchInhibited +fi + +if [ -n "$_isAllOn" ]; then + dispatchOn +else + dispatchOff +fi + +dispatchDefault + +handleWith unmapped diff --git a/hosts/common/programs/wvkbd.nix b/hosts/common/programs/wvkbd.nix new file mode 100644 index 000000000..b2b6f3238 --- /dev/null +++ b/hosts/common/programs/wvkbd.nix @@ -0,0 +1,20 @@ +{ config, ... }: +let + cfg = config.sane.programs.wvkbd; +in +{ + sane.programs.wvkbd = { + services.wvkbd = { + description = "wvkbd: wayland virtual keyboard"; + after = [ "graphical-session.target" ]; + wantedBy = [ "graphical-session.target" ]; + serviceConfig = { + # --hidden: send SIGUSR2 to unhide + Execstart = "${cfg.package}/bin/wvkbd-mobintl --hidden"; + Type = "simple"; + Restart = "always"; + RestartSec = "3s"; + }; + }; + }; +}