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 ( +
+
+

BUTTONS

Remapping

+ {controls.length} +
+
+ {controls.map((button) => { + const staged = snapshot.pending.keys.includes(`button-${button.controlId}`); + const canRemap = button.reprogrammable && button.remappableTo.length > 0; + 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;