feat: add hold-to-talk listener and restore Handy-managed preferences

This commit is contained in:
kiri committed 2026-09-09 20:44:59 +02:00
1 parent 0992f3b863
commit e61f230978
7 files changed
+135 -47

No files matched your search

+20 -26
View File
@@ -1,30 +1,16 @@
{
flake.modules.nixos.handy = { pkgs, ... }: {
services.udev.packages = [
(pkgs.writeTextDir "lib/udev/rules.d/70-handy-keyboard.rules" ''
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_KEYBOARD}=="1", TAG+="uaccess"
'')
];
};
flake.modules.homeManager.handy =
{ config, pkgs, ... }:
{ pkgs, ... }:
let
settings = (pkgs.formats.json { }).generate "handy-settings.json" {
settings = {
settings_schema_version = 1;
selected_model = "handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf/nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf";
selected_language = "auto";
translate_to_english = false;
selected_microphone = null;
always_on_microphone = false;
push_to_talk = true;
paste_method = "ctrl_shift_v";
typing_tool = "wtype";
clipboard_handling = "dont_modify";
auto_submit = false;
overlay_style = "live";
overlay_position = "bottom";
post_process_enabled = false;
start_hidden = true;
autostart_enabled = false;
update_checks_enabled = false;
show_whats_new_on_update = false;
onboarding_completed = true;
};
};
python = pkgs.python3.withPackages (ps: [ ps.evdev ]);
in
{
home.packages = [ pkgs.handy ];
@@ -37,10 +23,18 @@
};
Install.WantedBy = [ "graphical-session.target" ];
Service = {
# Handy writes migrations and runtime preferences: restore the Nix baseline on start.
ExecStartPre = "${pkgs.coreutils}/bin/install -Dm600 ${settings} ${config.xdg.dataHome}/com.pais.handy/settings_store.json";
ExecStart = "${pkgs.handy}/bin/handy --start-hidden";
};
};
systemd.user.services.handy-ptt = {
Unit = {
Description = "Handy push-to-talk key release listener";
PartOf = [ "graphical-session.target" ];
Requisite = [ "handy.service" ];
After = [ "handy.service" ];
};
Service.ExecStart = "${python}/bin/python ${./handy/_push_to_talk.py} ${pkgs.handy}/bin/handy";
};
};
}
@@ -0,0 +1,48 @@
"""Wait for release of the physical Super+D chord that launched this service."""
import signal
import subprocess
import sys
from contextlib import closing
from evdev import InputDevice, ecodes as key, list_devices
def held(keys):
return key.KEY_D in keys and bool({key.KEY_LEFTMETA, key.KEY_RIGHTMETA} & set(keys))
def interrupted(signum, frame):
raise KeyboardInterrupt
def main(handy):
signal.signal(signal.SIGTERM, interrupted)
for path in list_devices():
try:
device = InputDevice(path)
except OSError:
continue # Devices can disappear between enumeration and opening.
with closing(device):
if not held(device.active_keys()):
continue
action = "--cancel"
try:
subprocess.run([handy, "--toggle-transcription"], check=True)
# A quick release may already be queued while the CLI starts.
if held(device.active_keys()):
for event in device.read_loop():
if not held(device.active_keys()):
break
action = "--toggle-transcription"
finally:
# Disconnects, cancellation and service shutdown discard the recording.
subprocess.run([handy, action], check=True)
return
if __name__ == "__main__":
try:
main(sys.argv[1])
except KeyboardInterrupt:
pass
@@ -0,0 +1,37 @@
"""Synthetic key-state checks; never reads a keyboard or records audio."""
from unittest.mock import Mock, patch
import _push_to_talk as ptt
down = [ptt.key.KEY_LEFTMETA, ptt.key.KEY_D]
def check(states, expected, error=None):
device = Mock()
device.active_keys.side_effect = states
device.read_loop.return_value = iter([object(), object()])
if error:
device.read_loop.side_effect = error
with (
patch.object(ptt, "list_devices", return_value=["keyboard"]),
patch.object(ptt, "InputDevice", return_value=device),
patch.object(ptt.signal, "signal"),
patch.object(ptt.subprocess, "run") as run,
):
try:
ptt.main("handy")
except (OSError, KeyboardInterrupt):
assert error is not None
assert [call.args[0][1] for call in run.call_args_list] == expected
device.close.assert_called_once()
check([[]], []) # Released before the listener starts: do not record.
check([down, []], ["--toggle-transcription", "--toggle-transcription"])
check([down, down, down, [ptt.key.KEY_D]], ["--toggle-transcription"] * 2)
check([down, down], ["--toggle-transcription", "--cancel"], OSError("unplugged"))
check([down, down], ["--toggle-transcription", "--cancel"], KeyboardInterrupt())
assert ptt.held([ptt.key.KEY_RIGHTMETA, ptt.key.KEY_D])
assert not ptt.held([ptt.key.KEY_LEFTMETA])
print("Push-to-talk release, repeat, disconnect and cancellation checks passed")
+11 -7
View File
@@ -36,19 +36,23 @@ in
};
# Dictation
"Mod+Shift+D" = {
"Mod+D" = {
spawn = [
handy
"--toggle-transcription"
"systemctl"
"--user"
"start"
"handy-ptt.service"
];
_props.repeat = false;
_props.hotkey-overlay-title = "Hold to Dictate";
};
"Mod+Shift+D" = {
spawn-sh = "systemctl --user stop handy-ptt.service && ${handy} --toggle-transcription";
_props.repeat = false;
_props.hotkey-overlay-title = "Toggle Dictation";
};
"Mod+Ctrl+D" = {
spawn = [
handy
"--cancel"
];
spawn-sh = "systemctl --user stop handy-ptt.service && ${handy} --cancel";
_props.repeat = false;
_props.hotkey-overlay-title = "Cancel Dictation";
};