diff --git a/src/app/App.tsx b/src/app/App.tsx
index 06e69b3d..062a3e4e 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -15,7 +15,7 @@ import { BatteryIcon } from "./ui";
import { DpiCard } from "./cards/DpiCard";
import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards";
import { LightingCard } from "./cards/LightingCard";
-import { MxMasterCards } from "./cards/MxMasterCards";
+import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards";
import {
DebounceCard,
EggButtonCard,
@@ -140,6 +140,8 @@ function Workspace({
show(has.eggPolling, ["performance"]) ? : null,
show(has.eggCpi, ["performance"]) ? : null,
show(has.eggButtons, ["buttons"]) ? : null,
+ show(has.mxMasterButtons, ["buttons"])
+ ? : null,
show(has.pulsarPro, ["profiles"]) ? : null,
].filter((node) => node !== null);
diff --git a/src/app/cards/MxMasterCards.tsx b/src/app/cards/MxMasterCards.tsx
index ab4bba6b..951c398c 100644
--- a/src/app/cards/MxMasterCards.tsx
+++ b/src/app/cards/MxMasterCards.tsx
@@ -115,6 +115,93 @@ function EasySwitchCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
);
}
+/**
+ * Reprogrammable controls.
+ *
+ * Only the controls the device says are reprogrammable get a dropdown, and the
+ * targets in it come from the device's own group mask. Left and right click
+ * report an empty mask, so the firmware — not this card — is what keeps the
+ * primary buttons where they are; they are still listed with a note, so the
+ * absence of a dropdown reads as a restriction rather than a missing button.
+ *
+ * Virtual controls are left out entirely. A device reports them (the MX Master
+ * 4's "virtual gesture button" is the event its gesture button emits when held
+ * and dragged), but they are not something anyone can press, so a row for one
+ * is noise rather than a restriction worth showing.
+ */
+export function MxMasterButtonsCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
+ const all = snapshot.buttons;
+ if (!all || all.length === 0) return null;
+ const controls = all.filter((button) => !button.virtual);
+ if (controls.length === 0) return null;
+ const diverted = controls.filter((button) => button.diverted);
+ const firmwareLocked = controls.filter((button) => !button.reprogrammable);
+ const busy = snapshot.settingInProgress;
+ const nameOf = (controlId: number): string =>
+ all.find((candidate) => candidate.controlId === controlId)?.name
+ ?? `Control 0x${controlId.toString(16).padStart(4, "0").toUpperCase()}`;
+ const anyStaged = snapshot.pending.keys.some((key) => key.startsWith("button-"));
+ return (
+
+
+ {firmwareLocked.length > 0 ? (
+
+ {firmwareLocked.map((button) => button.name).join(" and ")}
+ {firmwareLocked.length === 1 ? " is" : " are"} locked by the mouse's firmware and cannot be
+ remapped.
+
+ ) : null}
+ {diverted.length > 0 ? (
+
+
+ {diverted.length === 1
+ ? `${diverted[0]!.name} is being handled by another application, so it does nothing here.`
+ : `${diverted.map((button) => button.name).join(", ")} are being handled by another `
+ + "application, so they do nothing here."}
+
+
+
+ ) : null}
+
+ );
+}
+
export function MxMasterCards({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
return <>>;
}
diff --git a/src/app/cards/availability.test.ts b/src/app/cards/availability.test.ts
index 4224c361..9bc62a87 100644
--- a/src/app/cards/availability.test.ts
+++ b/src/app/cards/availability.test.ts
@@ -20,6 +20,7 @@ function snapshot(overrides: {
capabilities?: Partial;
settingsPending?: boolean;
showExperimental?: boolean;
+ buttons?: ControlSnapshot["buttons"];
}): ControlSnapshot {
const status = overrides.status === null ? null : { ...STATUS, ...overrides.status };
return {
@@ -27,10 +28,31 @@ function snapshot(overrides: {
traits: traitsFor(status),
capabilities: overrides.capabilities ?? null,
settingsPending: overrides.settingsPending ?? false,
+ buttons: overrides.buttons ?? null,
preferences: { showExperimental: overrides.showExperimental ?? true },
} as unknown as ControlSnapshot;
}
+const CONTROL = {
+ controlId: 0x00c3, taskId: 0x009c, flags: 0x31, group: 2, groupMask: 3,
+ name: "Gesture button", taskName: "Gesture button", reprogrammable: true,
+ mappedTo: 0x00c3, diverted: false, remappableTo: [0x0052], remapFlags: 0,
+} as unknown as NonNullable[number];
+
+test("the button card appears only when the mouse reports controls", () => {
+ // The driver answers with an empty list on a mouse without 0x1B04, so this
+ // must key on the controls themselves rather than on the brand.
+ assert.equal(cardAvailability(snapshot({})).mxMasterButtons, false);
+ assert.equal(cardAvailability(snapshot({ buttons: [] })).mxMasterButtons, false);
+ assert.equal(cardAvailability(snapshot({ buttons: [CONTROL] })).mxMasterButtons, true);
+});
+
+test("a non-Logitech mouse never gets the button card", () => {
+ assert.equal(cardAvailability(snapshot({
+ status: { brand: "Pulsar" }, buttons: [CONTROL],
+ })).mxMasterButtons, false);
+});
+
test("no device offers no cards at all", () => {
const has = cardAvailability(snapshot({ status: null }));
assert.equal(Object.values(has).every((value) => value === false), true);
diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts
index 4184071c..bb3b0140 100644
--- a/src/app/cards/availability.ts
+++ b/src/app/cards/availability.ts
@@ -24,6 +24,7 @@ export interface CardAvailability {
eggPolling: boolean;
eggCpi: boolean;
eggButtons: boolean;
+ mxMasterButtons: boolean;
pulsarPro: boolean;
profiles: boolean;
logitechDetails: boolean;
@@ -52,6 +53,7 @@ const NOTHING: CardAvailability = {
eggPolling: false,
eggCpi: false,
eggButtons: false,
+ mxMasterButtons: false,
pulsarPro: false,
profiles: false,
logitechDetails: false,
@@ -118,6 +120,12 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
eggCpi: eggs,
eggButtons: eggs
&& status.eggMulticlickFilters !== undefined && status.eggButtonMappings !== undefined,
+ // The driver reports an empty list for a mouse without 0x1B04, which the
+ // controller stores as null — so this is "the device has controls", not
+ // "the device is an MX Master".
+ // Not gated on `host`: Logitech opts out of the shared advanced section,
+ // and this card lives in the buttons tab regardless.
+ mxMasterButtons: traits.logitech && (snapshot.buttons?.length ?? 0) > 0,
pulsarPro: host && isPulsarProProtocol(status),
};
}
diff --git a/src/control.css b/src/control.css
index 624fc2a1..fe20f406 100644
--- a/src/control.css
+++ b/src/control.css
@@ -1169,3 +1169,13 @@ body { height: 100vh; overflow: hidden; }
.easy-switch-confirm p { margin: 0 0 .5rem; color: #e2c489; font-size: .66rem; line-height: 1.45; }
.easy-switch-confirm-actions { display: flex; gap: .4rem; }
.easy-switch-confirm-actions button { padding: .32rem .6rem; border: 1px solid #7a6234; border-radius: 6px; background: #2e2718; color: #f0d9a4; font-size: .64rem; }
+
+.button-remap-list { display: flex; flex-direction: column; gap: .3rem; }
+.button-remap-row { display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .3rem .45rem; border: 1px solid transparent; border-radius: 6px; color: var(--faint); font-size: .66rem; }
+.button-remap-row select { max-width: 11rem; }
+/* A control the firmware will not let anything point at reads as a fact about
+ the mouse, not a disabled input the user should try to enable. */
+.button-remap-row output { color: var(--dim); font-size: .64rem; }
+.button-remap-diverted { margin-top: .6rem; padding: .55rem .65rem; border: 1px solid #5c4a2a; border-radius: 7px; background: #241f16; }
+.button-remap-diverted p { margin: 0 0 .5rem; color: #e2c489; font-size: .66rem; line-height: 1.45; }
+.button-remap-diverted button { padding: .32rem .6rem; border: 1px solid #7a6234; border-radius: 6px; background: #2e2718; color: #f0d9a4; font-size: .64rem; }
diff --git a/src/device/controller.ts b/src/device/controller.ts
index 1c777325..2ebae178 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -79,7 +79,9 @@ import {
LOGITECH_HAPTIC_EFFECTS,
LOGITECH_HAPTIC_PRESETS,
LOGITECH_SMART_SHIFT_OFF,
+ logitechControlName,
type LogitechHapticPreset,
+ type LogitechReprogrammableControl,
} from "@openmouse/protocol/logitech";
import { PulsarProHidClient } from "@openmouse/protocol/drivers/pulsar/pulsar-pro-hid";
import { OrbitalHidClient } from "@openmouse/protocol/drivers/orbital/hid";
@@ -172,6 +174,7 @@ const pulsarClient = (): PulsarClient | null =>
: null;
let onboardProfiles: OnboardProfile[] | null = null;
+let buttons: LogitechReprogrammableControl[] | null = null;
let onboardProfilesLoading = false;
let lastDeviceMode: MouseStatus["deviceMode"] = "Unknown";
let lastProfileFormat: MouseStatus["onboardProfileFormat"] = null;
@@ -327,6 +330,7 @@ function buildSnapshot(): ControlSnapshot {
customDpiEditing,
customDpiText,
onboardProfiles,
+ buttons,
editedProfile,
profilesExpanded,
deviceMode: lastDeviceMode,
@@ -336,6 +340,7 @@ function buildSnapshot(): ControlSnapshot {
stagedBunnyHopMs,
stagedProfileRates,
stagedProfileName,
+ stagedButtonMappings: Object.fromEntries(stagedButtonMappings),
analogTuning,
eggPollingDivider,
pending: {
@@ -623,6 +628,99 @@ export async function requestHostSwitch(slot: number): Promise {
}
}
+/**
+ * Reads the reprogrammable controls, if the mouse has any.
+ *
+ * Two round-trips per control puts this well outside what the refresh poll can
+ * afford, so it runs on connect and after a write. A mouse without 0x1B04
+ * answers with an empty list and the card stays hidden.
+ */
+async function readButtons(): Promise {
+ const client = logitechClient();
+ if (!client) {
+ buttons = null;
+ return;
+ }
+ try {
+ const controls = await client.readButtons();
+ buttons = controls.length > 0 ? controls : null;
+ } catch {
+ // A mouse that will not answer keeps the card hidden rather than showing
+ // an empty one; the next connect tries again.
+ buttons = null;
+ }
+}
+
+const BUTTON_GROUP = "logitech-buttons";
+const stagedButtonMappings = new Map();
+
+/**
+ * Stages a remap.
+ *
+ * There is no preview: the controls are not part of MouseStatus, so the card
+ * renders the staged mapping itself rather than mirroring it onto a status
+ * snapshot. Every staged remap shares one group and is written together, in
+ * the order it was staged.
+ */
+export function applyButtonMapping(controlId: number, targetControlId: number): void {
+ if (!logitechClient()) return;
+ const control = buttons?.find((candidate) => candidate.controlId === controlId);
+ if (!control) return;
+ if (control.mappedTo === targetControlId) {
+ stagedButtonMappings.delete(controlId);
+ dropPendingChange(`button-${controlId}`);
+ emit();
+ return;
+ }
+ stagedButtonMappings.set(controlId, targetControlId);
+ const target = logitechControlName(targetControlId);
+ stageChange({
+ key: `button-${controlId}`,
+ group: BUTTON_GROUP,
+ label: `${control.name} → ${target}`,
+ command: `Remap ${control.name} (0x${controlId.toString(16).padStart(4, "0")}) to ${target}`,
+ progress: `Remapping ${control.name} to ${target}…`,
+ apply: writeStagedButtonMappings,
+ });
+}
+
+async function writeStagedButtonMappings(): Promise {
+ const client = logitechClient();
+ if (!client) return;
+ try {
+ for (const [controlId, targetControlId] of stagedButtonMappings) {
+ buttons = await client.setButtonMapping(controlId, targetControlId);
+ }
+ } finally {
+ stagedButtonMappings.clear();
+ }
+}
+
+/**
+ * Hands diverted buttons back to the hardware.
+ *
+ * Immediate rather than staged, unlike a remap. A diversion is state another
+ * application left in the mouse, not a preference this user expressed, and the
+ * button does nothing at all until it is cleared — staging a repair behind a
+ * flash step would leave a dead button dead for no reason.
+ */
+export async function restoreDivertedButtons(): Promise {
+ const client = logitechClient();
+ if (!client || settingInProgress) return;
+ settingInProgress = true;
+ setReadStatus("Restoring buttons to hardware control…");
+ emit();
+ try {
+ buttons = await client.clearButtonDiversion();
+ setReadStatus("Buttons restored to hardware control.");
+ } catch (error) {
+ setReadStatus(error instanceof Error ? error.message : "Unable to restore the buttons.");
+ } finally {
+ settingInProgress = false;
+ emit();
+ }
+}
+
async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise {
if (interfacePreferences.reducedMotion) return;
await wait(milliseconds);
@@ -1145,6 +1243,7 @@ async function activateClientNow(client: SupportedClient): Promise {
latestDeviceStatus = null;
clearActiveClients();
if (activeDevice !== client.device) onboardProfiles = null;
+ buttons = null;
activeDevice = client.device;
recordDiagnosticCommand("Read device status");
lastRenderedStatusKey = null;
@@ -1163,6 +1262,7 @@ async function activateClientNow(client: SupportedClient): Promise {
deviceStatuses.set(client.device, status);
capabilities = readCapabilities();
applyStatus(status);
+ await readButtons();
if (dm) {
await dm.startNotifications(() => {
void refreshStatus();
@@ -2763,6 +2863,12 @@ export function start(): void {
if (!isPendingChange(BUNNY_HOP_KEY)) stagedBunnyHopMs = null;
if (!isPendingChange(PROFILE_RATE_KEY)) stagedProfileRates = { wireless: null, wired: null };
if (!isPendingChange(PROFILE_NAME_KEY)) stagedProfileName = null;
+ // A remap dropped from the pending bar (reverted, or the group flashed)
+ // must leave the side map, or its select stays on the staged value and a
+ // later flash would write a mapping the user took back.
+ for (const controlId of stagedButtonMappings.keys()) {
+ if (!isPendingChange(`button-${controlId}`)) stagedButtonMappings.delete(controlId);
+ }
if (!isPendingChange(DPI_SLOTS_KEY)) {
syncDpiSlotPlan();
}
diff --git a/src/device/types.ts b/src/device/types.ts
index bbbe1f25..3488ec98 100644
--- a/src/device/types.ts
+++ b/src/device/types.ts
@@ -1,4 +1,5 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import type { LogitechReprogrammableControl } from "@openmouse/protocol/logitech";
import type { DpiStageCapabilities, DpiStagePlan, OnboardProfile } from "@openmouse/protocol/drivers/logitech/onboard-profiles";
import type { InterfacePreferences } from "../interface-preferences";
import type { PreviewMode } from "../preview-modes";
@@ -124,6 +125,12 @@ export interface ControlSnapshot {
customDpiText: string;
onboardProfiles: OnboardProfile[] | null;
+ /**
+ * Reprogrammable controls, or null on a mouse that has none. Two round-trips
+ * per control is too much for the refresh poll, so this is read on connect
+ * and after a write rather than alongside the status.
+ */
+ buttons: LogitechReprogrammableControl[] | null;
editedProfile: number | "host" | null;
profilesExpanded: boolean;
deviceMode: MouseStatus["deviceMode"];
@@ -134,6 +141,8 @@ export interface ControlSnapshot {
stagedBunnyHopMs: number | null;
stagedProfileRates: { wireless: number | null; wired: number | null };
stagedProfileName: string | null;
+ /** Control id → staged remap target, for controls with an unflashed remap. */
+ stagedButtonMappings: Record;
analogTuning: AnalogTuningState;
eggPollingDivider: number | null;