Skip to content

Hyprland: shortcuts are registered with the compositor but never bound to a key #1723

Open

Description

@ThePsyberSleuth

What happens

On Hyprland, Hex registers its actions with the compositor — hyprctl globalshortcuts lists anomaly.hex:dictation, anomaly.hex:paste-last, anomaly.hex:capture — but nothing binds a key to them.

So a user turns on dictation in Settings, presses the shortcut they recorded, and hears nothing. Making it work means discovering hl.dsp.global(...) and hand-writing a bind into their Hyprland config. Every other desktop Hex supports gets a working shortcut from Settings alone. This is not the same claim as the docs bug in #848 (the documented snippet used the old app id) — the current ids are correct, there is simply no bind on the user side of them.

Hex deliberately doesn't read /dev/input, so the compositor has to hold the keys. That leaves two routes: the GlobalShortcuts portal (what exists today, which Hyprland's portal advertises but does not bind), or the compositor-native one.

Current behaviour, concretely

One approach: bind through hyprctl eval

Neutral description of a route that has been prototyped; not a claim that it is the right one.

For each shortcut recorded in Settings, emit one Lua bind through hyprctl eval:

hl.bind("ALT + F10", hl.dsp.global("anomaly.hex:dictation"))

and keep the returned HL.Keybind handles so removal goes through the handle. hl.unbind(keys) is deliberately avoided for Hex's own binds — it removes every bind on those keys, including another application's. Details worth deciding on:

  • When to bind. On start, and again when the recorded key changes, so a Settings edit takes effect without a restart.
  • Conflicts. If another bind already holds the combination, leave it alone and log once per distinct conflict rather than shadowing it. On Omarchy the shipped option+space is already ALT + SPACE → "Toggle dictation", so the out-of-the-box result is a logged conflict and no bind until the user records a different key.
  • Binds left by an earlier run. A bind from a previous process has no live handle. Clearing it needs hl.unbind(<combo>), which is destructive — only safe after hyprctl -j binds shows nothing else on that combination (same mask, same key compared case-insensitively, empty submap).
  • Reloads. hyprctl reload drops IPC-created binds, so a periodic resync (5 s) plus a resync on the portal Changed event is needed to notice and re-bind.
  • Cleanup. Remove every bind Hex made on stop, on change, and on re-bind.
  • Timeout. hyprctl children should be killed on a deadline, and a combo recorded as bound before the call, since a timed-out call may still have bound it.
  • Gating. This rewrites the compositor's binding table, so it fits AGENTS.md's Labs rule ("new or user-requested features land in Labs, off by default", gated where the work starts, not just in the UI) — behind labs.hyprland_shortcuts, read where the listener starts so that with the flag off nothing touches hyprctl at all.

The portal path stays unchanged on this route; the shortcut state machine and all four modes behave as they do today.

How a prototype of that approach behaves

  • Binds on start and on Settings change; changing the key frees the old combo and binds the new one, no restart.
  • Refuses a combination another bind already holds instead of stealing it, logging ALT + SPACE is taken by Toggle dictation rather than silently shadowing.
  • Survives hyprctl reload via a 5 s resync.
  • Removes its binds on quit; the bind table returns to its original entry count with the user's own binds untouched.
  • Off by default behind labs.hyprland_shortcuts.
  • Hyprland only; other compositors keep current behaviour.

How it was checked

Unit and repo checks pass on the prototype branch (scripts/check hyprland 23 passed; scripts/check 'linux::' 245 passed, 9 ignored; cargo test --locked --bin hex labs:: 55 passed; clippy -D warnings clean; cargo fmt --check clean; scripts/code-shape.py --check clean).

Live behaviour below was observed on one Hyprland 0.57.1 machine (Omarchy, Arch) with a locally built binary — it is a single-machine result, not coverage:

  • Binds appear in hyprctl -j binds described Hex shortcut: anomaly.hex:dictation (ALT+F10), …:paste-last (CTRL+ALT+V), …:capture (F9), dispatching HL.Dispatcher(global).
  • A real chord rather than a direct dispatch: ALT+F10 was synthesized through /dev/uinput, so it entered libinput as keyboard input and reached the bind table, then Hex's portal listener, with no config edit.
  • Switching shortcut_mode between hold and toggle in settings.json changed the resulting state without rebinding.
  • Editing settings.json moved a bind from ALT+SPACE to ALT+J within ~8 s and freed the old combo.
  • SIGTERM left zero Hex shortcut: binds.

Known limits of the prototype

  • Only one machine, and no intelligible speech. The synthesized chord proves the bind path end to end, but the dictation sessions it produced were discarded by the voice-activity gate (level_dbfs=-45.3, speech_threshold=0.4), so transcription of a spoken sentence over this path is unproven. Voice-activity calibration rarely transfers between machines and microphones, which is why this needs confirmation somewhere else before it counts.
  • Punctuation coverage is enumerated, not general. Keys with no xkb name are skipped with a warning rather than approximated. COMMA, MINUS, EQUAL, BRACKETLEFT/BRACKETRIGHT, SEMICOLON, APOSTROPHE, GRAVE, BACKSLASH, PERIOD, SLASH work.
  • The destructive case is real. hl.unbind("<spec>") removes every bind on that combination — verified by watching it delete an unrelated SUPER + comma "Dismiss, invoke, or focus last notification" bind during probing. It is case-insensitive on key and modifier names but requires modifier order SUPER, CTRL, ALT, SHIFT, and Hyprland will store COMMA and comma as two separate binds on one physical key.
  • Registration isn't reflected back into Settings. With the portal's keyless grant, the row still reads "unconfirmed" while Hex's own bind works.

Prior art / reference implementation

A working prototype against dev at 8c900e8 lives on a branch in a fork: https://github.com/ThePsyberSleuth/hex/tree/hyprland-binds (494bbfc), 14 files. It is available as a PR if maintainers want it once direction is agreed; nothing has been sent upstream.

The ask

  1. Is the compositor-native route acceptable, or is the preference to keep pursuing the portal path (or to leave binding to the user's config and treat this as documentation)?
  2. If the route is acceptable, is it Labs-gated (labs.hyprland_shortcuts, off by default) rather than on by default?
  3. Should Hex ever clear a bind it did not create in this process, or is refusing and telling the user to remove it the safer default?

Activity

  1. thdxr commented on Nov 12, 2026

    @thdxr
    Member

    right now hyprland takes hex's shortcuts but leaves them unbound, so the key you record in settings does nothing until you add something like bind = , End, global, anomaly.hex:dictation to your config. the linux docs say this, but settings only says it can't confirm the key, which doesn't help much.

    our thoughts:

    • so far hex keeps one path on wayland: the shortcuts portal, or the hex dictation commands bound in your desktop. having hex write binds into hyprland through hyprctl would be the first hyprland-only code there, so we want to decide that on purpose
    • if we do it, it'd be a labs toggle in the shortcuts group, off by default. hex would only remove binds it made itself, and if a key is already taken it leaves it alone and says so in settings
    • if we go this way we'll build it here and credit your branch

    two questions:

    • would settings showing the exact bind lines to paste into your config be enough, or is the point not having to touch the config at all?
    • when hex finds a leftover bind from an earlier run, should it clear it if nothing else is on that key, or always leave it and tell you?

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

  2. added
    needs-designNeeds a closer look or a product decision before we build it
    on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-designNeeds a closer look or a product decision before we build it

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions