Panic shortcut: free a trapped cursor from the keyboard - #528
Merged
Conversation
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.
This was referenced Jul 31, 2026
Merged
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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+Mfor 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:
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.
Rescuedis broadcast before the unhook is requested. The other order puts the resultingStoppedfirst, the UI drops live preview on that, and theRescuedbehind it finds nothing left to act on — the reload never happens.Three deliberate choices in the listener
RegisterHotKey, notWH_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 answersERROR_HOTKEY_ALREADY_REGISTERED.WM_HOTKEYgoes 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.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
Shortcutcommand, 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
RegisterHotKeyrefuses, 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:Oem3is`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 readsCtrl+Oem7, which is honest but not pretty — prettifying it needs aMapVirtualKeyround-trip and can come later.That grammar is a contract between two languages and two processes with no compiler behind it.
ShortcutBoxTestsandshortcut.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, andSuspended,ResumedandProbedhave a case in neither — every sleep, resume and probe was faulting them from inside aDispatcher.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.
RegisterHotKeytakes 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.