feat: add hold-to-talk listener and restore Handy-managed preferences
This commit is contained in:
1 parent
0992f3b863
commit
e61f230978
7 files changed
+135
-47
No files matched your search
@@ -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")
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ in
|
||||
nixosModules.network-manager
|
||||
nixosModules.nftables-firewall
|
||||
nixosModules.niri
|
||||
nixosModules.handy
|
||||
nixosModules.notes
|
||||
nixosModules.plymouth
|
||||
nixosModules.printing
|
||||
|
||||
Reference in new issue
Block a user