Repository navigation
fix: route device actions to the right mouse; per-mouse profile tabs - #174
Merged
Merged
Conversation
…e UI selection Profile applies, reconnect re-applies, window-focus profile switching and diverted-button / gesture / thumb wheel input all resolved their target through the carousel selection (ActiveDeviceResolver::activeSession()). The selection is UI state and is wrong for device work: - With one mouse, a disconnect hides its card and clears the selection (-1). On reconnect PhysicalDevice emits transportSetupComplete before stateChanged re-inserts the card, so applyProfileToHardware found no session and returned silently. The mouse stayed in firmware defaults after every wake / KVM switch: DPI reset, buttons undiverted, gestures dead. The log showed "applying profile" followed by nothing. - With two mice, mouse B's profile was written to mouse A on setup and on every B reconnect, B's buttons ran A's mappings against A's session, button-made DPI/SmartShift changes were saved to A's profile, and only the selected mouse followed window focus. Every device-originated path now names its device: - ProfileOrchestrator::applyProfileToHardware(device, profile) writes to that device's transport using its own descriptor, and logs when it cannot instead of bailing silently. setupProfileForDevice and onTransportSetupComplete pass their device; neither touches the UI's current-descriptor pointer any more. - onWindowFocusChanged switches every connected mouse to its own app profile; only the selected mouse moves the tab bar's hardware marker. - ButtonActionDispatcher slots take the source PhysicalDevice (AppRoot tags each device's signals), use its descriptor, session and hardware profile, and key per-device state by its serial. The by-button signals carry the serial so applyHardwareChange saves to that mouse. - DeviceModel keeps the same mouse selected when a row is inserted or removed in front of it, instead of silently re-pointing the index. UI edits (button reassignments, point/scroll controls, tabs) still act on the selected mouse, which is the one the user is looking at. CarouselSwitchSwapsButtonModel relied on the bug: adding mouse B wrote B's profile onto A's session, whose change signals refreshed A's button list as a side effect. It now drives real carousel switches and compares action names (both mappings surface as type "keystroke").
ProfileModel was a single tab list shared by every mouse. Each time a mouse connected, setupProfileForDevice appended that mouse's app bindings to it, and nothing ever removed or rebuilt them. Once a second mouse connected, both showed the union of their app profiles: - clicking another mouse's tab created an empty profile for the selected mouse that no app binding pointed at; - adding or removing a tab changed only the selected mouse's profiles but the tab appeared or vanished for both; - addProfile refused an app class already in the list, so a mouse could not get its own profile for an app the other mouse already had; - the viewed-tab and "active on device" highlights were single values shared by both mice. The tab bar now always shows the selected mouse's app profiles: - ProfileModel::resetProfiles replaces the app tabs in one model reset and points both highlights at profiles by name, without emitting the user-action signals (profileSwitched / profileAdded / profileRemoved). - ProfileEngine::appBindings(serial) exposes a mouse's bindings. - ProfileOrchestrator::rebuildProfileTabs(serial) builds the tabs from them, and runs on every carousel selection change (AppRoot) and when the selected mouse is set up. Connecting another mouse no longer touches the tab bar; with no mouse selected only Defaults remains.
This was referenced Nov 9, 2026
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.
Problem
Two bugs with one root cause: device work was aimed at whichever mouse is selected in the carousel, not the mouse it was for.
1. Profile not re-applied after wake / KVM switch (single mouse). On disconnect,
DeviceModelhides the offline mouse's card. With one mouse the selection becomes-1. On reconnectPhysicalDeviceemitstransportSetupCompletebeforestateChangedre-adds the card, soapplyProfileToHardwarefound noactiveSession()and returned silently. The mouse stayed in firmware defaults (DPI reset, buttons undiverted, gestures dead, default SmartShift). The log readdevice transport ready, applying profile: "default"followed by nothing. This has been present since thePhysicalDevicerefactor. In July it was misattributed to a connection race, and #147 only fixed the main-wheel symptom.2. Two mice of the same model interfere with each other.
Changes
fix(device): route device actions to the mouse they belong toProfileOrchestrator::applyProfileToHardware(device, profile)writes to that device's transport using its own descriptor. It logs when it can't, instead of bailing silently.setupProfileForDeviceandonTransportSetupCompletepass their device.ButtonActionDispatcherslots take the sourcePhysicalDevice(AppRoot tags each device's signals). Per-device state is keyed by serial. The by-button DPI/SmartShift signals carry the serial, so changes are saved to that mouse.DeviceModelkeeps the same mouse selected when a row is inserted or removed in front of it.fix(profiles): show each mouse's own app profile tabsProfileModel::resetProfilesrebuilds the tab list in one reset, without emitting user-action signals.ProfileEngine::appBindings(serial)on every carousel selection change, and when the selected mouse is set up. Connecting another mouse no longer touches the tab bar.Tests
ProfileModel::resetProfilesunit testsCarouselSwitchSwapsButtonModelwas rewritten: its precondition only passed because adding mouse B wrote B's profile onto A, which refreshed A's button list as a side effect.Hardware validation
Done with two MX Master 3S-family mice on two Bolt receivers.
device transport ready, applying profile … to <its serial>followed by the full writes, and DPI went from the firmware's 1000 back to 1800.Related
applyProfileToHardware. After this change those triggers should callapplyProfileToHardware(device, …). fix(device): re-apply profile when a transport re-attaches #159's use-after-free fix and its no-0x41-on-resume handling are independent and still worthwhile.Not in this PR
0xB035) match the MX Master 3 descriptor. That will be a separate PR.🤖 Generated with Claude Code