|
Last sent prematurely--I hit the wrong key. I'll put back the part that started it. Carl, Yeah, I thought that mouse control was on the wish list. Claude has written me, in Python,/p> OT: I just yesterday had Claude write me a very helpful little script for those who move windows around (especially important if you have multiple monitors): holding down the mousewheel (most mice have the feature) turns you cursor into a crosshair and allows you to move the window its over, regardless of where on the window it is. No more I also had Claude make mousewheelt horizontally (tilt left) or vertically (tilt right). P.S. I'm writing this using Hmail, Claude's emulation of Eudora for linux Mint. On Mon, 07 Sep 2026 18:58:40 +0000, Carl Distefano wrote: > Reply to note from harrybinswanger <dmarc-noreply@xxxxxxxxxxxxx> Mon, 07 Sep 2026 12:22:19 -0400 > > Harry, > > > The older version was not reporting on what was beneath the cursor > > when I right-clicked (or did anything else I could think of). > > Right-clicked? It's just an XPL script like any other. You can run it from the CMline or bind it to a key. > > But the idea of being able to bind commands or scripts to mouse events is an interesting one. I plan to look into it. > > -- > Carl Distefano > cld@xxxxxxxxxx > |
## hb.py â hbkeys config, rebuilt from HB.ahk on 2026-06-21.
#
# Syntax reminder:
# [appclass] scope following hotkeys to that WM_CLASS
# passthrough APP hbkeys gets fully out of the way for that app
# lhs => rhs static, always-on key remap
# trig::send TEXT type TEXT; {Key} presses a key; {^v} a chord
# trig::run CMD run a shell command
# trig::suspend toggle the global suspend
# trig:: indented Python below is the body
# trig can be a key, a mouse button (mbutton, lbutton, xbutton1...),
# or a wheel direction (wheelup wheeldown wheelleft wheelright)
# modifiers: ^ ctrl ! alt + shift # super
# literal brace in a send: {{} for { , {}} for }
# ---- static remaps: keyd owns these (do NOT redefine here) ----------------
# capslockâctrl, ctrlâalt, numlock, brackets, Insert/KP1 live in
# /etc/keyd/default.conf. hbkeys reads keyd's OUTPUT, so redefining them
# here remaps twice (ctrl-chords became alt-chords). They sit in keyd so
# XyScript's own hb.kbd sees capslock as a real Ctrl (CTRL=29).
# ---- ctrl cursor layer â window-aware, so it lives HERE, not keyd:
# XyScript needs the raw ctrl chords (its hb.kbd maps them itself),
# and `passthrough xyscript` below delivers exactly that. ----
^j::send {Left}
^;::send {Right}
^v::send {Down}
^t::send {Up}
^h::send {Home}
^g::send {End}
^]::send {+End}{Delete}
^[::send {+Home}{Delete}
^space::send _
^l::send {F3}{Right}
^y::send {Home}{Home}{+Down}{Delete}
^q::send {!F4}
kp0::send {^v}
^kp0::send {^Ins}
scrolllock::send {printscreen}
^w::norepeat send {^Right}{^Backspace}
# ---- copy / cut with a cork-pop (ported from HB.ahk makePOPsound) ----
^c::
norepeat
key("^c")
run("paplay ~/Dropbox/BestCorkPop.wav")
^x::
norepeat
key("^x")
run("paplay ~/Dropbox/BestCorkPop.wav")
# ---- alt+shift math / Greek symbols ----
!+v::send ÷
!+=::send ±
!+0::send °
!+1::send ¹
!+2::send ²
!+3::send ³
!+4::send ¼
!+8::send â
!+p::send Ï
!+a::send α
!+i::send Ã
!+o::send õ
^!o::send ö
!+e::send ε
!+c::send âº
!+d::send Î
f1::Send Harry
^f1::Send 4266 8435 2898 2907
^+f1::norepeat banner("11/30 452")
f2::Send Binswanger
f3::Send 5027 Rustic Oaks Circle
f4::Send Naples
f5::send FL
f6::send 34105
f7::send Harry Binswanger{Enter}5027 Rustic Oaks Circle{Enter}Naples, Fl 34105{Enter}
f8::send 917-496-3525
f9::send hb@xxxxxxxxxxxx
+f9::send harrybinswanger@xxxxxxxxx
f10::
norepeat
run("setsid ~/d/hbkeys/reload-hbkeys.sh")
banner("hbkeys reloaded", 1000)
F11::Send You hit f11
^,::send ~/
# ---- alt+shift+k: reload hbkeys (stopkeys, wait, startkeys) â detached so it
# survives killing its own daemon ----
!+k::
norepeat
run("setsid ~/d/hbkeys/reload-hbkeys.sh")
banner("hbkeys reloaded", 1000)
# ---- gnome-terminal: ^w is native delete-word-back; don't use the GUI idiom
# ({^Right}{^Backspace}) here â Ctrl-Backspace acts as plain Backspace in
# a terminal, so re-emit a real Ctrl-W and let the shell/TUI handle it. ----
[gnome-terminal]
^w::send {^w}
^+p::run gnome-terminal -- python3 ~/Dropbox/py/pwgen.py
[Hmail]
^1::send in Hmail
!i::send Regards,{Enter}Harry{Enter}
!b::send Best,{Enter}Harry{Enter}
# ---- suspend toggle (Pause key) ----
pause::suspend
# ---- XyWrite under DOSBox-X and XyScript: hbkeys steps fully aside ----
passthrough dosbox-x
passthrough xyscript
# ---- Notepad++: save config.ini, then quit & reopen Hmail ----
[notepad++.exe]
^s::
norepeat
key("^s")
if "Z:\\home\\h\\Hmail\\config.ini" in title():
run("~/.local/bin/hmail-reload-if-config.sh")
# ---- Alt+C: Claude round-trip â run the clipboard in a terminal, output back to
clipboard
!c::
norepeat
run("setsid python3 ~/Dropbox/hbkeys/hbclaude.py")
banner("Claude round-trip", 800)
# ---- F12 = area screenshot, straight into KolourPaint --------------------
# Tap F12, move the mouse to size the selection, tap F12 again to shoot (or Esc
# to cancel). No mouse button is bound anywhere: binding one makes hbkeys grab
# and relay the whole mouse, which made the pointer jerky. The second F12 is
# swallowed by this same binding, so it reaches the running selection as a
# second launch of the script, which signals the first and exits.
f12::norepeat run python3 /home/h/d/hbkeys/areashot.py
# Volume-Up is repurposed as "make the USB speakers the default sink" (it was
# c:123 in .xbindkeysrc). It no longer raises the volume.
# The K270 calculator key: never used, and a stuck/repeating press once
# launched 179 calculators in one second, filling the X server's 256-client
# limit (nothing could open a window until they were killed, 2026-09-07).
calc::norepeat banner("Calculator button has been disabled", 1500)
volumeup::norepeat run pactl set-default-sink
alsa_output.usb-Generic_USB2.0_Device_20130101ph0-00.analog-stereo
# Super+A types the name (was mod4 + a).
#a::send Harry
# NOT ported: `control + c:90` -> `copyq show`. c:90 is Ctrl+KP0, which hb.py
# already binds above as `^kp0::send {^Ins}` â hbkeys swallows the chord, so the
# xbindkeys copy of it has been dead for a while. Say which one you want on
# Ctrl+KP0 and it goes here.
# ---- Mouse: move / resize the window under focus -------------------------
# These arrive via an X grab of just the one button (no evdev mouse grab, no
# motion relay â that's what made the pointer jerky). Left-click drops the
# window, Escape cancels and restores it.
# Wheel PRESSED and HELD = drag the window under the pointer; releasing the
# wheel drops it (a real drag, via _NET_WM_MOVERESIZE)
# Wheel tilted LEFT = the right edge follows the pointer (width)
# Wheel tilted RIGHT = the bottom edge follows the pointer (height)
mbutton::
norepeat
movewindow()
# Debounced: a tilt can arrive as a burst of presses (repeat while held),
# and every extra press would re-start the resize â which looked like
# "left-click won't release it". One binding fires per burst; the op starts
# only after 600ms of tilt silence.
import time as _t
wheelleft::
norepeat
now = _t.time()
if now - state.get("tiltL", 0) > 0.6:
send("{!F8}{Right}")
state["tiltL"] = now
wheelright::
norepeat
now = _t.time()
if now - state.get("tiltR", 0) > 0.6:
send("{!F8}{Down}")
state["tiltR"] = now#env python3
"""
hbkeys â an AutoHotkey-style keyboard daemon for Linux (X11 / Cinnamon).
One program owns the keyboard: it grabs the physical keyboard(s) at the evdev
level, decides what each keystroke means (optionally per-application), and emits
the result through a virtual uinput device. Literal text / Unicode is typed via
`xdotool` instead, because uinput speaks keycodes, not characters.
See CLAUDE.md for the architecture and why it's built this way. The engine is
generic; everything you actually want to change lives in the CONFIG TABLES
section near the bottom. Add bindings there, not in the engine.
Run (from a terminal inside the Cinnamon X11 session):
python3 hbkeys.py
Ctrl-C ungrabs the keyboard and exits cleanly. If the keyboard ever wedges,
SSH in from another machine and: pkill -f hbkeys
"""
import os
import re
import sys
import time
import signal
import selectors
import subprocess
import threading
import fcntl
import evdev
from evdev import ecodes as e, UInput, InputDevice, categorize # noqa: F401
KEYD = "keyd virtual keyboard"
# ----------------------------------------------------------------------------
# Key name â evdev keycode table
# ----------------------------------------------------------------------------
# Used both for parsing triggers ("^j", "F1", "[") and output chords
# ("shift+Home", "Left"). Keep names lowercase in the lookup.
def _letters_digits():
m = {}
for c in "abcdefghijklmnopqrstuvwxyz":
m[c] = getattr(e, "KEY_" + c.upper())
for c in "0123456789":
m[c] = getattr(e, "KEY_" + c)
return m
NAME2CODE = _letters_digits()
NAME2CODE.update({
# whitespace / editing
"space": e.KEY_SPACE, "enter": e.KEY_ENTER, "return": e.KEY_ENTER,
"tab": e.KEY_TAB, "esc": e.KEY_ESC, "escape": e.KEY_ESC,
"backspace": e.KEY_BACKSPACE, "bs": e.KEY_BACKSPACE,
"delete": e.KEY_DELETE, "del": e.KEY_DELETE, "insert": e.KEY_INSERT, "ins": e.KEY_INSERT,
# navigation
"left": e.KEY_LEFT, "right": e.KEY_RIGHT, "up": e.KEY_UP, "down": e.KEY_DOWN,
"home": e.KEY_HOME, "end": e.KEY_END, "pgup": e.KEY_PAGEUP, "pgdn": e.KEY_PAGEDOWN,
"pageup": e.KEY_PAGEUP, "pagedown": e.KEY_PAGEDOWN,
# symbols (US layout, unshifted key)
"[": e.KEY_LEFTBRACE, "]": e.KEY_RIGHTBRACE, ";": e.KEY_SEMICOLON,
"'": e.KEY_APOSTROPHE, ",": e.KEY_COMMA, ".": e.KEY_DOT, "/": e.KEY_SLASH,
"\\": e.KEY_BACKSLASH, "-": e.KEY_MINUS, "=": e.KEY_EQUAL, "`": e.KEY_GRAVE,
# toggles / misc
"capslock": e.KEY_CAPSLOCK, "numlock": e.KEY_NUMLOCK, "scrolllock": e.KEY_SCROLLLOCK,
"pause": e.KEY_PAUSE, "printscreen": e.KEY_SYSRQ, "menu": e.KEY_COMPOSE,
# numeric keypad (evdev keycodes are numlock-independent; the keypad-0/Ins
# key is always KP0 here, regardless of the NumLock state the OS would use
# to decide between "0" and "Insert").
"kp0": e.KEY_KP0, "kp1": e.KEY_KP1, "kp2": e.KEY_KP2, "kp3": e.KEY_KP3,
"kp4": e.KEY_KP4, "kp5": e.KEY_KP5, "kp6": e.KEY_KP6, "kp7": e.KEY_KP7,
"kp8": e.KEY_KP8, "kp9": e.KEY_KP9, "kpdot": e.KEY_KPDOT, "kpins": e.KEY_KP0,
"kpenter": e.KEY_KPENTER, "kpplus": e.KEY_KPPLUS, "kpminus": e.KEY_KPMINUS,
"kpstar": e.KEY_KPASTERISK, "kpslash": e.KEY_KPSLASH,
})
# function keys F1..F24
for _i in range(1, 25):
NAME2CODE["f%d" % _i] = getattr(e, "KEY_F%d" % _i)
# Media / system keys â the ones a laptop's top row emits.
NAME2CODE.update({
"volumeup": e.KEY_VOLUMEUP, "volumedown": e.KEY_VOLUMEDOWN,
"mute": e.KEY_MUTE, "playpause": e.KEY_PLAYPAUSE,
"nextsong": e.KEY_NEXTSONG, "prevsong": e.KEY_PREVIOUSSONG,
"stopcd": e.KEY_STOPCD, "calc": e.KEY_CALC, "mail": e.KEY_MAIL,
"homepage": e.KEY_HOMEPAGE, "search": e.KEY_SEARCH,
"brightnessup": e.KEY_BRIGHTNESSUP, "brightnessdown": e.KEY_BRIGHTNESSDOWN,
})
# Mouse buttons. Names are exactly what `sudo evtest` prints, so what you read
# off the mouse is what you type in hb.py. Mice are only grabbed when hb.py
# actually binds one of these (see _sync_mice), so a config with no mouse
# bindings never touches the pointer.
BTN2CODE = {
"btn_left": e.BTN_LEFT, "btn_right": e.BTN_RIGHT, "btn_middle": e.BTN_MIDDLE,
"btn_side": e.BTN_SIDE, "btn_extra": e.BTN_EXTRA,
"btn_forward": e.BTN_FORWARD, "btn_back": e.BTN_BACK, "btn_task": e.BTN_TASK,
}
NAME2CODE.update(BTN2CODE)
# AHK spellings, so HB.ahk mouse hotkeys port over unchanged.
NAME2CODE.update({
"lbutton": e.BTN_LEFT, "rbutton": e.BTN_RIGHT, "mbutton": e.BTN_MIDDLE,
"xbutton1": e.BTN_SIDE, "xbutton2": e.BTN_EXTRA,
})
BTN_CODES = frozenset(BTN2CODE.values())
# The wheel as a trigger. Wheel directions have no evdev press/release, so they
# get synthetic codes far above any real keycode; like the buttons above they
# arrive via the X passive grab (see _sync_xgrabs), where X numbers them 4-7.
WHEEL2CODE = {
"wheelup": 0x1000, "wheeldown": 0x1001,
"wheelleft": 0x1002, "wheelright": 0x1003,
}
NAME2CODE.update(WHEEL2CODE)
# trigger code <-> X core button number, for the passive X grabs.
CODE2XBTN = {
e.BTN_LEFT: 1, e.BTN_MIDDLE: 2, e.BTN_RIGHT: 3,
0x1000: 4, 0x1001: 5, 0x1002: 6, 0x1003: 7,
e.BTN_SIDE: 8, e.BTN_EXTRA: 9,
}
XBTN2CODE = {v: k for k, v in CODE2XBTN.items()}
# Pointers we must never grab, matched by name. The Yoga's in-lid touchscreen
# and touchpad are disabled at the X level by ~/bin/lid-input-guard.sh while the
# lid is shut (they press against the keyboard deck and fire phantom events that
# break screenshot drags). An evdev grab here would route those phantom events
# around that guard and out our virtual pointer, undoing the fix. Keep this
# pattern in sync with PATTERNS in lid-input-guard.sh.
MOUSE_EXCLUDE = re.compile(r"wacom hid|gxtp5100|hbkeys|keyd", re.I)
# Internal buses â nothing the user plugs in. BUS_I2C / BUS_HOST.
INTERNAL_BUSES = (0x18, 0x19)
# Modifier name â the keycode we press to emit that modifier.
MOD_KEYCODE = {
"ctrl": e.KEY_LEFTCTRL, "alt": e.KEY_LEFTALT,
"shift": e.KEY_LEFTSHIFT, "super": e.KEY_LEFTMETA,
}
# AHK-style modifier prefix symbols.
MOD_SYMBOL = {"^": "ctrl", "!": "alt", "+": "shift", "#": "super"}
# Bitmask for the set of modifiers currently held (physical state).
MOD_BIT = {"ctrl": 1, "alt": 2, "shift": 4, "super": 8}
# Which physical keycodes map onto which modifier bit.
KEYCODE_MOD_BIT = {
e.KEY_LEFTCTRL: 1, e.KEY_RIGHTCTRL: 1,
e.KEY_LEFTALT: 2, e.KEY_RIGHTALT: 2,
e.KEY_LEFTSHIFT: 4, e.KEY_RIGHTSHIFT: 4,
e.KEY_LEFTMETA: 8, e.KEY_RIGHTMETA: 8,
}
ALL_MOD_KEYCODES = list(KEYCODE_MOD_BIT.keys())
# ----------------------------------------------------------------------------
# Trigger / chord parsing
# ----------------------------------------------------------------------------
def parse_trigger(spec):
""" "^+p" -> (modmask, keycode). Leading ^ ! + # are modifiers. """
mask = 0
i = 0
while i < len(spec) and spec[i] in MOD_SYMBOL:
mask |= MOD_BIT[MOD_SYMBOL[spec[i]]]
i += 1
name = spec[i:].lower()
if name not in NAME2CODE:
raise ValueError("unknown key in trigger %r (part %r)" % (spec, name))
return mask, NAME2CODE[name]
def parse_chord(spec):
""" "shift+Home" -> (set_of_mod_names, keycode). "Left" -> (set(), KEY_LEFT). """
parts = spec.split("+")
key = parts[-1].lower()
mods = set(p.lower() for p in parts[:-1])
for m in mods:
if m not in MOD_KEYCODE:
raise ValueError("unknown modifier %r in chord %r" % (m, spec))
if key not in NAME2CODE:
raise ValueError("unknown key %r in chord %r" % (key, spec))
return mods, NAME2CODE[key]
# ----------------------------------------------------------------------------
# Actions (what a binding does when it fires)
# ----------------------------------------------------------------------------
# Each action is a tagged tuple. The engine interprets them in fire().
def Keys(*chords):
"""Emit a sequence of key chords through uinput (modifier-clean)."""
return ("keys", [parse_chord(c) for c in chords])
def Text(s):
"""Type literal text / Unicode via xdotool."""
return ("text", s)
def Run(cmd):
"""Run a shell command (detached)."""
return ("run", cmd)
def Func(fn):
"""Call a Python function (engine internals: suspend toggle, etc.)."""
return ("func", fn)
class Binding:
__slots__ = ("action", "permit", "repeat")
def __init__(self, action, permit=False, repeat=False):
self.action = action
self.permit = permit # still fires while suspended
self.repeat = repeat # fires on autorepeat (value==2) too
# ----------------------------------------------------------------------------
# Active-window context (X11 WM_CLASS) â runs in a background thread
# ----------------------------------------------------------------------------
class WindowWatcher(threading.Thread):
"""Tracks the active window's WM_CLASS (lowercased) so hotkeys can be
scoped per application, reproducing AutoHotkey's #IfWinActive."""
daemon = True
def __init__(self):
super().__init__()
self._cls = ""
try:
from Xlib import X, display
self._X = X
self._dpy = display.Display()
self._root = self._dpy.screen().root
self._NET_ACTIVE = self._dpy.intern_atom("_NET_ACTIVE_WINDOW")
self._root.change_attributes(event_mask=X.PropertyChangeMask)
self._ok = True
except Exception as ex:
sys.stderr.write("WindowWatcher disabled (no X11?): %s\n" % ex)
self._ok = False
@property
def cls(self):
return self._cls
def _read_active(self):
try:
win_id = self._root.get_full_property(self._NET_ACTIVE, self._X.AnyPropertyType)
if not win_id or not win_id.value:
return
win = self._dpy.create_resource_object("window", win_id.value[0])
wm = win.get_wm_class() # (instance, class)
self._cls = (wm[1] if wm else "").lower()
except Exception:
pass
def run(self):
if not self._ok:
return
self._read_active()
while True:
ev = self._dpy.next_event()
if ev.type == self._X.PropertyNotify and ev.atom == self._NET_ACTIVE:
self._read_active()
# ----------------------------------------------------------------------------
# The suspend banner (a separate process so tkinter's mainloop is isolated)
# ----------------------------------------------------------------------------
_BANNER_CODE = r'''
import tkinter as tk
r = tk.Tk()
r.overrideredirect(True)
r.attributes("-topmost", True)
try: r.attributes("-type", "splash")
except Exception: pass
r.configure(bg="#cc0000")
w, h = 260, 70
sw = r.winfo_screenwidth()
r.geometry("%dx%d+%d+%d" % (w, h, (sw - w)//2, 24))
tk.Label(r, text="hbkeys SUSPENDED", fg="white", bg="#cc0000",
font=("DejaVu Sans", 12, "bold")).pack(expand=True, fill="both")
r.mainloop()
'''
class Banner:
def __init__(self):
self._proc = None
def show(self):
if self._proc is None:
self._proc = subprocess.Popen([sys.executable, "-c", _BANNER_CODE])
def hide(self):
if self._proc is not None:
self._proc.terminate()
self._proc = None
# A general-purpose info banner: a topmost splash that shows arbitrary text and
# self-dismisses after `ms` (or on click / any key). Text + duration come in via
# argv so one template serves every banner() call from hb.py.
_POPUP_CODE = r'''
import sys, tkinter as tk
text, ms = sys.argv[1], int(sys.argv[2])
r = tk.Tk()
r.overrideredirect(True)
r.attributes("-topmost", True)
try: r.attributes("-type", "splash")
except Exception: pass
r.configure(bg="#1060c0")
tk.Label(r, text=text, fg="white", bg="#1060c0", justify="center",
font=("DejaVu Sans", 28, "bold"), padx=40, pady=24).pack(expand=True, fill="both")
r.update_idletasks()
w, h = r.winfo_reqwidth(), r.winfo_reqheight()
r.geometry("%dx%d+%d+%d" % (w, h, (r.winfo_screenwidth() - w)//2, 40))
r.bind("<Button-1>", lambda e: r.destroy())
r.bind("<Key>", lambda e: r.destroy())
if ms > 0:
r.after(ms, r.destroy)
r.mainloop()
'''
# ----------------------------------------------------------------------------
# The engine
# ----------------------------------------------------------------------------
GLOBAL = None # context key meaning "applies in every application"
# Marker file mirroring the in-memory suspend state to disk, so an external
# watchdog can tell "deliberately suspended" from "crashed" and leave a
# suspended daemon down instead of relaunching it. Present == suspended.
SUSPEND_FLAG = "/tmp/hbkeys-suspended-%d" % os.getuid()
class HBKeys:
def __init__(self):
self.static_remap = {} # {keycode: keycode}
self.auto_suspend = set()
self.table = {} # {(ctx,mask,key): Binding}
self.state = {} # persistent scratch for hb.py logic (survives reload)
self.window = WindowWatcher()
self.banner = Banner()
self.suspended = False
self.held = set() # physical keycodes currently down (post static remap)
self.modmask = 0 # cached from self.held
self.swallowed = set() # keycodes whose press we ate; eat their release too
self.devices = []
self.ui = None
self.mice = [] # grabbed pointer devices (only if hb.py binds a button)
self.mouse_paths = set()
self.ui_ptr = None # virtual pointer; separate device, like keyd's
self._pending = [] # uinput devices with events awaiting a syn
self.sel = None
# -- build the lookup table -------------------------------------------
def _compile(self, bindings):
table = {}
for entry in bindings:
ctx, trig, action = entry[0], entry[1], entry[2]
opts = entry[3] if len(entry) > 3 else {}
mask, key = parse_trigger(trig)
ckey = ctx.lower() if isinstance(ctx, str) else ctx
table[(ckey, mask, key)] = Binding(action, **opts)
return table
def lookup(self, ctx, mask, key):
return (self.table.get((ctx, mask, key))
or self.table.get((GLOBAL, mask, key)))
# -- live reload (hb.py is polled for changes in the run loop) ---------
def _hb_mtime(self):
try:
return os.path.getmtime(HB_PATH)
except OSError:
return None
# -- helpers exposed to hb.py's compiled actions ----------------------
# Inside a hb.py block these names are in scope (see _namespace): a plain
# `send("x\Enter\y")`, `run(...)`, `window()`, `clip()`, `suspend()`,
# `showclass()`, and the persistent `state` dict.
def _send(self, s):
"""Emit a send-string: literal text, with {KeyName} / {^v} chords
pressed as real keys. e.g. "foo{Enter}bar" or "{Left}". Send a
literal brace with {{} or {}}; a stray unmatched brace is literal text.
Braces (not backslashes) so a string can safely end in a key token â
"{Backspace}" is valid Python, r"\\Backspace\\" is not."""
for i, part in enumerate(re.split(r"\{(\{|\}|[^{}]*)\}", s)):
if i % 2 == 0: # literal text between the {...} tokens
if part:
self.emit_text(part)
elif part in ("{", "}"): # {{} / {}} -> a literal brace
self.emit_text(part)
elif part: # a {KeyName} or {^v} chord token
self.emit_keys([ahk_chord(part)])
def _key(self, *chords):
"""Press one or more key chords, no braces: key("^c") /
key("+End", "Delete"). Use this in blocks; {Key} notation is for
the one-liner `send` form (or inside a send("...") string)."""
self.emit_keys([ahk_chord(c) for c in chords])
def _run(self, cmd):
subprocess.Popen(cmd, shell=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _banner(self, text, ms=6000):
"""Pop a topmost info banner showing `text`; auto-closes after `ms`
milliseconds (0 = stay until clicked / any key). Each call is its own
process, so banners stack rather than replace each other."""
subprocess.Popen([sys.executable, "-c", _POPUP_CODE, str(text), str(ms)])
def _clip(self):
for tool in (["xclip", "-o", "-selection", "clipboard"], ["xsel", "-b"]):
try:
return subprocess.run(tool, capture_output=True, text=True,
check=False).stdout
except FileNotFoundError:
continue
return ""
def _title(self):
"""Active window title, read fresh via xdotool. On-demand (not cached
like window()) so per-file hotkeys see Npp tab-switches and the unsaved
'*' correctly, and so this thread never touches the watcher's Xlib
display."""
try:
r = subprocess.run(["xdotool", "getactivewindow", "getwindowname"],
capture_output=True, text=True, check=False)
return r.stdout.rstrip("\n")
except FileNotFoundError:
return ""
def _namespace(self, bindings, static_remap, auto_suspend):
"""Globals for exec-ing translated hb.py. The friendly verbs resolve
to bound methods here; _bind/_remap/_passthrough collect the tables."""
def _bind(trigger, ctx, fn, permit=False, repeat=True):
bindings.append((ctx, trigger, ("call", fn),
{"repeat": repeat, "permit": permit}))
def _remap(lhs, rhs):
static_remap[name2code(lhs)] = name2code(rhs)
def _passthrough(name):
auto_suspend.append(name)
return {
"send": self._send, "key": self._key, "run": self._run,
"banner": self._banner,
"clip": self._clip, "window": (lambda: self.window.cls),
"title": self._title,
"suspend": self.toggle_suspend, "showclass": self.show_context,
"movewindow": self._movewin,
"state": self.state, "e": e,
"_bind": _bind, "_remap": _remap, "_passthrough": _passthrough,
}
def _load(self, initial=False):
"""Translate hb.py from the friendly syntax to Python, exec it, and
hot-swap the tables. Built fully before anything live is replaced, so a
keystroke never sees a half-applied config. A broken hb.py is fatal at
startup but ignored on live reload (keep the running config), so a
mid-edit save can't kill the daemon."""
bindings, static_remap, auto_suspend = [], {}, []
raw, src, linemap = "", "", []
try:
if os.path.exists(HB_PATH):
with open(HB_PATH) as f:
raw = f.read()
src, linemap = translate(raw)
exec(compile(src, "hb.py", "exec"),
self._namespace(bindings, static_remap, auto_suspend))
table = self._compile(bindings)
except Exception as ex:
# Map the line number back to the hb.py line the user actually wrote.
msg = _hb_error(ex, linemap, raw.split("\n"))
sys.stderr.write("hbkeys: error in hb.py: %s\n" % msg)
if initial:
sys.exit(1)
# Visible splash (notifications may be off) â stays until clicked so
# the error can be read; small monospace, since it may be multi-line.
subprocess.Popen([sys.executable, POPUP_PATH,
"hb.py error:\n%s" % msg, "0", "11"])
return
self.table = table
self.static_remap = static_remap
self.auto_suspend = set(c.lower() for c in auto_suspend)
self._sync_mice() # grab / release the mouse to match the new table
self._sync_xgrabs() # X passive grabs for mouse-button/wheel triggers
if not initial:
sys.stderr.write("hbkeys: reloaded hb.py\n")
subprocess.Popen([sys.executable, POPUP_PATH, "reloaded hb.py", "1000"])
def reload(self):
self._load(initial=False)
# -- uinput emission helpers ------------------------------------------
# Button codes go out the virtual POINTER, everything else the virtual
# keyboard, so a chord that mixes the two (ctrl+btn_left) still works.
def _dev_for(self, code):
return self.ui_ptr if (code in BTN_CODES and self.ui_ptr) else self.ui
def _w(self, code, value):
dev = self._dev_for(code)
dev.write(e.EV_KEY, code, value)
if dev not in self._pending:
self._pending.append(dev)
def _syn(self):
for dev in self._pending:
dev.syn()
del self._pending[:]
def _clean_mods_release(self):
"""Release every physically-held modifier; return list to restore."""
held = [mc for mc in ALL_MOD_KEYCODES if mc in self.held]
for mc in held:
self._w(mc, 0)
if held:
self._syn()
return held
def _clean_mods_restore(self, held):
for mc in held:
self._w(mc, 1)
if held:
self._syn()
def emit_keys(self, chords):
held = self._clean_mods_release()
for mods, key in chords:
mkeys = [MOD_KEYCODE[m] for m in mods]
for mc in mkeys:
self._w(mc, 1)
self._w(key, 1)
self._w(key, 0)
for mc in reversed(mkeys):
self._w(mc, 0)
self._syn()
self._clean_mods_restore(held)
def emit_text(self, s):
held = self._clean_mods_release()
try:
subprocess.run(["xdotool", "type", "--clearmodifiers", "--", s],
check=False)
finally:
self._clean_mods_restore(held)
# -- suspend ----------------------------------------------------------
def _set_suspend_flag(self, on):
"""Reflect suspend state in the on-disk marker the watchdog reads."""
try:
if on:
open(SUSPEND_FLAG, "w").close()
elif os.path.exists(SUSPEND_FLAG):
os.remove(SUSPEND_FLAG)
except OSError:
pass
def toggle_suspend(self):
self.suspended = not self.suspended
self._set_suspend_flag(self.suspended)
if self.suspended:
self.banner.show()
sys.stderr.write("hbkeys: SUSPENDED\n")
else:
self.banner.hide()
sys.stderr.write("hbkeys: resumed\n")
def show_context(self):
cls = self.window.cls or "(none)"
subprocess.run(["notify-send", "-t", "1500", "hbkeys context", cls],
check=False)
# -- fire an action ---------------------------------------------------
def fire(self, binding, value):
# autorepeat: only repeat bindings flagged repeat=True
if value == 2 and not binding.repeat:
return
if value == 0: # press already handled; eat the release
return
try:
self._exec_action(binding.action)
except Exception as ex:
# a buggy hb.py action must never crash the daemon (that would
# wedge the grabbed keyboard). Report and carry on.
sys.stderr.write("hbkeys: action error: %s\n" % ex)
subprocess.run(["notify-send", "-t", "3000",
"hbkeys: action error", str(ex)], check=False)
def _exec_action(self, action):
kind = action[0]
if kind == "keys":
self.emit_keys(action[1])
elif kind == "text":
self.emit_text(action[1])
elif kind == "run":
self._run(action[1])
elif kind == "func": # bound method taking self
action[1](self)
elif kind == "call": # hb.py-compiled action; helpers already bound
action[1]()
# -- per-event handling ----------------------------------------------
def handle(self, code, value):
ctx = self.window.cls
# Full passthrough for auto-suspend apps (e.g. XyWrite under dosbox-x):
# no static remap, no hotkeys â the app owns the keyboard.
if ctx in self.auto_suspend:
self._w(code, value)
self._syn()
return
# Static remap (capslock->ctrl, numlock->capslock, ...)
code = self.static_remap.get(code, code)
# Track physical modifier / held state on the post-remap keycode.
if value == 1:
self.held.add(code)
elif value == 0:
self.held.discard(code)
if code in KEYCODE_MOD_BIT:
self.modmask = 0
for kc in self.held:
self.modmask |= KEYCODE_MOD_BIT.get(kc, 0)
# If we swallowed this key's press, swallow its release too.
if value == 0 and code in self.swallowed:
self.swallowed.discard(code)
return
binding = self.lookup(ctx, self.modmask, code)
# Suspended: only permit-flagged bindings fire; everything else passes.
if self.suspended and not (binding and binding.permit):
self._w(code, value)
self._syn()
return
if binding:
if value == 1:
self.swallowed.add(code)
self.fire(binding, value)
return
# Default: pass the (possibly static-remapped) key straight through.
self._w(code, value)
self._syn()
# -- mouse triggers via X passive grabs -------------------------------
# grab_button() takes only the ONE button+modifier combo from the X
# server, so pointer motion never passes through hbkeys â this replaced
# the evdev grab-and-relay, which made the pointer jerky. The grab is
# synchronous: X freezes the press until we decide. A press whose binding
# fires is swallowed (AsyncPointer) and the automatic grab dropped at
# once, so muffin can take its own pointer grab for an interactive move;
# anything else is replayed to the app untouched â so in XyScript,
# while suspended, or for an unbound combo the click still lands.
def _xgrab_setup(self):
try:
from Xlib import X, display
self._XG = X
d = display.Display()
d.set_error_handler(lambda *a, **k: 0) # BadAccess etc.: ignore
self.xdisp = d
self.xroot = d.screen().root
self.xmods = {1: X.ControlMask, 2: X.Mod1Mask,
4: X.ShiftMask, 8: X.Mod4Mask}
self.xgrabbed = set()
return True
except Exception as ex:
sys.stderr.write("hbkeys: X button grabs disabled: %s\n" % ex)
self.xdisp = None
return False
def _xmask(self, mask):
m = 0
for bit, xm in self.xmods.items():
if mask & bit:
m |= xm
return m
def _sync_xgrabs(self):
if getattr(self, "xdisp", None) is None:
return
X = self._XG
want = set()
for (_ctx, mask, key) in self.table:
xb = CODE2XBTN.get(key)
if xb:
base = self._xmask(mask)
# grab each Lock/NumLock variant, or those states mask us out
for extra in (0, X.LockMask, X.Mod2Mask, X.LockMask | X.Mod2Mask):
want.add((xb, base | extra))
for (xb, xm) in self.xgrabbed - want:
self.xroot.ungrab_button(xb, xm)
for (xb, xm) in want - self.xgrabbed:
self.xroot.grab_button(xb, xm, False, X.ButtonPressMask,
X.GrabModeSync, X.GrabModeAsync, 0, 0)
self.xgrabbed = want
self.xdisp.flush()
def _movewin(self, direction=8, button=2):
"""EWMH _NET_WM_MOVERESIZE: give the window under the pointer to the
WM for an interactive move (direction 8) or resize (0-7 = edges) that
muffin itself ends the moment `button` is physically released â the
hold-and-drag feel, unlike the Alt+F7 mode's click-to-drop. Escape
still cancels and restores."""
if getattr(self, "xdisp", None) is None:
return
from Xlib import protocol
d, X = self.xdisp, self._XG
p = self.xroot.query_pointer()
win, node, wm_state = None, p.child, d.intern_atom("WM_STATE")
while node: # descend to the managed client window
if node.get_full_property(wm_state, 0):
win = node
break
node = node.query_pointer().child
if win is None:
return
ev = protocol.event.ClientMessage(
window=win, client_type=d.intern_atom("_NET_WM_MOVERESIZE"),
data=(32, [p.root_x, p.root_y, direction, button, 2]))
self.xroot.send_event(
ev, event_mask=X.SubstructureRedirectMask | X.SubstructureNotifyMask)
d.flush()
if direction == 8:
self._movewin_watch(button)
def _movewin_watch(self, button):
"""Muffin ignores the message's button field and runs the move as
click-to-drop, so the drag would NOT end on release by itself. This
watcher listens to the mice READ-ONLY (no grab, no relay â pointer
stays smooth) for the physical release of `button`, then sends the
confirming left-click via XTEST. If Escape cancelled the move first,
muffin's pointer grab is gone and no click is sent (probed with a
grab attempt), so no stray click ever reaches an app."""
if getattr(self, "_mw_thread", None) and self._mw_thread.is_alive():
return
want = {2: e.BTN_MIDDLE, 1: e.BTN_LEFT, 3: e.BTN_RIGHT}.get(button)
def watch():
try:
import select as _select
from Xlib import X, display
from Xlib.ext import xtest
d = display.Display()
root = d.screen().root
if root.query_pointer().mask & (1 << (7 + button)):
devs, deadline, released = self._mouse_devices(), time.time() + 60, False
while time.time() < deadline and not released:
r, _, _ = _select.select(devs, [], [], 0.5)
for dv in r:
try:
evs = list(dv.read())
except OSError:
continue
if any(v.type == e.EV_KEY and v.code == want and v.value == 0
for v in evs):
released = True
for dv in devs:
try: dv.close()
except Exception: pass
if not released:
d.close(); return
# Confirm only while the WM still holds its move grab.
st = root.grab_pointer(False, 0, X.GrabModeAsync, X.GrabModeAsync,
0, 0, X.CurrentTime)
if st == X.GrabSuccess:
d.ungrab_pointer(X.CurrentTime)
else:
xtest.fake_input(d, X.ButtonPress, 1)
xtest.fake_input(d, X.ButtonRelease, 1)
d.sync(); d.close()
except Exception as ex:
sys.stderr.write("hbkeys: movewin watcher: %s\n" % ex)
self._mw_thread = threading.Thread(target=watch, daemon=True)
self._mw_thread.start()
def _handle_xbtn(self):
d, X = self.xdisp, self._XG
while d.pending_events():
ev = d.next_event()
if ev.type != X.ButtonPress:
continue
code = XBTN2CODE.get(ev.detail)
ctx = self.window.cls
binding = None
if code is not None and ctx not in self.auto_suspend:
binding = self.lookup(ctx, self.modmask, code)
if binding and not (self.suspended and not binding.permit):
d.allow_events(X.AsyncPointer, X.CurrentTime)
d.ungrab_pointer(X.CurrentTime) # end the automatic grab NOW
d.flush() # (muffin needs the pointer)
self.fire(binding, 1)
else:
d.allow_events(X.ReplayPointer, X.CurrentTime)
d.flush()
# -- the mouse (opt-in: grabbed only while hb.py binds a button) ------
# Swallowing a button requires an EVIOCGRAB of the whole device, so every
# other pointer event has to be relayed through our own virtual pointer.
# That is a real cost (a wedged daemon = a frozen pointer), so we pay it
# only when a binding actually needs it. The grab dies with the process,
# so a crash returns the mouse rather than losing it.
def _wants_mouse(self):
# Mouse triggers now come in via the X passive grab (_sync_xgrabs);
# the evdev grab-and-relay below made pointer motion jerky (2026-09-06)
# and is kept only as dormant machinery.
return False
def _mouse_devices(self):
"""Real mice: relative motion + a left button, on an external bus.
Excludes our own virtual pointer and keyd's (either would build a
feedback loop) and the in-lid devices (see MOUSE_EXCLUDE)."""
found = []
for path in evdev.list_devices():
try:
d = InputDevice(path)
except Exception:
continue
try:
caps = d.capabilities()
if (e.BTN_LEFT in caps.get(e.EV_KEY, []) and e.EV_REL in caps
and not MOUSE_EXCLUDE.search(d.name)
and d.info.bustype not in INTERNAL_BUSES):
found.append(d)
continue
except Exception:
pass
try: d.close()
except Exception: pass
return found
def _sync_mice(self):
"""Bring the pointer grab in line with the current binding table."""
if self.sel is None: # not running yet; run() calls us again
return
want = self._wants_mouse()
if want and not self.mice:
devs = self._mouse_devices()
if not devs:
sys.stderr.write("hbkeys: hb.py binds a mouse button but no mouse was found\n")
return
try:
self.ui_ptr = UInput.from_device(*devs, name="hbkeys-virtual-pointer")
except Exception as ex:
sys.stderr.write("hbkeys: from_device failed (%s); using generic pointer caps\n" % ex)
self.ui_ptr = UInput({
e.EV_KEY: sorted(BTN_CODES),
e.EV_REL: [e.REL_X, e.REL_Y, e.REL_WHEEL, e.REL_HWHEEL],
}, name="hbkeys-virtual-pointer")
time.sleep(0.2) # let X see the new device before we grab
for d in devs:
try:
d.grab()
except Exception as ex:
sys.stderr.write("hbkeys: cannot grab %s (%s)\n" % (d.name, ex))
continue
self.sel.register(d, selectors.EVENT_READ)
self.mice.append(d)
sys.stderr.write("hbkeys: grabbed mouse %s (%s)\n" % (d.path, d.name))
self.mouse_paths = {d.path for d in self.mice}
elif not want and self.mice:
self._ungrab_mice()
def _ungrab_mice(self):
for d in self.mice:
try: self.sel.unregister(d)
except Exception: pass
try: d.ungrab()
except Exception: pass
try: d.close()
except Exception: pass
self.mice = []
self.mouse_paths = set()
if self.ui_ptr:
try: self.ui_ptr.close()
except Exception: pass
self.ui_ptr = None
sys.stderr.write("hbkeys: released the mouse (no button bindings)\n")
# -- setup / teardown -------------------------------------------------
def _grab_devices(self):
kbds = []
for path in evdev.list_devices():
try:
d = InputDevice(path)
except PermissionError:
sys.stderr.write("No permission for %s (are you in the 'input' group?)\n" % path)
continue
caps = d.capabilities().get(e.EV_KEY, [])
# a real keyboard has letter keys; skip our own virtual device & mice
if e.KEY_A in caps and e.KEY_Z in caps and "hbkeys" not in d.name.lower():
kbds.append(d)
kbds = [d for d in kbds if d.name == KEYD] or kbds
if not kbds:
sys.stderr.write(
"hbkeys: no grabbable keyboard found.\n"
" Most likely you are not in the 'input' group yet. Fix with:\n"
" sudo usermod -aG input $USER\n"
" then log out and back in.\n")
sys.exit(1)
# Build the virtual output device from the real keyboards' capabilities.
self.ui = UInput.from_device(*kbds, name="hbkeys-virtual")
time.sleep(0.2) # let the new device settle before grabbing
for d in kbds:
d.grab()
sys.stderr.write("hbkeys: grabbed %s (%s)\n" % (d.path, d.name))
self.devices = kbds
def _keyboards_changed(self):
"""True if the set of real keyboards on the system differs from
the set we grabbed at startup (e.g. a KVM switch)."""
present = set()
for path in evdev.list_devices():
try:
d = InputDevice(path)
except Exception:
continue
try:
caps = d.capabilities().get(e.EV_KEY, [])
if e.KEY_A in caps and e.KEY_Z in caps \
and "hbkeys" not in d.name.lower():
present.add((path, d.name))
except Exception:
pass
finally:
try: d.close()
except Exception: pass
present = {p for p, n in present if n == KEYD} or {p for p, _ in present}
return present != {d.path for d in self.devices}
def _release(self, *_):
for d in self.devices + self.mice:
try: d.ungrab()
except Exception: pass
try:
if self.ui_ptr: self.ui_ptr.close()
except Exception: pass
try: self.banner.hide()
except Exception: pass
try:
if self.ui: self.ui.close()
except Exception: pass
sys.stderr.write("\nhbkeys: released keyboard. Bye.\n")
sys.exit(0)
def run(self):
self._load(initial=True) # fatal if hb.py is broken at startup
self._set_suspend_flag(False) # a fresh start is never suspended
self.window.start()
self._grab_devices()
signal.signal(signal.SIGTERM, self._release)
sel = selectors.DefaultSelector()
self.sel = sel
for d in self.devices:
sel.register(d, selectors.EVENT_READ)
self._sync_mice() # hb.py was loaded before the selector existed
if self._xgrab_setup():
sel.register(self.xdisp, selectors.EVENT_READ)
self._sync_xgrabs()
sys.stderr.write("hbkeys: running. Ctrl-C to quit.\n")
self._mtime = self._hb_mtime() # baseline for live-reload polling
try:
while True:
# 1s timeout so an idle daemon still notices an hb.py save;
# keystrokes return immediately, so this adds no input latency.
try:
for key, _ in sel.select(timeout=1.0):
dev = key.fileobj
if dev is getattr(self, "xdisp", None):
self._handle_xbtn()
continue
is_mouse = dev.path in self.mouse_paths
for ev in dev.read():
if ev.type == e.EV_KEY:
self.handle(ev.code, ev.value)
# EV_MSC scancodes and source SYN are intentionally
# dropped; we generate our own clean SYN stream.
elif is_mouse:
# A grabbed mouse is ours to relay: motion and
# wheel go straight out the virtual pointer,
# frame boundaries preserved so motion stays
# smooth. (EV_MSC still dropped, as above.)
if ev.type == e.EV_SYN:
if ev.code == e.SYN_REPORT and self.ui_ptr:
self.ui_ptr.syn()
elif ev.type != e.EV_MSC and self.ui_ptr:
self.ui_ptr.write(ev.type, ev.code, ev.value)
except OSError:
# A grabbed keyboard vanished (KVM switched away).
# Exit cleanly; the watchdog relaunches us in ~2s to
# grab whatever keyboards are still present.
sys.stderr.write("hbkeys: keyboard vanished; restarting via watchdog.\n")
self._release()
sys.exit(0)
if not getattr(self, "suspended", False) and self._keyboards_changed():
# A keyboard appeared or disappeared (KVM switched back).
# Restart via the watchdog so we re-grab everything.
sys.stderr.write("hbkeys: keyboard set changed; restarting via watchdog.\n")
self._release()
sys.exit(0)
m = self._hb_mtime()
if m != self._mtime:
self._mtime = m
self.reload()
except KeyboardInterrupt:
self._release()
# ============================================================================
# CONFIG â all key assignments live in hb.py, written in the friendly syntax
# below and translated to Python on every save (live-reloaded). Edit hb.py,
# not this file.
# ============================================================================
HB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hb.py")
POPUP_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "popup.py")
def name2code(name):
"""Resolve a friendly key/modifier name to an evdev keycode (for => remaps
and chords): a key name ("capslock", "[") or a modifier word ("ctrl")."""
n = name.lower()
if n in NAME2CODE:
return NAME2CODE[n]
if n in MOD_KEYCODE:
return MOD_KEYCODE[n]
raise ValueError("unknown key name %r" % name)
def ahk_chord(token):
""" "^v" -> ({"ctrl"}, KEY_V). "Enter" -> (set(), KEY_ENTER). Leading
^ ! + # are modifiers; the rest is a key name."""
mods, i = [], 0
while i < len(token) and token[i] in MOD_SYMBOL:
mods.append(MOD_SYMBOL[token[i]])
i += 1
name = token[i:].lower()
if name not in NAME2CODE:
raise ValueError("unknown key %r in chord %r" % (name, token))
return (set(mods), NAME2CODE[name])
def _stmt(rest):
"""Translate a one-line action (the part after `::`) to a Python statement.
`send X` / `run X` pass X as a raw string; `suspend`/`showclass`/`ignore`
are verbs; anything else is treated as a verbatim Python statement."""
head = rest.split(None, 1)
verb = head[0].lower() # verbs are case-insensitive (AHK-style)
arg = head[1] if len(head) > 1 else ""
if verb in ("send", "run"):
return "%s(%r)" % (verb, arg)
if verb == "suspend":
return "suspend()"
if verb in ("showclass", "context", "class"):
return "showclass()"
if verb in ("ignore", "nothing", "noop"):
return "pass"
return rest # power users: a bare Python call, e.g. ^x::myhelper()
def translate(text):
"""Friendly hb.py syntax -> Python source. Lines:
# comment / blank -> passed through (blank also ends a block)
[appclass] / [global] -> sets the context for following hotkeys
capslock => ctrl -> static, always-on key remap
passthrough dosbox-x -> app where hbkeys fully gets out of the way
^!+p::send foo\\Enter\\bar -> one-line hotkey
^!+p:: -> hotkey whose body is the indented Python
if window()=="x": return block beneath it (if/return/indent are
send("...") real Python)
Any other line is emitted verbatim, so top-level `def`s, imports, and
state assignments in hb.py just work.
Returns (python_source, linemap). Each hotkey expands to several Python
lines, so the generated source drifts out of step with hb.py; linemap[k]
is the 1-based hb.py line that produced generated line k+1, letting a
compile/exec error's line number be mapped back to what the user wrote."""
lines = text.split("\n")
out, linemap = [], []
ctx, i, n, fn = "None", 0, len(lines), 0
def emit(code, src): # src = 1-based hb.py line it came from
out.append(code); linemap.append(src)
while i < n:
line = lines[i]
s = line.strip()
ln = i + 1 # 1-based source line of `line`
if s == "": # blank ends a context (and any block)
ctx = "None"; emit("", ln); i += 1; continue
if s.startswith("#"):
emit(line, ln); i += 1; continue
m = re.match(r"^\[(.+?)\]$", s) # [firefox] / [global]
if m:
name = m.group(1).strip()
ctx = "None" if name.lower() == "global" else repr(name)
i += 1; continue
m = re.match(r"^(\S+)\s*=>\s*(\S+)$", s) # capslock => ctrl
if m:
emit("_remap(%r, %r)" % (m.group(1), m.group(2)), ln); i += 1; continue
m = re.match(r"^passthrough\s+(\S+)$", s) # passthrough dosbox-x
if m:
emit("_passthrough(%r)" % m.group(1), ln); i += 1; continue
m = re.match(r"^(\S+?)::(.*)$", line) # trigger:: [rest]
if m:
trig, rest = m.group(1).strip(), m.group(2).strip()
fn += 1; fname = "__hk%d" % fn
emit("def %s():" % fname, ln)
permit, repeat = False, True # all bindings repeat unless `norepeat`
if rest:
if rest.split(None, 1)[0].lower() == "norepeat":
repeat = False
rest = rest[len("norepeat"):].strip()
emit(" " + (_stmt(rest) if rest else "pass"), ln)
# the suspend toggle must survive suspension, or you can't resume
permit = bool(rest) and rest.split(None, 1)[0].lower() == "suspend"
i += 1
else: # gather the indented block below
i += 1
base, emitted = None, False
while i < n and lines[i].strip() != "":
bl = lines[i]
indent = len(bl) - len(bl.lstrip())
if indent == 0:
break # dedent to col 0 ends the block
if base is None:
base = indent
body_line = bl[base:]
if not emitted and body_line.strip().lower() == "norepeat":
repeat = False; i += 1; continue # first-line flag, consumed
emit(" " + body_line, i + 1)
emitted = True; i += 1
if not emitted:
emit(" pass", ln)
emit("_bind(%r, %s, %s, %s, %s)" % (trig, ctx, fname, permit, repeat), ln)
continue
emit(line, ln); i += 1 # verbatim Python passthrough
return "\n".join(out), linemap
def _hb_error(ex, linemap, srclines):
"""Render an hb.py load error with the line number mapped back to the line
the user actually wrote â translate() expands each hotkey to several lines,
so a raw compile/exec line number points into the generated Python."""
def describe(gline):
hl = linemap[gline - 1] if gline and 1 <= gline <= len(linemap) else gline
text = srclines[hl - 1].strip() if hl and 1 <= hl <= len(srclines) else ""
return hl, text
if isinstance(ex, SyntaxError) and ex.lineno:
hl, text = describe(ex.lineno)
return "line %s: %s\n %s" % (hl, ex.msg, text)
# a runtime error while exec-ing a top-level statement: walk to the last
# frame that lives in the generated hb.py source and map its line.
gline, tb = None, getattr(ex, "__traceback__", None)
while tb:
if tb.tb_frame.f_code.co_filename == "hb.py":
gline = tb.tb_lineno
tb = tb.tb_next
if gline is not None:
hl, text = describe(gline)
return "line %s: %s: %s\n %s" % (hl, type(ex).__name__, ex, text)
return "%s: %s" % (type(ex).__name__, ex)
def main():
_LOCK_PATH = f"/tmp/hbkeys-{os.getuid()}.lock"
_lock_fh = open(_LOCK_PATH, "w")
try:
fcntl.flock(_lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
sys.exit("hbkeys: already running â exiting")
if not os.environ.get("DISPLAY"):
sys.stderr.write("hbkeys: no $DISPLAY â run from inside the graphical session.\n")
HBKeys().run()
if __name__ == "__main__":
main()