Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions frontend/src/components/panels/ButtonRemapPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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(<ButtonRemapPanel remap={mouseControlsRemap as never} ready onStage={onStage} />);
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(<ButtonRemapPanel remap={mouseControlsRemap as never} ready onStage={onStage} />);
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(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={onStage} />);
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/panels/ButtonRemapPanel.tsx
Original file line number Diff line number Diff line change
@@ -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[] };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/desktop-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/hooks/useDesktopWorkspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof snapshot> }) => void> = [];
const harness = serviceFor({ OnConfiguration: vi.fn().mockImplementation((callback) => { configurationListeners.push(callback); return vi.fn(); }) });
Expand Down
1 change: 1 addition & 0 deletions internal/desktop/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down
32 changes: 23 additions & 9 deletions internal/desktop/service_explicit_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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))
Expand Down
31 changes: 28 additions & 3 deletions internal/protocol/x6/remap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions internal/protocol/x6/remap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions internal/x6/remap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
19 changes: 19 additions & 0 deletions openspec/changes/mouse-controls-remapping/apply-progress.md
Original file line number Diff line number Diff line change
@@ -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.
Loading