Skip to content

Commit 9896f77

Browse files
committed
feat: allow lower-level paste keycode config
1 parent 26d81cd commit 9896f77

File tree

3 files changed

+81
-17
lines changed

3 files changed

+81
-17
lines changed

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ Toggle this behavior in `~/.config/hyprwhspr/config.json`:
598598

599599
```jsonc
600600
{
601-
"paste_mode": "ctrl_shift" // "ctrl_shift" | "ctrl" | "super" (default: "ctrl_shift")
601+
"paste_mode": "ctrl_shift", // "ctrl_shift" | "ctrl" | "super" (default: "ctrl_shift")
602602
}
603603
```
604604

@@ -610,6 +610,24 @@ Toggle this behavior in `~/.config/hyprwhspr/config.json`:
610610

611611
- **`"super"`** — Sends Super+V. Maybe finicky.
612612

613+
**Non-QWERTY layouts (bepo, dvorak, etc.)**
614+
615+
`ydotool` sends physical Linux keycodes, so `Ctrl+KEY_V` might not be `Ctrl+v` on your layout.
616+
617+
Quick way to fix it on Wayland (no arithmetic):
618+
619+
- Run `wev` in terminal
620+
- Press the key that types `v` on your layout
621+
- Copy the printed `keycode` into `paste_keycode_wev`
622+
623+
```jsonc
624+
{
625+
"paste_keycode_wev": 55 // `wev` keycode for the key that types 'v' on your layout
626+
}
627+
```
628+
629+
Advanced (if you already know the Linux evdev keycode): set `paste_keycode` directly.
630+
613631
**Auto-submit** - automatically press Enter after pasting:
614632

615633
> aka Dictation YOLO

lib/src/config_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ def __init__(self):
4545
# Values: "super" | "ctrl_shift" | "ctrl"
4646
# Default "ctrl_shift" for flexible unix-y primitive
4747
'paste_mode': 'ctrl_shift',
48+
# Wayland/XKB keycode as printed by `wev` for the key that types 'v'.
49+
# If set, hyprwhspr will convert it to Linux evdev by subtracting 8.
50+
# This avoids users having to do the math themselves.
51+
'paste_keycode_wev': None,
52+
# ydotool sends Linux evdev keycodes (physical keys), not keysyms/characters.
53+
# Default 47 = KEY_V (works on QWERTY; on other layouts set this to the keycode
54+
# for the physical key that produces 'v' on your layout).
55+
'paste_keycode': 47,
4856
# Back-compat for older configs (used only if paste_mode is absent):
4957
'shift_paste': True, # true = Ctrl+Shift+V, false = Ctrl+V
5058
# Transcription backend settings

lib/src/text_injector.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
pyperclip = require_package('pyperclip')
2222

23+
DEFAULT_PASTE_KEYCODE = 47 # Linux evdev KEY_V on QWERTY
24+
2325

2426
class TextInjector:
2527
"""Handles injecting text into focused applications"""
@@ -42,6 +44,37 @@ def _check_ydotool(self) -> bool:
4244
except Exception:
4345
return False
4446

47+
def _get_paste_keycode(self) -> int:
48+
"""
49+
Get the Linux evdev keycode used for the 'V' part of paste chords.
50+
51+
ydotool's `key` command sends raw keycodes (physical keys). On non-QWERTY
52+
layouts, KEY_V (47) may not map to a keysym 'v', so Ctrl+KEY_V won't paste.
53+
Users can set either:
54+
- `paste_keycode_wev`: the Wayland/XKB keycode printed by `wev` (we subtract 8)
55+
- `paste_keycode`: the Linux evdev keycode directly (advanced)
56+
"""
57+
keycode = DEFAULT_PASTE_KEYCODE
58+
if self.config_manager:
59+
wev_keycode = self.config_manager.get_setting('paste_keycode_wev', None)
60+
if wev_keycode is not None:
61+
try:
62+
# wev reports Wayland/XKB keycodes, which are typically evdev+8
63+
wev_keycode_int = int(wev_keycode)
64+
converted = wev_keycode_int - 8
65+
return converted if converted > 0 else DEFAULT_PASTE_KEYCODE
66+
except Exception:
67+
# If parsing fails, fall back to evdev keycode setting
68+
pass
69+
70+
keycode = self.config_manager.get_setting('paste_keycode', DEFAULT_PASTE_KEYCODE)
71+
72+
try:
73+
keycode_int = int(keycode)
74+
return keycode_int if keycode_int > 0 else DEFAULT_PASTE_KEYCODE
75+
except Exception:
76+
return DEFAULT_PASTE_KEYCODE
77+
4578
def _get_active_window_info(self) -> Optional[Dict[str, Any]]:
4679
"""Get active window info from Hyprland (if available)"""
4780
try:
@@ -115,35 +148,39 @@ def _send_paste_keys_slow(self, paste_mode: str) -> bool:
115148
the key sequence when modifiers arrive too quickly.
116149
"""
117150
try:
151+
paste_keycode = self._get_paste_keycode()
152+
paste_keycode_pressed = f'{paste_keycode}:1'
153+
paste_keycode_released = f'{paste_keycode}:0'
154+
118155
if paste_mode == 'super':
119156
# Super+V with delays: Super down, delay, V down, V up, Super up
120157
subprocess.run(['ydotool', 'key', '125:1'], capture_output=True, timeout=1)
121158
time.sleep(0.015)
122-
subprocess.run(['ydotool', 'key', '47:1', '47:0'], capture_output=True, timeout=1)
159+
subprocess.run(['ydotool', 'key', paste_keycode_pressed, paste_keycode_released], capture_output=True, timeout=1)
123160
time.sleep(0.010)
124161
subprocess.run(['ydotool', 'key', '125:0'], capture_output=True, timeout=1)
125162

126163
elif paste_mode == 'ctrl_shift':
127164
# Ctrl+Shift+V with delays: mods down, delay, V, delay, mods up
128165
subprocess.run(['ydotool', 'key', '29:1', '42:1'], capture_output=True, timeout=1)
129166
time.sleep(0.015)
130-
subprocess.run(['ydotool', 'key', '47:1', '47:0'], capture_output=True, timeout=1)
167+
subprocess.run(['ydotool', 'key', paste_keycode_pressed, paste_keycode_released], capture_output=True, timeout=1)
131168
time.sleep(0.010)
132169
subprocess.run(['ydotool', 'key', '42:0', '29:0'], capture_output=True, timeout=1)
133170

134171
elif paste_mode == 'ctrl':
135172
# Ctrl+V with delays
136173
subprocess.run(['ydotool', 'key', '29:1'], capture_output=True, timeout=1)
137174
time.sleep(0.015)
138-
subprocess.run(['ydotool', 'key', '47:1', '47:0'], capture_output=True, timeout=1)
175+
subprocess.run(['ydotool', 'key', paste_keycode_pressed, paste_keycode_released], capture_output=True, timeout=1)
139176
time.sleep(0.010)
140177
subprocess.run(['ydotool', 'key', '29:0'], capture_output=True, timeout=1)
141178

142179
elif paste_mode == 'alt':
143180
# Alt+V with delays
144181
subprocess.run(['ydotool', 'key', '56:1'], capture_output=True, timeout=1)
145182
time.sleep(0.015)
146-
subprocess.run(['ydotool', 'key', '47:1', '47:0'], capture_output=True, timeout=1)
183+
subprocess.run(['ydotool', 'key', paste_keycode_pressed, paste_keycode_released], capture_output=True, timeout=1)
147184
time.sleep(0.010)
148185
subprocess.run(['ydotool', 'key', '56:0'], capture_output=True, timeout=1)
149186

@@ -378,10 +415,11 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
378415
self._clear_stuck_modifiers()
379416
time.sleep(0.02) # Brief settle after clearing modifiers
380417

381-
# "super" -> Super+V (125:1 47:1 47:0 125:0)
382-
# "ctrl_shift" -> Ctrl+Shift+V (29:1 42:1 47:1 47:0 42:0 29:0)
383-
# "ctrl" -> Ctrl+V (29:1 47:1 47:0 29:0)
384-
# "alt" -> Alt+V (56:1 47:1 47:0 56:0)
418+
paste_keycode = self._get_paste_keycode()
419+
paste_keycode_pressed = f'{paste_keycode}:1'
420+
paste_keycode_released = f'{paste_keycode}:0'
421+
422+
# Paste chords are sent as modifiers + a configurable keycode (default KEY_V=47).
385423
paste_mode = None
386424
if self.config_manager:
387425
paste_mode = self.config_manager.get_setting('paste_mode', None)
@@ -412,25 +450,25 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
412450

413451
# Fast path for non-Kitty terminals (original behavior)
414452
if paste_mode == 'super':
415-
# LeftMeta (Super) = 125, 'V' = 47
453+
# LeftMeta (Super) = 125
416454
result = subprocess.run(
417-
['ydotool', 'key', '125:1', '47:1', '47:0', '125:0'],
455+
['ydotool', 'key', '125:1', paste_keycode_pressed, paste_keycode_released, '125:0'],
418456
capture_output=True, timeout=5
419457
)
420458
elif paste_mode == 'ctrl_shift':
421459
result = subprocess.run(
422-
['ydotool', 'key', '29:1', '42:1', '47:1', '47:0', '42:0', '29:0'],
460+
['ydotool', 'key', '29:1', '42:1', paste_keycode_pressed, paste_keycode_released, '42:0', '29:0'],
423461
capture_output=True, timeout=5
424462
)
425463
elif paste_mode == 'ctrl':
426464
result = subprocess.run(
427-
['ydotool', 'key', '29:1', '47:1', '47:0', '29:0'],
465+
['ydotool', 'key', '29:1', paste_keycode_pressed, paste_keycode_released, '29:0'],
428466
capture_output=True, timeout=5
429467
)
430468
elif paste_mode == 'alt':
431-
# LeftAlt = 56, 'V' = 47
469+
# LeftAlt = 56
432470
result = subprocess.run(
433-
['ydotool', 'key', '56:1', '47:1', '47:0', '56:0'],
471+
['ydotool', 'key', '56:1', paste_keycode_pressed, paste_keycode_released, '56:0'],
434472
capture_output=True, timeout=5
435473
)
436474
else:
@@ -440,12 +478,12 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
440478
shift_paste = self.config_manager.get_setting('shift_paste', True)
441479
if shift_paste:
442480
result = subprocess.run(
443-
['ydotool', 'key', '29:1', '42:1', '47:1', '47:0', '42:0', '29:0'],
481+
['ydotool', 'key', '29:1', '42:1', paste_keycode_pressed, paste_keycode_released, '42:0', '29:0'],
444482
capture_output=True, timeout=5
445483
)
446484
else:
447485
result = subprocess.run(
448-
['ydotool', 'key', '29:1', '47:1', '47:0', '29:0'],
486+
['ydotool', 'key', '29:1', paste_keycode_pressed, paste_keycode_released, '29:0'],
449487
capture_output=True, timeout=5
450488
)
451489

0 commit comments

Comments
 (0)