[Date Prev][Date Next][Subject Prev][Subject Next][Date Index][Subject Index]

Re: A keyboard attempt plus random comments



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()