From 1420e3cbdbcfe7f6a93d553ba8f74686f1d5c919 Mon Sep 17 00:00:00 2001 From: blak0p Date: Thu, 17 Sep 2026 10:34:00 +0200 Subject: [PATCH] feat(remap): add mouse controls actions --- .../panels/ButtonRemapPanel.test.tsx | 33 ++++++ .../components/panels/ButtonRemapPanel.tsx | 14 ++- frontend/src/desktop-contract.ts | 2 +- .../src/hooks/useDesktopWorkspace.test.ts | 17 +++ internal/desktop/service.go | 1 + .../desktop/service_explicit_apply_test.go | 32 +++-- internal/protocol/x6/remap.go | 31 ++++- internal/protocol/x6/remap_test.go | 30 +++++ internal/x6/remap.go | 5 + .../apply-progress.md | 19 +++ .../mouse-controls-remapping/design.md | 47 ++++++++ .../mouse-controls-remapping/proposal.md | 50 ++++++++ .../specs/button-remapping/spec.md | 109 ++++++++++++++++++ .../changes/mouse-controls-remapping/tasks.md | 58 ++++++++++ 14 files changed, 431 insertions(+), 17 deletions(-) create mode 100644 openspec/changes/mouse-controls-remapping/apply-progress.md create mode 100644 openspec/changes/mouse-controls-remapping/design.md create mode 100644 openspec/changes/mouse-controls-remapping/proposal.md create mode 100644 openspec/changes/mouse-controls-remapping/specs/button-remapping/spec.md create mode 100644 openspec/changes/mouse-controls-remapping/tasks.md diff --git a/frontend/src/components/panels/ButtonRemapPanel.test.tsx b/frontend/src/components/panels/ButtonRemapPanel.test.tsx index f92b4bb..2db9d35 100644 --- a/frontend/src/components/panels/ButtonRemapPanel.test.tsx +++ b/frontend/src/components/panels/ButtonRemapPanel.test.tsx @@ -16,6 +16,10 @@ const multimediaRemap = { ...remap, Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"], }; +const mouseControlsRemap = { + ...multimediaRemap, + Actions: [...multimediaRemap.Actions, "scroll_up", "scroll_down", "dpi_cycle", "dpi_plus", "dpi_minus"], +}; afterEach(cleanup); @@ -54,6 +58,35 @@ describe("ButtonRemapPanel", () => { expect(onStage).not.toHaveBeenCalled(); }); + it("keeps Mouse Controls visible, ordered, and inaccessible for Button 1", () => { + const onStage = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Button 1 action" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Mouse Controls" })); + const submenu = screen.getByRole("menu", { name: "Mouse Controls actions" }); + const entries = ["Scroll Up", "Scroll Down", "DPI Cycle", "DPI+", "DPI−"]; + expect(within(submenu).getAllByRole("menuitem").map((entry) => entry.textContent)).toEqual(entries); + for (const label of entries) { + const entry = within(submenu).getByRole("menuitem", { name: label }); + expect(entry).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(entry); + } + const trigger = screen.getByRole("button", { name: "Button 1 action" }); + fireEvent.keyDown(trigger, { key: "End" }); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.keyDown(trigger, { key: " " }); + expect(onStage).not.toHaveBeenCalled(); + }); + + it("stages a Mouse Controls action for an eligible button", () => { + const onStage = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Button 2 action" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Mouse Controls" })); + fireEvent.click(screen.getByRole("menuitem", { name: "DPI Cycle" })); + expect(onStage).toHaveBeenCalledWith(2, "dpi_cycle"); + }); + it("allows Buttons 2–7 to stage Multimedia actions", () => { const onStage = vi.fn(); render(); diff --git a/frontend/src/components/panels/ButtonRemapPanel.tsx b/frontend/src/components/panels/ButtonRemapPanel.tsx index 58e3f88..ae4db60 100644 --- a/frontend/src/components/panels/ButtonRemapPanel.tsx +++ b/frontend/src/components/panels/ButtonRemapPanel.tsx @@ -1,6 +1,6 @@ import { GnomeSelect } from "./GnomeSelect"; -type Action = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire" | "media_player" | "play_pause" | "stop" | "previous_track" | "next_track" | "volume_up" | "volume_down" | "mute"; +type Action = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire" | "media_player" | "play_pause" | "stop" | "previous_track" | "next_track" | "volume_up" | "volume_down" | "mute" | "scroll_up" | "scroll_down" | "dpi_cycle" | "dpi_plus" | "dpi_minus"; type Button = { Button: number; Action: Action | null; PreservedDefault: string }; type Remap = { Pending: { Buttons: Button[] }; @@ -28,9 +28,15 @@ const labelFor = (action: Action) => volume_up: "Volume Up", volume_down: "Volume Down", mute: "Mute", + scroll_up: "Scroll Up", + scroll_down: "Scroll Down", + dpi_cycle: "DPI Cycle", + dpi_plus: "DPI+", + dpi_minus: "DPI−", })[action]; const isMultimedia = (action: Action) => ["media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"].includes(action); +const isMouseControls = (action: Action) => ["scroll_up", "scroll_down", "dpi_cycle", "dpi_plus", "dpi_minus"].includes(action); export function ButtonRemapPanel({ remap, @@ -84,12 +90,12 @@ export function ButtonRemapPanel({ options={remap.Actions.map((action) => ({ value: action, label: labelFor(action), - group: isMultimedia(action) ? "Multimedia" : "Basic", - disabled: button.Button === 1 && isMultimedia(action), + group: isMouseControls(action) ? "Mouse Controls" : isMultimedia(action) ? "Multimedia" : "Basic", + disabled: button.Button === 1 && (isMultimedia(action) || isMouseControls(action)), }))} onChange={(val) => { const action = val as Action; - if (!(button.Button === 1 && isMultimedia(action))) onStage(button.Button, action); + if (!(button.Button === 1 && (isMultimedia(action) || isMouseControls(action)))) onStage(button.Button, action); }} /> diff --git a/frontend/src/desktop-contract.ts b/frontend/src/desktop-contract.ts index 66b9c96..e4eabac 100644 --- a/frontend/src/desktop-contract.ts +++ b/frontend/src/desktop-contract.ts @@ -12,7 +12,7 @@ export type LightingSpeedVariant = { TemplateID: string }; export type LightingColorTemplate = { TemplateID: string; CSSColor: string }; export type LightingEffect = { Mode: LightingMode; Label: string; DefaultTemplateID: string; SpeedVariants: LightingSpeedVariant[]; ColorTemplates: LightingColorTemplate[] }; export type LightingSnapshot = { Pending: LightingSelection; Applied: LightingSelection | null; Effects: LightingEffect[]; Revision: number; Firmware: string; Error: { Code: string } }; -export type RemapAction = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire" | "media_player" | "play_pause" | "stop" | "previous_track" | "next_track" | "volume_up" | "volume_down" | "mute"; +export type RemapAction = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire" | "media_player" | "play_pause" | "stop" | "previous_track" | "next_track" | "volume_up" | "volume_down" | "mute" | "scroll_up" | "scroll_down" | "dpi_cycle" | "dpi_plus" | "dpi_minus"; export type RemapButton = { Button: number; Action: RemapAction | null; PreservedDefault: "" | "DPI+" | "DPI-" }; export type RemapConfig = { Buttons: RemapButton[] }; export type RemapSnapshot = { Pending: RemapConfig; Applied: RemapConfig; Factory: RemapConfig; Actions: RemapAction[]; Revision: number; Firmware: string; Persistence: string; RetryAvailable: boolean; Error: { Code: string } }; diff --git a/frontend/src/hooks/useDesktopWorkspace.test.ts b/frontend/src/hooks/useDesktopWorkspace.test.ts index 2a326de..228b228 100644 --- a/frontend/src/hooks/useDesktopWorkspace.test.ts +++ b/frontend/src/hooks/useDesktopWorkspace.test.ts @@ -92,6 +92,23 @@ describe("useDesktopWorkspace", () => { expect(harness.unsubscribeRemap).toHaveBeenCalledOnce(); }); + it("clears only the explicitly replaced DPI marker when staging remap", async () => { + const pending = { Buttons: [ + { Button: 1, Action: "left", PreservedDefault: "" }, { Button: 2, Action: "right", PreservedDefault: "" }, + { Button: 3, Action: "middle", PreservedDefault: "" }, { Button: 4, Action: "forward", PreservedDefault: "" }, + { Button: 5, Action: "backward", PreservedDefault: "" }, { Button: 6, Action: null, PreservedDefault: "DPI+" }, + { Button: 7, Action: null, PreservedDefault: "DPI-" }, + ] }; + const harness = serviceFor({ GetRemapSnapshot: vi.fn().mockResolvedValue(remap({ Pending: pending, Applied: pending })) }); + const { result } = renderHook(() => useDesktopWorkspace(harness.service)); + await waitFor(() => expect(result.current.model.remap?.Pending.Buttons).toHaveLength(7)); + + act(() => result.current.actions.stageRemap(6, "dpi_cycle")); + + expect(result.current.model.remap?.Pending.Buttons[5]).toEqual({ Button: 6, Action: "dpi_cycle", PreservedDefault: "" }); + expect(result.current.model.remap?.Pending.Buttons[6]).toEqual({ Button: 7, Action: null, PreservedDefault: "DPI-" }); + }); + it("hydrates both settings, applies them independently, and refreshes both after a configuration event", async () => { const configurationListeners: Array<(event: { Binding: typeof binding; Snapshot: ReturnType }) => void> = []; const harness = serviceFor({ OnConfiguration: vi.fn().mockImplementation((callback) => { configurationListeners.push(callback); return vi.fn(); }) }); diff --git a/internal/desktop/service.go b/internal/desktop/service.go index 385aa14..a985810 100644 --- a/internal/desktop/service.go +++ b/internal/desktop/service.go @@ -1366,6 +1366,7 @@ func remapSnapshotLocked(state *remapState) RemapSnapshot { actions := []x6.RemapAction{ x6.RemapOff, x6.RemapLeft, x6.RemapRight, x6.RemapMiddle, x6.RemapForward, x6.RemapBackward, x6.RemapDoubleClick, x6.RemapFire, x6.RemapMediaPlayer, x6.RemapPlayPause, x6.RemapStop, x6.RemapPreviousTrack, x6.RemapNextTrack, x6.RemapVolumeUp, x6.RemapVolumeDown, x6.RemapMute, + x6.RemapScrollUp, x6.RemapScrollDown, x6.RemapDPICycle, x6.RemapDPIPlus, x6.RemapDPIMinus, } return RemapSnapshot{Pending: cloneRemapConfig(state.pending), Applied: cloneRemapConfig(state.applied), Factory: cloneRemapConfig(state.factory), Actions: actions, Revision: state.revision, Firmware: state.firmware, Persistence: state.persistence, RetryAvailable: state.retry != nil, Error: state.err} } diff --git a/internal/desktop/service_explicit_apply_test.go b/internal/desktop/service_explicit_apply_test.go index bcbfe05..38008fa 100644 --- a/internal/desktop/service_explicit_apply_test.go +++ b/internal/desktop/service_explicit_apply_test.go @@ -170,8 +170,12 @@ func TestApplyRemapValidatesBindingACKAndPersistence(t *testing.T) { valid := transport.Candidate{VendorID: 0x1D57, ProductID: 0xFA60, Serial: "alpha", Path: "/dev/hidraw0"} config := x6.DefaultRemapConfig() config.Buttons[0].Action = x6.RemapFire - buttonOneMultimedia := x6.DefaultRemapConfig() - buttonOneMultimedia.Buttons[0].Action = x6.RemapMediaPlayer + buttonOneMouseControls := []x6.RemapConfig{} + for _, action := range []x6.RemapAction{x6.RemapScrollUp, x6.RemapScrollDown, x6.RemapDPICycle, x6.RemapDPIPlus, x6.RemapDPIMinus} { + blocked := x6.DefaultRemapConfig() + blocked.Buttons[0].Action = action + buttonOneMouseControls = append(buttonOneMouseControls, blocked) + } for _, tt := range []struct { name string @@ -189,13 +193,6 @@ func TestApplyRemapValidatesBindingACKAndPersistence(t *testing.T) { wantCode: InvalidConfiguration, wantCalls: 0, }, - { - name: "Button 1 multimedia rejects before command or persistence", - config: buttonOneMultimedia, - wantCode: InvalidConfiguration, - wantCalls: 0, - wantSaves: 0, - }, { name: "missing selection rejects before command", config: config, @@ -277,6 +274,22 @@ func TestApplyRemapValidatesBindingACKAndPersistence(t *testing.T) { } }) } + + for _, blocked := range buttonOneMouseControls { + t.Run("Button 1 Mouse Controls rejects before all side effects/"+string(blocked.Buttons[0].Action), func(t *testing.T) { + registry, err := mouse.NewProfileRegistry(x6.NewProfile()) + if err != nil { t.Fatalf("NewProfileRegistry() error = %v", err) } + command := &remapCommandFake{ack: true} + persistence := &remapPersistenceFake{} + service := New(statusFake{}, &writerFake{}, appliedStoreFake{applied: x6.DefaultDPIConfig()}).AttachInventory(mouse.NewTargetedService(registry, inventorySourceFake{candidates: []transport.Candidate{valid}}, command)) + service.remapPersistence = persistence + service.RefreshInventory(context.Background()) + before, got := service.GetRemapSnapshot(), service.ApplyRemap(blocked) + if got.Error.Code != InvalidConfiguration || command.calls != 0 || persistence.saves != 0 || got.Revision != before.Revision || !remapConfigsEqual(got.Pending, before.Pending) || !remapConfigsEqual(got.Applied, before.Applied) { + t.Fatalf("ApplyRemap() = %#v, writes=%d saves=%d; want unchanged rejected state", got, command.calls, persistence.saves) + } + }) + } } func TestRemapSnapshotOrdersFreshBasicThenMultimediaCatalog(t *testing.T) { @@ -285,6 +298,7 @@ func TestRemapSnapshotOrdersFreshBasicThenMultimediaCatalog(t *testing.T) { want := []x6.RemapAction{ x6.RemapOff, x6.RemapLeft, x6.RemapRight, x6.RemapMiddle, x6.RemapForward, x6.RemapBackward, x6.RemapDoubleClick, x6.RemapFire, x6.RemapMediaPlayer, x6.RemapPlayPause, x6.RemapStop, x6.RemapPreviousTrack, x6.RemapNextTrack, x6.RemapVolumeUp, x6.RemapVolumeDown, x6.RemapMute, + x6.RemapScrollUp, x6.RemapScrollDown, x6.RemapDPICycle, x6.RemapDPIPlus, x6.RemapDPIMinus, } if len(first.Actions) != len(want) { t.Fatalf("catalog length = %d, want %d", len(first.Actions), len(want)) diff --git a/internal/protocol/x6/remap.go b/internal/protocol/x6/remap.go index 7d28fea..1f5076c 100644 --- a/internal/protocol/x6/remap.go +++ b/internal/protocol/x6/remap.go @@ -23,6 +23,11 @@ const ( RemapVolumeUp RemapAction = "volume_up" RemapVolumeDown RemapAction = "volume_down" RemapMute RemapAction = "mute" + RemapScrollUp RemapAction = "scroll_up" + RemapScrollDown RemapAction = "scroll_down" + RemapDPICycle RemapAction = "dpi_cycle" + RemapDPIPlus RemapAction = "dpi_plus" + RemapDPIMinus RemapAction = "dpi_minus" ) type RemapButton struct { @@ -68,8 +73,8 @@ func ValidateRemapConfig(config RemapConfig) error { if !isRemapAction(button.Action) { return fmt.Errorf("remap button %d has unsupported action %q", button.Button, button.Action) } - if button.Button == 1 && isMultimediaRemapAction(button.Action) { - return fmt.Errorf("remap button 1 does not support multimedia action %q", button.Action) + if button.Button == 1 && (isMultimediaRemapAction(button.Action) || isMouseControlsRemapAction(button.Action)) { + return fmt.Errorf("remap button 1 does not support action %q", button.Action) } } return nil @@ -127,6 +132,16 @@ func remapActionID(action RemapAction) byte { return 0x1c case RemapMute: return 0x1a + case RemapScrollUp: + return 0x09 + case RemapScrollDown: + return 0x0a + case RemapDPICycle: + return 0x0d + case RemapDPIPlus: + return 0x0e + case RemapDPIMinus: + return 0x0f default: return 0 } @@ -139,7 +154,17 @@ func MatchesRemapACK(report []byte) bool { func isRemapAction(action RemapAction) bool { switch action { case RemapOff, RemapLeft, RemapRight, RemapMiddle, RemapForward, RemapBackward, RemapDoubleClick, RemapFire, - RemapMediaPlayer, RemapPlayPause, RemapStop, RemapPreviousTrack, RemapNextTrack, RemapVolumeUp, RemapVolumeDown, RemapMute: + RemapMediaPlayer, RemapPlayPause, RemapStop, RemapPreviousTrack, RemapNextTrack, RemapVolumeUp, RemapVolumeDown, RemapMute, + RemapScrollUp, RemapScrollDown, RemapDPICycle, RemapDPIPlus, RemapDPIMinus: + return true + default: + return false + } +} + +func isMouseControlsRemapAction(action RemapAction) bool { + switch action { + case RemapScrollUp, RemapScrollDown, RemapDPICycle, RemapDPIPlus, RemapDPIMinus: return true default: return false diff --git a/internal/protocol/x6/remap_test.go b/internal/protocol/x6/remap_test.go index 6ad0c87..1415c28 100644 --- a/internal/protocol/x6/remap_test.go +++ b/internal/protocol/x6/remap_test.go @@ -81,6 +81,36 @@ func TestRemapMultimediaActionsUseExactIDsAndProtectButtonOne(t *testing.T) { } } +func TestMouseControlsUseExactIDsAndRespectPhysicalButtonPolicy(t *testing.T) { + mouseControls := []struct { + action RemapAction + id byte + }{ + {RemapScrollUp, 0x09}, {RemapScrollDown, 0x0a}, {RemapDPICycle, 0x0d}, {RemapDPIPlus, 0x0e}, {RemapDPIMinus, 0x0f}, + } + for _, tt := range mouseControls { + t.Run(string(tt.action), func(t *testing.T) { + for button := 2; button <= 7; button++ { + config := DefaultRemapConfig() + config.Buttons[button-1].Action = tt.action + report, err := EncodeRemapReport(config) + if err != nil { + t.Fatalf("EncodeRemapReport(Button %d, %q) error = %v", button, tt.action, err) + } + offset := 3 + (remapGroupByButton[button-1]-1)*3 + if report[offset] != tt.id { + t.Fatalf("Button %d action byte = 0x%02x, want 0x%02x", button, report[offset], tt.id) + } + } + blocked := DefaultRemapConfig() + blocked.Buttons[0].Action = tt.action + if err := ValidateRemapConfig(blocked); err == nil { + t.Fatalf("ValidateRemapConfig() accepted Button 1 %q", tt.action) + } + }) + } +} + func TestRemapDefaultsPreserveDPIMarkersAndReturnCopies(t *testing.T) { first := DefaultRemapConfig() second := DefaultRemapConfig() diff --git a/internal/x6/remap.go b/internal/x6/remap.go index 5bc632b..11a2e31 100644 --- a/internal/x6/remap.go +++ b/internal/x6/remap.go @@ -29,6 +29,11 @@ const ( RemapVolumeUp = protocol.RemapVolumeUp RemapVolumeDown = protocol.RemapVolumeDown RemapMute = protocol.RemapMute + RemapScrollUp = protocol.RemapScrollUp + RemapScrollDown = protocol.RemapScrollDown + RemapDPICycle = protocol.RemapDPICycle + RemapDPIPlus = protocol.RemapDPIPlus + RemapDPIMinus = protocol.RemapDPIMinus ) func DefaultRemapConfig() RemapConfig { return protocol.DefaultRemapConfig() } diff --git a/openspec/changes/mouse-controls-remapping/apply-progress.md b/openspec/changes/mouse-controls-remapping/apply-progress.md new file mode 100644 index 0000000..b9ed0b8 --- /dev/null +++ b/openspec/changes/mouse-controls-remapping/apply-progress.md @@ -0,0 +1,19 @@ +# Apply Progress + +## RED + +- Added protocol, desktop, and panel coverage before production edits. +- `go test ./internal/protocol/x6 ./internal/x6 ./internal/desktop` failed as expected because the five new constants were undefined. +- Focused frontend test execution initially could not run because `vitest` was unavailable; this environment was restored before the final frontend validation. + +## GREEN and Triangulation + +- Added the five closed actions and Button 1 validation before pending/applied/revision/config changes, encoding, persistence, transport, and device I/O. Established desktop semantics may still publish the typed invalid-configuration failure status. +- Verified all 30 Buttons 2–7/action encodings at the protocol layer and all five Button 1 rejections in protocol and desktop-boundary tests. +- Focused frontend tests now verify Mouse Controls category order, Button 1 pointer and trigger-keyboard blocking, eligible staging, and targeted DPI-marker clearing: 14 tests passed. +- `cd frontend && npm test` passed: 9 files, 89 tests. `cd frontend && npm run build` passed using the repository's configured Vite script. + +## Diff budget + +- Source/test diff: 137 changed lines (120 additions, 17 deletions), below the 400-line limit. +- No generated bindings, transport, HID, persistence, reset, or report infrastructure was changed. diff --git a/openspec/changes/mouse-controls-remapping/design.md b/openspec/changes/mouse-controls-remapping/design.md new file mode 100644 index 0000000..76a871f --- /dev/null +++ b/openspec/changes/mouse-controls-remapping/design.md @@ -0,0 +1,47 @@ +# Design: Mouse Controls Button Remapping + +## Technical Approach + +Extend the existing closed remap action model and selector metadata while preserving the current report and apply pipeline. The protocol layer owns the action set and physical-button policy; the desktop layer continues to publish the catalog; the frontend renders category and disabled metadata; staging remains local and explicit apply remains the only write path. + +## Authoritative Validation Boundary + +Add the five exact action values and IDs to `internal/protocol/x6/remap.go`. Keep validation closed and add a Mouse Controls classifier. `ValidateRemapConfig` MUST reject a Mouse Controls action in Button 1. The rejection must occur before `EncodeRemapReport`, pending/applied/revision mutation, persistence, transport, or device I/O. `internal/x6/remap.go` should only expose the typed adapter/constants; it must not become a second policy authority. + +## Catalog and UI + +`internal/desktop/service.go` should continue returning the existing snapshot shape while constructing the ordered catalog with a new `Mouse Controls` category representation appropriate to the current frontend contract. Existing Basic and Multimedia membership/order must remain unchanged. `ButtonRemapPanel.tsx` should render exactly five Mouse Controls entries in the specified order. For Button 1, each entry is visible, accessible as disabled, and blocked for pointer and keyboard selection. Buttons 2–7 receive enabled entries. `GnomeSelect.tsx` should preserve existing ungrouped callers and existing keyboard/readiness behavior. + +When `useDesktopWorkspace.ts` stages an explicit action for Button 6 or 7, it should clear only that button's `PreservedDefault` marker, matching the current behavior. No unrelated marker, reset default, or applied state may change during staging. + +## Protocol and Lifecycle Preservation + +Do not change the remap baseline, `remapGroupByButton` mapping `[1, 2, 3, 7, 8, 5, 6]`, report length, 18 wire groups, parameter bytes, checksum range/order, or `MatchesRemapACK`. DPI Cycle (`0x0d`) is assigned through an existing physical slot; wire group 4 remains internal and is not exposed as a button. + +Staging MUST produce zero device writes. `ApplyRemap` MUST retain selected-binding validation, one bounded report write, exact ACK matching (`03 10 50 00 08`), ACK-gated applied state, persistence retry without a second hardware write, and discard semantics. Reset remains the existing Basic default path. + +## Expected File Plan + +| Path | Planned role | +|---|---| +| `internal/protocol/x6/remap.go` | Five constants/IDs, closed-set classifier, Button 1 validation. | +| `internal/protocol/x6/remap_test.go` | IDs, order-independent encoding, eligibility, Button 1 rejection, exclusions, report/ACK invariants. | +| `internal/x6/remap.go` | Typed re-exports and adapter coverage. | +| `internal/x6/remap_test.go` | Adapter delegation and validation coverage, if needed by existing conventions. | +| `internal/desktop/service.go` | Ordered categorized catalog only; preserve apply/persistence state machine. | +| `internal/desktop/service_explicit_apply_test.go` | Catalog, zero-I/O rejection, ACK/persistence/retry/discard and reset regressions. | +| `frontend/src/desktop-contract.ts` | Handwritten action/category contract updates only. | +| `frontend/src/components/panels/GnomeSelect.tsx` | Minimal category/disabled option behavior. | +| `frontend/src/components/panels/ButtonRemapPanel.tsx` | Mouse Controls rendering and Button 1 disabled metadata. | +| `frontend/src/components/panels/ButtonRemapPanel.test.tsx` | Selector order, accessibility, interaction, staging, and marker behavior. | +| `frontend/src/hooks/useDesktopWorkspace.ts` | Preserve established explicit-stage marker clearing. | + +Generated bindings, `internal/mouse/**`, `internal/hidlinux/**`, `internal/transport/**`, persistence implementations, reset implementations, and report infrastructure are explicitly unchanged. + +## TDD and Verification Shape + +Follow `openspec/config.yaml` strict TDD phases: RED records failing behavioral tests; GREEN implements the smallest catalog/validation/UI change; TRIANGULATE covers the full action/button matrix and lifecycle invariants; REFACTOR consolidates helpers only after green. Use the configured Go, race, vet, frontend test, and frontend build commands during implementation; this planning change does not run implementation verification. + +## Rollback + +Remove only the five action/catalog/selector/validation additions and their tests. Do not alter report encoding, transport, persistence, reset, generated bindings, or hardware behavior. diff --git a/openspec/changes/mouse-controls-remapping/proposal.md b/openspec/changes/mouse-controls-remapping/proposal.md new file mode 100644 index 0000000..1cbcb3c --- /dev/null +++ b/openspec/changes/mouse-controls-remapping/proposal.md @@ -0,0 +1,50 @@ +# Proposal: Add Mouse Controls Button Remapping + +## Why + +The X6 remap catalog has checked-in capture evidence for five device actions that are not yet offered by the configurator. This change exposes those actions without changing the established seven-button model, report lifecycle, or transport boundary. + +## What Changes + +- Add exactly five capture-backed actions to the remap catalog, in this product order: + 1. Scroll Up — `scroll_up` — `0x09` + 2. Scroll Down — `scroll_down` — `0x0a` + 3. DPI Cycle — `dpi_cycle` — `0x0d` + 4. DPI+ — `dpi_plus` — `0x0e` + 5. DPI− — `dpi_minus` — `0x0f` +- Display them in a new `Mouse Controls` selector category. +- Allow all five actions on Buttons 2–7. +- Keep Button 1's current assignment and show all five Mouse Controls entries as visibly disabled. +- Reject a bypassed Button 1 assignment in authoritative backend validation before state mutation, report encoding, persistence, transport, or I/O. +- Clear the existing Buttons 6/7 preserved-default markers only when a user stages an explicit replacement, using the established staging behavior. + +## Invariants + +The following remain unchanged: + +- Exactly seven physical remap slots; DPI Cycle is an action for an existing slot, not an eighth button or exposed wire group 4. +- Existing Basic and Multimedia catalogs, labels, order, and behavior. +- The 59-byte `0x08` report, non-linear physical-to-wire mapping, parameters, checksum, and exact remap ACK. +- Explicit apply, ACK-gated applied state, persistence retry, discard, and reset lifecycle. +- Protocol, transport, HID, persistence, generated-binding, and reset contracts. + +## Out of Scope + +Browser/system actions, shortcuts, Easy Aim, macros, unknown values, internal wire group 4, an eighth button, report/infrastructure/lifecycle redesign, generated bindings, transport/HID/persistence/reset changes, and live hardware operations. + +## Evidence and Affected Areas + +All five IDs are documented in `docs/protocol-captures.md` and have checked-in capture support under `captures/0x08-remap/`, including `btn6_scroll_up.pcapng`, `btn6_scroll_down.pcapng`, and `btn6_dpi_cycle_plus_minus.pcapng`. + +Expected implementation surfaces are `internal/protocol/x6/remap.go`, `internal/x6/remap.go`, `internal/desktop/service.go`, `frontend/src/desktop-contract.ts`, `frontend/src/components/panels/GnomeSelect.tsx`, `frontend/src/components/panels/ButtonRemapPanel.tsx`, `frontend/src/hooks/useDesktopWorkspace.ts`, and the corresponding existing protocol, desktop, and panel tests. These are forecast only; this change edits no production code or tests. + +## Success Criteria + +- Every selector retains existing Basic and Multimedia content and adds exactly one ordered Mouse Controls category. +- Buttons 2–7 can stage and apply each of the five exact IDs. +- Button 1 displays but cannot select any Mouse Controls entry, and backend bypasses fail before all listed side effects. +- Existing report, ACK, apply, retry, discard, reset, and DPI-marker behavior remains unchanged. + +## Rollback and Hardware Safety + +Rollback removes only the five catalog values, selector metadata/disabled presentation, validation rule, and their implementation tests. It performs no device write and does not rewrite persisted state. Planning and tests must use hardware-free fakes/fixtures: never run as root, claim USB interfaces, replay captures against hardware, or perform hidraw I/O. No code path may write without the existing explicit apply action and exact ACK gate. diff --git a/openspec/changes/mouse-controls-remapping/specs/button-remapping/spec.md b/openspec/changes/mouse-controls-remapping/specs/button-remapping/spec.md new file mode 100644 index 0000000..545a8e2 --- /dev/null +++ b/openspec/changes/mouse-controls-remapping/specs/button-remapping/spec.md @@ -0,0 +1,109 @@ +# Delta Specification: Mouse Controls Button Remapping + +## MODIFIED Requirements + +### Requirement: Remap action catalog remains closed and ordered + +The system MUST retain every existing Basic and Multimedia action in its current catalog and order and MUST add exactly these five Mouse Controls actions in this order: + +1. Scroll Up — `scroll_up` — `0x09` +2. Scroll Down — `scroll_down` — `0x0a` +3. DPI Cycle — `dpi_cycle` — `0x0d` +4. DPI+ — `dpi_plus` — `0x0e` +5. DPI− — `dpi_minus` — `0x0f` + +The catalog MUST NOT derive display order from numeric wire IDs. Browser/system actions, shortcuts, Easy Aim, macros, and unknown values MUST remain excluded and fail closed. + +#### Scenario: Mouse Controls catalog is exact + +- **GIVEN** the remap catalog is requested +- **WHEN** its categories and entries are rendered +- **THEN** it contains one `Mouse Controls` category +- **AND** that category contains exactly the five entries above in the stated order +- **AND** each entry retains its stated wire ID + +#### Scenario: Excluded values fail closed + +- **GIVEN** a configuration contains a Browser/system action, shortcut, Easy Aim, macro, or unknown value +- **WHEN** authoritative validation runs +- **THEN** validation rejects the configuration before report encoding or device I/O + +### Requirement: Selectors expose Mouse Controls without changing existing categories + +Every one of the seven physical-button selectors MUST retain the existing Basic and Multimedia catalog/order and MUST visibly expose the new Mouse Controls category in the specified order. No selector may expose an eighth button or internal wire group 4 as a physical slot. + +#### Scenario: All seven selectors show the new category + +- **GIVEN** the remap panel is ready +- **WHEN** each physical-button selector is opened +- **THEN** it exposes Basic, Multimedia, and Mouse Controls +- **AND** Mouse Controls contains exactly the five ordered actions +- **AND** the existing categories remain unchanged + +### Requirement: Mouse Controls assignments are restricted by physical button + +Buttons 2–7 MUST accept all five Mouse Controls actions. Button 1 MUST retain its current assignment and MUST display all five Mouse Controls entries as visibly and accessibly disabled. Backend validation MUST reject a Button 1 Mouse Controls assignment before state mutation, encoding, persistence, transport, or I/O. + +#### Scenario: Buttons 2–7 accept every Mouse Controls action + +- **GIVEN** any physical Button 2–7 and any Mouse Controls action +- **WHEN** the action is staged and explicitly applied +- **THEN** staging succeeds without a device write +- **AND** apply uses the exact action ID for the selected action + +#### Scenario: Button 1 entries are disabled + +- **GIVEN** the Button 1 selector is rendered +- **WHEN** its Mouse Controls category is inspected +- **THEN** all five entries are visible and have disabled semantics +- **AND** pointer, Enter, and Space interaction cannot stage an entry + +#### Scenario: Backend rejects a Button 1 bypass + +- **GIVEN** a direct request assigns any Mouse Controls action to Button 1 +- **WHEN** authoritative validation runs +- **THEN** it rejects the request before state mutation, report encoding, persistence, transport, or I/O +- **AND** pending, applied, revision, and persistence state remain unchanged + +### Requirement: Existing report and acknowledgement contracts remain unchanged + +`RemapConfig` MUST continue to contain exactly seven ordered physical buttons. The physical-to-wire mapping MUST remain `[1, 2, 3, 7, 8, 5, 6]`; wire group 4 remains an internal group and MUST NOT become an exposed button. Reports MUST remain 59 bytes with header `08 3b 01`, eighteen three-byte groups, unchanged parameters, and the big-endian additive checksum over bytes `[3:57]` stored at `[57:59]`. Only `03 10 50 00 08` MUST be accepted as the remap ACK. + +#### Scenario: A Mouse Controls assignment preserves report shape + +- **GIVEN** an eligible Button 2–7 has one Mouse Controls action +- **WHEN** the complete report is encoded +- **THEN** its length, header, group count, hidden groups, parameter bytes, checksum range, and ACK contract are unchanged +- **AND** only the selected action byte and resulting checksum differ as applicable + +#### Scenario: Existing behavior is preserved + +- **GIVEN** an existing Basic or Multimedia remap configuration +- **WHEN** it is encoded and applied +- **THEN** its report, ACK, binding, and lifecycle behavior remain unchanged + +### Requirement: Explicit apply, persistence, discard, reset, and preserved markers remain unchanged + +Staging MUST perform no device write. Only explicit `ApplyRemap` MAY authorize the existing bounded write, and applied state MUST advance only after the exact ACK. Persistence retry MUST NOT repeat hardware I/O. Discard MUST abandon the local draft without I/O. Factory reset MUST restore the existing Basic default remap. Buttons 6/7 preserved-default markers MUST clear only when a user stages an explicit replacement. + +#### Scenario: Staging is side-effect free + +- **GIVEN** a valid Mouse Controls action is staged on Button 2–7 +- **WHEN** the user does not invoke Apply +- **THEN** no report, transport, persistence, or device I/O occurs + +#### Scenario: Apply is ACK-gated and retry-safe + +- **GIVEN** a valid staged Mouse Controls remap +- **WHEN** Apply is invoked +- **THEN** one existing report lifecycle is used +- **AND** applied state advances only after `03 10 50 00 08` +- **AND** persistence retry does not issue a second device write + +#### Scenario: Reset and DPI markers retain established behavior + +- **GIVEN** factory reset is invoked, or a user stages an explicit replacement on Button 6 or 7 +- **WHEN** the operation completes +- **THEN** reset restores the existing Basic defaults +- **AND** only the explicitly replaced button's preserved-default marker is cleared +- **AND** no new button, wire group, or Mouse Controls reset default is introduced diff --git a/openspec/changes/mouse-controls-remapping/tasks.md b/openspec/changes/mouse-controls-remapping/tasks.md new file mode 100644 index 0000000..04df631 --- /dev/null +++ b/openspec/changes/mouse-controls-remapping/tasks.md @@ -0,0 +1,58 @@ +# Tasks: Mouse Controls Button Remapping + +## Review Workload Forecast + +| Field | Forecast | +|---|---| +| Expected authored changed lines | 260–360, including production and tests; generated files excluded | +| 400-line budget risk | Medium; measure the implementation diff before delivery | +| Chained PRs | Not expected; stop and request maintainer direction if the budget is exceeded | +| Hardware activity | None; all tests use protocol fixtures and fakes | +| Expected production files | `internal/protocol/x6/remap.go`; `internal/x6/remap.go`; `internal/desktop/service.go`; `frontend/src/desktop-contract.ts`; `frontend/src/components/panels/GnomeSelect.tsx`; `frontend/src/components/panels/ButtonRemapPanel.tsx`; `frontend/src/hooks/useDesktopWorkspace.ts` | +| Expected test files | `internal/protocol/x6/remap_test.go`; `internal/x6/remap_test.go` if adapter coverage is required; `internal/desktop/service_explicit_apply_test.go`; `frontend/src/components/panels/ButtonRemapPanel.test.tsx` | +| Explicitly not changed | `internal/mouse/**`, `internal/hidlinux/**`, `internal/transport/**`, persistence/reset implementations, generated bindings, report infrastructure, captures, and documentation outside this change | + +The forecast covers the approved extension only. If actual authored changes exceed 400 lines, pause before delivery preparation and obtain a maintainer decision; do not silently chain or claim an exception. + +## RED + +- [x] 1. Add table-driven protocol tests for the five exact action values/IDs, explicit catalog order, Buttons 2–7 eligibility, every Button 1 rejection, excluded/unknown values, seven-button shape, non-linear mapping, hidden wire group 4, unchanged parameters, checksum, and exact remap ACK. +- [x] 2. Add adapter/desktop tests for catalog publication, selected binding, Button 1 rejection before pending/applied/revision/config changes, encoding, persistence, transport, and device I/O, ACK failure/success, persistence retry without a second write, discard, and Basic reset regression. +- [x] 3. Add panel/selector tests for seven visible grouped selectors, exact Mouse Controls labels/order, Button 1 disabled pointer/keyboard behavior, Button 2–7 staging, existing-category retention, and Buttons 6/7 marker behavior. + +## GREEN + +- [x] 4. Extend the closed protocol action set and `ValidateRemapConfig` in `internal/protocol/x6/remap.go`; preserve report construction, mapping, checksum, and ACK handling. +- [x] 5. Re-export the five typed values in `internal/x6/remap.go` and publish the categorized catalog from `internal/desktop/service.go` without changing snapshot/lifecycle contracts. +- [x] 6. Extend the handwritten frontend contract and existing selector/panel with category metadata and accessible disabled options; keep ungrouped `GnomeSelect` callers unchanged. +- [x] 7. Preserve the established `useDesktopWorkspace.ts` staging behavior so an explicit replacement clears only the targeted Button 6/7 preserved-default marker. + +## TRIANGULATE + +- [x] 8. Cover all 30 eligible button/action combinations (Buttons 2–7 × five actions) at the protocol layer and all five Button 1 rejections through authoritative validation and desktop boundaries. +- [x] 9. Verify exact IDs at each eligible non-linear wire target, report length/header/groups/parameters/checksum, exact ACK, excluded values, and no exposure of an eighth button or wire group 4. +- [x] 10. Verify staging is inert; apply is explicit and ACK-gated; failed ACK leaves applied state unchanged; persistence retry performs no second device write; discard and Basic reset remain unchanged. +- [x] 11. Verify the seven selectors, category/order, accessible disabled semantics, keyboard navigation through the selector trigger, Button 2–7 selection, and targeted DPI-marker clearing. + +## REFACTOR + +- [x] 12. Consolidate catalog/action metadata and test fixtures only after the full RED/GREEN/TRIANGULATE boundary is green. Keep product order explicit and fail-closed handling obvious. +- [x] 13. Inspect the final implementation diff for the 400-line budget and forbidden generated/transport/HID/persistence/reset/report changes. + +## Verification + +Run the commands required by `openspec/config.yaml`, one at a time, during implementation: + +```text +go test ./... +go test -race ./internal/desktop/... +go vet ./... +cd frontend && npm test +cd frontend && npm run build +``` + +Compare every implementation scenario in `specs/button-remapping/spec.md` against its test delta before delivery. This planning change itself does not run implementation verification. + +## Boundaries + +No task may run as root, claim USB interfaces, replay captures against hardware, write hidraw, alter generated bindings, change transport/HID/persistence/reset/report infrastructure, add a physical button, or expose internal wire group 4. Do not edit production code or tests as part of this OpenSpec authoring task.