Skip to content

Panic shortcut: free a trapped cursor from the keyboard - #528

Merged
mgth merged 5 commits into
masterfrom
panic-shortcut
Jul 31, 2026
Merged

Panic shortcut: free a trapped cursor from the keyboard#528
mgth merged 5 commits into
masterfrom
panic-shortcut

Conversation

@mgth

@mgth mgth commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes #526.

A cursor stuck on a screen it cannot leave puts every recovery LBM offers — Undo, Stop, the tray menu — behind a click that cannot be made. The eleven closed "cursor gets stuck" issues were all geometry bugs and were fixed as such; none of them left the user a way out while it was happening. Live update (#525) sharpens it, since an untested geometry now goes under the mouse deliberately, but the need stands on its own.

Hold Ctrl+Alt+Shift+M for about a second. Configurable under Options → Mouse.

Who decides what

The daemon does one thing and always the same thing: release the clip, say so, take the hook down. It knows nothing about why, and that is the point — it has to work with no UI reachable behind the very cursor it is freeing, or with no UI running at all.

What the rescue should mean belongs to the UI, which is where the knowledge is:

  • previewing a layout → there is an experiment to throw away: drop live update, reload the saved layout, start the engine again on it;
  • not previewing → what trapped the user is what they committed to, and leaving the engine down is the answer.

So the ordinary case is over in one press. Nothing counts presses and there is no escalation window; the escalation falls out of the state.

Rescued is broadcast before the unhook is requested. The other order puts the resulting Stopped first, the UI drops live preview on that, and the Rescued behind it finds nothing left to act on — the reload never happens.

Three deliberate choices in the listener

  • RegisterHotKey, not WH_KEYBOARD_LL. The daemon already hooks the mouse globally; a keyboard hook would mean seeing every keystroke on the desktop — a keylogging surface that antivirus and anti-cheat notice, and that a mouse utility has no business needing. This sees one combination, and Windows arbitrates collisions instead of leaving them silent. Verified from another process: the combination answers ERROR_HOTKEY_ALREADY_REGISTERED.
  • Its own thread, with its own message loop. WM_HOTKEY goes to the queue of the thread that registered; registering on the hook pump would make the rescue depend on the pump it is rescuing you from.
  • Held, not tapped, with the auto-repeat backlog drained afterwards so letting go cannot fire a second time.

Recording it

Pressed, not typed: the daemon can only register what Windows understands, so a name typed in and unmappable there would be accepted here and fail silently, far from its cause.

Sent the moment it is recorded, through a new Shortcut command, rather than riding to the daemon inside the next Apply. That was the first thing to get wrong — recording one and waiting for an Apply meant nothing was attempted, so nothing succeeded and nothing failed, and there was no way to tell "it works" from "something else owns it". It still travels inside the layout as well, which is what gets one to a standalone daemon replaying its startup file.

When RegisterHotKey refuses, the daemon says so and the options row says the combination is taken and the rescue is NOT active. A rescue that silently does not exist is worse than none, because the user only finds out at the moment they need it.

The key table covers letters, digits, function and navigation keys, punctuation and the numeric keypad. Without the last two, Ctrl+² could not be recorded at all — on a French keyboard that is most of the interesting keys. OEM keys are named by position rather than by what they print: Oem3 is ` on QWERTY and ² on AZERTY, and the position is what gets registered, so a shortcut means the same physical key whatever the layout says it prints. The button therefore reads Ctrl+Oem7, which is honest but not pretty — prettifying it needs a MapVirtualKey round-trip and can come later.

That grammar is a contract between two languages and two processes with no compiler behind it. ShortcutBoxTests and shortcut.rs's tests are the compiler: every name locked on both sides, along with the refusals — a bare key (registering one globally would take it from every application on the desktop) and keys with no virtual-key code.

Also fixed, because adding an event made it blocking

Both daemon event handlers ended in default: throw new ArgumentOutOfRangeException, and Suspended, Resumed and Probed have a case in neither — every sleep, resume and probe was faulting them from inside a Dispatcher.Post. An event stream you do not control is ignored, not refused; a daemon newer than its UI is a normal state of affairs.

Not possible, and worth writing down

Two modifiers alone — both Shift keys together, say. RegisterHotKey takes modifiers plus a key, and there is no key in that. The only way round is a keyboard hook, which is the surface this design exists to avoid.

Linux gets nothing yet: no global shortcut without a portal, and reading one from evdev means opening the keyboard devices.

Tests

105 + 60 + 113 + 5 managed, 56 Rust, clippy clean.

mathieu added 5 commits July 31, 2026 20:59
A cursor stuck on a screen it cannot leave puts every recovery LBM offers —
Undo, Stop, the tray menu — behind a click that cannot be made. The eleven
closed "cursor gets stuck" issues were all geometry bugs and were fixed as
such; none of them left the user a way out while it was happening. Live
update sharpens it, since an untested geometry now goes under the mouse
deliberately, but the need stands on its own (#526).

Ctrl+Alt+Shift+M, held for about a second.

The daemon does one thing and always the same thing: release the clip, say
so, take the hook down. It knows nothing about why, and that is the point —
it has to work with no UI reachable behind the very cursor it is freeing, or
with no UI running at all.

What the rescue should MEAN belongs to the UI, which is where the knowledge
is. Previewing a layout? Then there is an experiment to throw away: drop
live update, reload the saved layout, start the engine again on it. Not
previewing? Then what trapped the user is what they committed to, and
leaving the engine down is the answer — so the ordinary case is over in one
press, with nothing counting presses and no escalation window.

Three deliberate choices in the listener:

- RegisterHotKey, not WH_KEYBOARD_LL. The daemon already hooks the mouse
  globally; a keyboard hook would mean seeing every keystroke on the desktop
  — a keylogging surface that antivirus and anti-cheat notice, and that a
  mouse utility has no business needing. This sees one combination, and
  Windows arbitrates collisions instead of leaving them silent. Verified
  from another process: the combination now answers
  ERROR_HOTKEY_ALREADY_REGISTERED.

- Its own thread, with its own message loop. WM_HOTKEY goes to the queue of
  the thread that registered; registering on the hook pump would make the
  rescue depend on the pump it is rescuing you from.

- Held, not tapped, with the auto-repeat backlog drained afterwards so
  letting go cannot fire a second time.

`Rescued` is broadcast BEFORE the unhook is requested. The other order puts
the resulting `Stopped` first, the UI drops live preview on that, and the
`Rescued` behind it finds nothing left to act on — the reload never happens.

Also fixed, because adding an event made it blocking: both daemon event
handlers ended in `default: throw new ArgumentOutOfRangeException`, and
Suspended, Resumed and Probed have no case in either — every sleep, resume
and probe was faulting them from inside a Dispatcher.Post. An event stream
you do not control is ignored, not refused; a daemon newer than its UI is a
normal state of affairs.

The shortcut travels in ZonesLayout, so it reaches a standalone daemon
through the startup replay, and the daemon already adopts what a layout
names. Nothing writes it yet: recording it in the options is the other half
of #526, and until then the default is what registers.
The panic shortcut was whatever the daemon defaulted to. This makes it the
user's, recorded by pressing it rather than typed: the daemon can only
register what Windows understands, so a name typed here that it cannot map
would be accepted and then fail silently over there, far from its cause.
Pressing a combination cannot produce anything the keyboard cannot produce.

The combination is sent the moment it is recorded, through a new Shortcut
command, rather than riding to the daemon inside the next Apply. That was
the first thing to get wrong: recording one and waiting for an Apply meant
nothing was attempted, so nothing succeeded and nothing failed, and there
was no way to tell "it works" from "something else owns it". It still
travels inside the layout as well — that is what gets one to a standalone
daemon replaying its startup file.

And when RegisterHotKey refuses, the daemon says so: ShortcutUnavailable
carries the combination, and the options row says it is taken and that the
rescue is NOT active. A rescue that silently does not exist is worse than
none, because the user only finds out at the moment they need it.

The key table now covers punctuation and the numeric keypad. It had letters,
digits, function and navigation keys and nothing else, which on a French
keyboard amputates most of the interesting ones — Ctrl+² could not be
recorded at all. OEM keys are named by position, not by what they print:
Oem3 is ` on QWERTY and ² on AZERTY, and the position is what gets
registered, so a shortcut means the same physical key whatever the layout
says it prints.

That grammar is a contract between two languages and two processes with no
compiler behind it. The two test files are the compiler: every name is
locked on both sides, along with the refusals — a bare key (registering one
globally would take it from every application on the desktop) and keys the
daemon has no virtual-key code for.

Two things the ControlTheme had to sort out, after a first attempt that
rendered a blank bar. StyleKeyOverride pointed at Button to borrow its look,
which also made every selector written against ShortcutBox miss; and a
setter binding wants $self, not RelativeSource Self. The theme is now keyed
on the control's own type and BasedOn the Button theme, the way SettingRow
next door already did it.

Not possible, and worth writing down: two modifiers alone — both Shift keys
together, say. RegisterHotKey takes modifiers plus a key, and there is no
key in that. The only way round would be a keyboard hook, which is the
keylogging surface this design exists to avoid.
Documents the way out when a layout leaves the cursor somewhere it cannot
escape, next to the live update it most often follows.

The part worth writing down is what the shortcut does in each case — throw
away the preview and go back to the saved layout, or stop the engine — and
that the setting tells you when another application already owns the
combination. Plus the Windows-only limit, with its reason: a global shortcut
on Wayland needs a portal, and reading one from evdev would mean opening the
keyboard devices.
Two things about the settings panel, one asked for and one found on the way.

The support card no longer scrolls away. It moved out of the ScrollViewer
into its own row, and takes a Compact class as soon as the scroller leaves
its top: the sentence explaining it goes, the padding tightens, and a shadow
appears so the content passing underneath reads as passing underneath. What
is left is the icon, the title and the Ko-fi button on one line — a line of
thanks should not cost a third of the panel for the whole visit, nor
disappear the moment you look away from it.

Four pixels of slack before it switches, or content resting a hair off zero
makes the card flicker between its two shapes.

The description had to be named PART_Description in SettingRow's template:
without a name no selector can reach inside the template to hide it. That is
worth more than this one card — any settings row can be collapsed the same
way now.

And the panel used to open part-way down. Nothing to do with the pinned card:
the Algorithm and Border-values lists carry AutoScrollToSelectedItem, which
defaults on, so each asked to be scrolled to its selection as it was
realized. The request travels up the visual tree and the settings scroller is
what satisfies it. Both lists hold two or three cards and are visible whole,
so scrolling to the selection buys nothing and cost the panel its opening
position. That is also why it was always the same place regardless of what
the user had been doing: it followed those lists' position on the page, not
any state.
@mgth
mgth merged commit 7ca4d53 into master Jul 31, 2026
1 check passed
@mgth
mgth deleted the panic-shortcut branch July 31, 2026 19:59
pull Bot pushed a commit to Mu-L/LittleBigMouse that referenced this pull request Aug 3, 2026
Taken now rather than at the next release so it gets a whole cycle of use
before it ships. Builds clean, the suites pass, the app runs.

Popups now position on the screen containing their anchor point (#21750),
which for a tool whose whole subject is multi-monitor geometry is the change
to watch. It also brings ToolTip.ShouldUseOverlayLayer (#21830): a tooltip
rendered inside its parent window has no top-level of its own for the OS to
route a click to, which is the mechanism that was missing when the toolbar
tips were swallowing clicks. The placement and delay put in for mgth#528 stay as
they are for now — the simplification is worth trying on top, once this has
proven itself.

Also bumps HLab.Sys.Argyll and the HLab.Avalonia submodule (mgth/HLab.Avalonia).
DataGrid stays at 12.1.0, which has no 12.1.1 and does not need one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Panic shortcut: free the cursor without the mouse

1 participant