diff --git a/package-lock.json b/package-lock.json index e5edf043..165feaca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -459,7 +459,7 @@ }, "node_modules/@openmouse/protocol": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#673ede4c9b42c8e7bb200beb2c0400b3bd16c5cb", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#23dd6a0759badcd461083f28d2f8b0a2f09e327f", "engines": { "node": ">=20" } diff --git a/public/devices/README.md b/public/devices/README.md index be2eea1f..c82d27af 100644 --- a/public/devices/README.md +++ b/public/devices/README.md @@ -28,6 +28,14 @@ product page. official PRO X SUPERLIGHT 2c product gallery. Confirm redistribution terms before including it in a public release package. +`logitech-mx-master-4.png` is a line-art trace made for this repository, not a +vendor render. Only geometry derives from the source: the outer silhouette and +the shell seams — button split, scroll-wheel housing, thumb rest, thumb wheel, +side-panel crease and the wheel-mode button — were authored as paths against a +product image. No colour, shading, texture or lettering was carried over, and +the Logitech wordmark on the shell was deliberately excluded rather than faded, +since it is a trademark and this repository is public. + `endgame-gear-op1-8k.png` was supplied from an Overclockers UK product-image URL for the OP1 8K. Confirm redistribution terms before including it in a public release package. diff --git a/public/devices/logitech-mx-master-4.png b/public/devices/logitech-mx-master-4.png new file mode 100644 index 00000000..e0d84033 Binary files /dev/null and b/public/devices/logitech-mx-master-4.png differ diff --git a/src/control-events.ts b/src/control-events.ts index 401d4c57..c47245a5 100644 --- a/src/control-events.ts +++ b/src/control-events.ts @@ -57,6 +57,17 @@ export interface ControlEventHandlers { capLandingToLiftOff(): void; applyGamingSurfaceMode(mode: NonNullable): void; applyLightforceSwitchMode(mode: NonNullable): void; + applyHapticIntensity(preset: string): void; + applyHapticEnabled(enabled: boolean): void; + applyHapticBatterySaving(enabled: boolean): void; + applyWheelMode(mode: NonNullable): void; + applySmartShiftThreshold(threshold: number | null): void; + applyHiResScroll(enabled: boolean): void; + applyInvertScroll(inverted: boolean): void; + applyThumbWheelInverted(inverted: boolean): void; + applyFriendlyName(name: string): void; + requestHostSwitch(): Promise; + cancelHostSwitch(): void; applyLighting(patch: Partial>): void; applyNinjutsoSetting(setting: "system" | "hyper" | "optical" | "slam", value: string | boolean): void; // Mode and profile selection apply immediately: both are volatile navigation @@ -396,6 +407,67 @@ export function bindControlEvents(handlers: ControlEventHandlers): void { document.querySelector("#onboard-refresh")?.addEventListener("click", () => { void handlers.reloadOnboardProfiles(); }); + document.querySelectorAll("[data-haptic]").forEach((button) => { + button.addEventListener("click", () => { + const preset = button.dataset.haptic; + if (preset) handlers.applyHapticIntensity(preset); + }); + }); + + const switchToggle = (id: string, apply: (on: boolean) => void): void => { + document.querySelector(id)?.addEventListener("click", (event) => { + apply((event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true"); + }); + }; + switchToggle("#haptic-enabled-toggle", (on) => handlers.applyHapticEnabled(on)); + switchToggle("#haptic-battery-toggle", (on) => handlers.applyHapticBatterySaving(on)); + switchToggle("#hi-res-toggle", (on) => handlers.applyHiResScroll(on)); + switchToggle("#invert-scroll-toggle", (on) => handlers.applyInvertScroll(on)); + switchToggle("#thumb-wheel-toggle", (on) => handlers.applyThumbWheelInverted(on)); + + document.querySelectorAll("[data-wheel-mode]").forEach((button) => { + button.addEventListener("click", () => { + const mode = button.dataset.wheelMode as MouseStatus["wheelMode"]; + if (mode) handlers.applyWheelMode(mode); + }); + }); + + document.querySelector("#smart-shift-toggle")?.addEventListener("click", (event) => { + const enabling = (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true"; + const slider = document.querySelector("#smart-shift-threshold"); + // Re-enabling restores what the slider shows, which is the last value the + // mouse reported rather than an invented default. + handlers.applySmartShiftThreshold(enabling ? Number(slider?.value ?? 0) || null : null); + }); + + document.querySelector("#smart-shift-threshold")?.addEventListener("change", (event) => { + handlers.applySmartShiftThreshold(Number((event.currentTarget as HTMLInputElement).value)); + }); + + document.querySelector("#device-name-input")?.addEventListener("input", (event) => { + // Keeps the counter and the button honest while typing, from the field + // itself rather than from a status snapshot. + const input = event.currentTarget as HTMLInputElement; + const typed = input.value.trim(); + const apply = document.querySelector("#device-name-apply"); + if (apply) apply.disabled = typed.length === 0 || typed === input.dataset.current; + const remaining = document.querySelector("#device-name-remaining"); + if (remaining) remaining.textContent = `${typed.length}/${input.maxLength}`; + }); + + document.querySelector("#device-name-apply")?.addEventListener("click", () => { + const input = document.querySelector("#device-name-input"); + const name = input?.value.trim() ?? ""; + if (name) handlers.applyFriendlyName(name); + }); + + document.querySelector("#easy-switch-go")?.addEventListener("click", () => { + void handlers.requestHostSwitch(); + }); + document.querySelector("#easy-switch-cancel")?.addEventListener("click", () => { + handlers.cancelHostSwitch(); + }); + document.querySelectorAll("[data-lightforce]").forEach((button) => { button.addEventListener("click", () => { const mode = button.dataset.lightforce as MouseStatus["lightforceSwitchMode"]; diff --git a/src/control-template.ts b/src/control-template.ts index 315a3e58..7efd79d6 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -104,6 +104,10 @@ export function controlTemplate(buildLabel: string): string {

DPI

Sensitivity

Choose a DPI value

POLLING RATE

Report frequency

Higher rates update cursor movement more often, but use more battery.

SENSOR

Lift-off distance

Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.
+ + + +
diff --git a/src/control.css b/src/control.css index aae2692c..eb3a1a6d 100644 --- a/src/control.css +++ b/src/control.css @@ -1051,3 +1051,28 @@ body { height: 100vh; overflow: hidden; } .dpi-header-actions { width: 100%; } .dpi-header-actions input { width: 0; flex: 1 1 auto; font-size: 1.7rem; text-align: left; } } + +/* MX Master 4: wheel threshold, rename and Easy-Switch cards. */ +.wheel-threshold { display: block; margin-top: .35rem; color: var(--faint); font-size: .62rem; } +.wheel-threshold input { width: 100%; margin-top: .25rem; } + +.name-row { display: flex; gap: .4rem; } +.name-row input { flex: 1; min-width: 0; } +/* Matches .dpi-header-actions: without an explicit colour the button inherits + nothing legible once the card picks up its staged accent border. */ +.name-row button { padding: .4rem .65rem; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--card); color: var(--dim); font-size: .65rem; font-weight: 700; } +.name-row button:disabled { cursor: default; opacity: .45; } +.control-shell .name-row button:not(:disabled) { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); } + +.easy-switch-slots { display: flex; flex-wrap: wrap; gap: .35rem; } +.easy-switch-slots > * { min-width: 2.1rem; padding: .28rem .5rem; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--card); color: var(--dim); font-size: .68rem; text-align: center; } +/* An empty slot is rendered as a span, never a button, so it must not look like one. */ +.easy-switch-slots span { opacity: .5; cursor: default; } +.easy-switch-slots button { cursor: pointer; } +.easy-switch-slots .is-current { border-color: rgb(105 210 141 / 55%); background: rgb(105 210 141 / 12%); color: #8be3a9; } + +.easy-switch-confirm { margin-top: .55rem; padding: .55rem .65rem; border: 1px solid #5c4a2a; border-radius: 7px; background: #241f16; } +.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; } +.easy-switch-confirm-actions button:last-child { border-color: var(--line-strong); background: var(--card); color: var(--dim); } diff --git a/src/control.ts b/src/control.ts index 7cad747b..99866fea 100644 --- a/src/control.ts +++ b/src/control.ts @@ -2,6 +2,12 @@ import "./control.css"; import { estimateBatteryTime, saveBatterySample, type BatteryMode } from "./battery-history"; import { unsupportedNotice, unsupportedTemplate } from "./browser-support"; import { controlTemplate } from "./control-template"; +import { + LOGITECH_HAPTIC_EFFECTS as HAPTIC_EFFECTS, + LOGITECH_HAPTIC_PRESETS as HAPTIC_PRESET_VALUES, + LOGITECH_SMART_SHIFT_OFF as SMART_SHIFT_OFF, + type LogitechHapticPreset as HapticPreset, +} from "@openmouse/protocol/logitech"; import { bindControlEvents } from "./control-events"; import { initColorPickers, syncColorPickers } from "./ui/color-picker"; import { @@ -545,6 +551,17 @@ function renderControl(): void { capLandingToLiftOff, applyGamingSurfaceMode, applyLightforceSwitchMode, + applyHapticIntensity: (preset: string) => applyHapticIntensity(preset as HapticPreset), + applyHapticEnabled, + applyHapticBatterySaving, + applyWheelMode, + applySmartShiftThreshold, + applyHiResScroll, + applyInvertScroll, + applyThumbWheelInverted, + applyFriendlyName, + requestHostSwitch, + cancelHostSwitch, applyLighting, applyNinjutsoSetting, flashPendingChanges, @@ -581,6 +598,7 @@ function renderControl(): void { } if (!isPendingChange("gaming-surface")) stagedGamingSurface = null; if (!isPendingChange("lightforce-switch-mode")) stagedLightforce = null; + clearLogitechStaging(); renderBunnyHop(); }); onPendingChanges(renderStagedMarkers); @@ -1558,6 +1576,7 @@ function showStatus(deviceStatus: MouseStatus): void { ? "Turn the gaming surface on or set it to auto to adjust lift-off distance." : "Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer."; } + renderMxMaster4Cards(status, settingsPending); const gamingSurfaceRow = document.querySelector("#gaming-surface-row"); if (gamingSurfaceRow) gamingSurfaceRow.hidden = !status.gamingSurfaceMode; document.querySelectorAll("[data-gaming-surface]").forEach((button) => { @@ -3623,6 +3642,397 @@ const MODE_STATUS_GROUP = "logitech-mode-status"; let stagedGamingSurface: MouseStatus["gamingSurfaceMode"] = null; let stagedLightforce: MouseStatus["lightforceSwitchMode"] = null; +/** + * The MX Master 4 haptic pair, the wheel trio and the hi-res mode byte each + * ride in a single device write, so everything staged into one of these groups + * is written together. Staging them separately would cost two writes to one + * byte, and the second would be built from a device read the first already + * invalidated. + */ +/** + * Easy-Switch is deliberately absent from the staged-changes model. A switch + * disconnects this computer, so it cannot sit in a batch alongside writes that + * would never land, and there is nothing to preview: the mouse is either here + * or it is not. + */ +let pendingHostSlot: number | null = null; + +function renderMxMaster4Cards(status: MouseStatus, settingsPending: boolean): void { + renderHapticsCard(status, settingsPending); + renderWheelCard(status, settingsPending); + renderDeviceNameCard(status, settingsPending); + renderEasySwitchCard(status); +} + +function renderHapticsCard(status: MouseStatus, settingsPending: boolean): void { + const card = document.querySelector("#haptics-card"); + if (!card) return; + const intensity = status.hapticIntensity; + card.hidden = intensity === null || intensity === undefined; + if (card.hidden) return; + + const preset = (Object.keys(HAPTIC_PRESET_VALUES) as HapticPreset[]) + .find((name) => HAPTIC_PRESET_VALUES[name] === intensity); + // Strength is meaningless while the motor is off, so the presets follow the + // master switch rather than sitting there looking clickable. + const enabled = status.hapticEnabled === true; + + document.querySelectorAll("[data-haptic]").forEach((button) => { + setSelected(button, button.dataset.haptic === preset); + button.disabled = settingsPending || !enabled; + }); + setToggleValue("#haptic-enabled-toggle", status.hapticEnabled); + setToggleValue("#haptic-battery-toggle", status.hapticBatterySaving); + setText("#haptic-output", enabled ? preset ?? String(intensity) : "Off"); +} + +function renderWheelCard(status: MouseStatus, settingsPending: boolean): void { + const card = document.querySelector("#wheel-card"); + if (!card) return; + const hasWheel = status.wheelMode != null || status.hiResScroll != null; + card.hidden = !hasWheel; + if (!hasWheel) return; + + document.querySelectorAll("[data-wheel-mode]").forEach((button) => { + setSelected(button, button.dataset.wheelMode === status.wheelMode); + button.disabled = settingsPending || status.wheelMode == null; + }); + + // 255 disables SmartShift; any other value is both "on" and the setting. + const threshold = status.smartShiftThreshold; + const smartShiftOn = threshold != null && threshold !== SMART_SHIFT_OFF; + const smartShiftRow = document.querySelector("#smart-shift-row"); + if (smartShiftRow) smartShiftRow.hidden = threshold == null; + setToggleValue("#smart-shift-toggle", smartShiftOn); + + const slider = document.querySelector("#smart-shift-threshold"); + const thresholdRow = document.querySelector("#smart-shift-threshold-row"); + if (thresholdRow) thresholdRow.hidden = !smartShiftOn; + if (slider) { + // The refresh must not yank the handle out from under a drag. + if (smartShiftOn && document.activeElement !== slider) slider.value = String(threshold); + slider.disabled = settingsPending || !smartShiftOn; + } + setText("#smart-shift-value", smartShiftOn ? String(threshold) : "—"); + + setToggleValue("#hi-res-toggle", status.hiResScroll); + const invertRow = document.querySelector("#invert-scroll-row"); + if (invertRow) invertRow.hidden = status.supportsInvertScroll !== true; + setToggleValue("#invert-scroll-toggle", status.invertScroll); + const thumbRow = document.querySelector("#thumb-wheel-row"); + if (thumbRow) thumbRow.hidden = status.supportsThumbWheelInvert !== true; + setToggleValue("#thumb-wheel-toggle", status.thumbWheelInverted); + + setText("#wheel-ratchet-state", status.wheelRatchetEngaged == null + ? "—" + : status.wheelRatchetEngaged ? "Ratcheted now" : "Free-spinning now"); +} + +function renderDeviceNameCard(status: MouseStatus, settingsPending: boolean): void { + const card = document.querySelector("#device-name-card"); + if (!card) return; + const current = status.friendlyName; + const maxLength = status.friendlyNameMaxLength; + card.hidden = current == null || maxLength == null; + if (card.hidden || current == null || maxLength == null) return; + + const input = document.querySelector("#device-name-input"); + if (input) { + input.maxLength = maxLength; + input.dataset.current = current; + // Left alone while focused so the refresh cannot overwrite a half-typed name. + if (document.activeElement !== input) input.value = current; + input.disabled = settingsPending; + } + const typed = input?.value.trim() ?? ""; + const apply = document.querySelector("#device-name-apply"); + if (apply) apply.disabled = settingsPending || typed.length === 0 || typed === current; + setText("#device-name-remaining", `${typed.length}/${maxLength}`); +} + +function renderEasySwitchCard(status: MouseStatus): void { + const card = document.querySelector("#easy-switch-card"); + if (!card) return; + const { hostCount, currentHost, hostSlotsPaired } = status; + const known = hostCount != null && currentHost != null; + card.hidden = !known; + if (!known || hostCount == null || currentHost == null) { + cancelHostSwitch(); + return; + } + + const slots = document.querySelector("#easy-switch-slots"); + if (slots) { + slots.replaceChildren(...Array.from({ length: hostCount }, (_unused, index) => { + const active = index === currentHost; + const paired = hostSlotsPaired?.[index] === true; + /* + * Only a paired slot that is not the current one is a button. An empty + * slot is a plain span and cannot be clicked at all: switching there + * leaves the mouse unreachable until someone presses the button on its + * underside, which is not a thing a misclick should be able to do. + */ + const switchable = paired && !active; + const pill = document.createElement(switchable ? "button" : "span"); + pill.textContent = String(index + 1); + pill.title = active + ? "This computer" + : paired ? `Request a switch to computer ${index + 1}` : "Empty slot — nothing paired here"; + if (active) pill.classList.add("is-current"); + if (switchable) { + (pill as HTMLButtonElement).type = "button"; + pill.addEventListener("click", () => askForHostSwitch(index)); + } + return pill; + })); + } + + const pairedCount = hostSlotsPaired?.filter(Boolean).length ?? 0; + setText("#easy-switch-output", `On ${currentHost + 1} of ${hostCount} · ${pairedCount} paired`); +} + +/** Two-step confirmation that says what happens and how to undo it. */ +function askForHostSwitch(slot: number): void { + const row = document.querySelector("#easy-switch-confirm"); + const message = document.querySelector("#easy-switch-confirm-text"); + if (!row || !message) return; + pendingHostSlot = slot; + message.textContent = + `Request a switch to computer ${slot + 1}? The mouse will disconnect from this computer ` + + "straight away. To bring it back, press the button underneath the mouse."; + row.hidden = false; + document.querySelector("#easy-switch-go")?.focus(); +} + +function cancelHostSwitch(): void { + pendingHostSlot = null; + const row = document.querySelector("#easy-switch-confirm"); + if (row) row.hidden = true; +} + +/** + * Sends the mouse away. Says "requested" rather than "switched" because it + * cannot know: the device is gone the moment this succeeds, so nothing can be + * read back to confirm it arrived. + */ +async function requestHostSwitch(): Promise { + const slot = pendingHostSlot; + const client = activeClient; + cancelHostSwitch(); + if (!client || slot === null) return; + + try { + await client.requestHostSwitch(slot); + } catch (error) { + setText("#read-status", error instanceof Error ? error.message : "Unable to request the switch."); + return; + } + setText("#read-status", + `Switch to computer ${slot + 1} requested. If it worked the mouse has left this computer — ` + + "press the button underneath it to bring it back.", + ); +} + +const HAPTIC_GROUP = "logitech-haptic"; +const RATCHET_GROUP = "logitech-ratchet"; +const WHEEL_MODE_GROUP = "logitech-wheel-mode"; + +let stagedHapticIntensity: number | null = null; +let stagedHapticEnabled: boolean | null = null; +let stagedHapticBatterySaving: boolean | null = null; +let stagedWheelMode: MouseStatus["wheelMode"] = null; +/** + * undefined means nothing is staged; null means staged as off. Using null for + * both made "SmartShift off" stage a change that the apply then skipped, so it + * flashed successfully and wrote nothing, and the next poll read the old value + * back as if the switch had bounced. + */ +let stagedSmartShift: number | null | undefined; +let stagedHiRes: boolean | null = null; +let stagedInvertScroll: boolean | null = null; + +/** + * Drops a staged value once its change is no longer pending — flashed or + * reverted. Keeping it would let a later write in the same group resend a + * value the user had already discarded. + */ +function clearLogitechStaging(): void { + if (!isPendingChange("haptic-strength")) stagedHapticIntensity = null; + if (!isPendingChange("haptic-enabled")) stagedHapticEnabled = null; + if (!isPendingChange("haptic-battery-saving")) stagedHapticBatterySaving = null; + if (!isPendingChange("wheel-mode")) stagedWheelMode = null; + if (!isPendingChange("smart-shift")) stagedSmartShift = undefined; + if (!isPendingChange("hi-res-scroll")) stagedHiRes = null; + if (!isPendingChange("invert-scroll")) stagedInvertScroll = null; +} + +async function writeStagedHaptics(): Promise { + if (!activeClient) throw new Error("The mouse is no longer connected."); + // Order matters only in that strength is pointless while the motor is off, + // so the enable lands first and the sample buzz below is felt at the new + // strength rather than the old one. + if (stagedHapticEnabled !== null) await activeClient.setHapticEnabled(stagedHapticEnabled); + if (stagedHapticBatterySaving !== null) await activeClient.setHapticBatterySaving(stagedHapticBatterySaving); + if (stagedHapticIntensity !== null) await activeClient.setHapticIntensity(stagedHapticIntensity); + + /* + * The device does not buzz by itself. Logi Options+ plays an effect straight + * after these writes, and someone who has used the vendor software notices + * its absence — but a mouse that refuses the sample must not make a strength + * change that worked look failed. + */ + const shouldBuzz = stagedHapticIntensity !== null || stagedHapticEnabled === true; + if (shouldBuzz) { + const effect = stagedHapticEnabled === true && stagedHapticIntensity === null + ? HAPTIC_EFFECTS.enableConfirmation + : HAPTIC_EFFECTS.strengthSample; + await activeClient.playHapticEffect(effect).catch(() => undefined); + } +} + +async function writeStagedRatchet(): Promise { + if (!activeClient) throw new Error("The mouse is no longer connected."); + if (stagedWheelMode) await activeClient.setWheelMode(stagedWheelMode); + if (stagedSmartShift !== undefined) await activeClient.setSmartShiftThreshold(stagedSmartShift); +} + +async function writeStagedWheelMode(): Promise { + if (!activeClient) throw new Error("The mouse is no longer connected."); + if (stagedHiRes !== null) await activeClient.setHiResScroll(stagedHiRes); + if (stagedInvertScroll !== null) await activeClient.setInvertScroll(stagedInvertScroll); +} + +function applyHapticIntensity(preset: HapticPreset): void { + if (!activeClient) return; + stagedHapticIntensity = HAPTIC_PRESET_VALUES[preset]; + stageChange({ + key: "haptic-strength", + group: HAPTIC_GROUP, + label: `Haptics ${preset.toLowerCase()}`, + command: `Set haptic strength to ${preset} (${stagedHapticIntensity})`, + progress: `Setting haptic strength to ${preset.toLowerCase()}…`, + preview: (status) => { status.hapticIntensity = HAPTIC_PRESET_VALUES[preset]; }, + apply: writeStagedHaptics, + }); +} + +function applyHapticEnabled(enabled: boolean): void { + if (!activeClient) return; + stagedHapticEnabled = enabled; + stageChange({ + key: "haptic-enabled", + group: HAPTIC_GROUP, + label: `Haptics ${enabled ? "on" : "off"}`, + command: `Turn haptic feedback ${enabled ? "on" : "off"}`, + progress: `Turning haptic feedback ${enabled ? "on" : "off"}…`, + preview: (status) => { status.hapticEnabled = enabled; }, + apply: writeStagedHaptics, + }); +} + +function applyHapticBatterySaving(enabled: boolean): void { + if (!activeClient) return; + stagedHapticBatterySaving = enabled; + stageChange({ + key: "haptic-battery-saving", + group: HAPTIC_GROUP, + label: `Haptic battery saving ${enabled ? "on" : "off"}`, + command: `Turn haptic battery saving ${enabled ? "on" : "off"}`, + progress: `Turning haptic battery saving ${enabled ? "on" : "off"}…`, + preview: (status) => { status.hapticBatterySaving = enabled; }, + apply: writeStagedHaptics, + }); +} + +function applyWheelMode(mode: NonNullable): void { + if (!activeClient) return; + stagedWheelMode = mode; + const wording = mode === "Freespin" ? "free-spin" : "ratchet"; + stageChange({ + key: "wheel-mode", + group: RATCHET_GROUP, + label: `Wheel ${wording}`, + command: `Set the wheel to ${wording}`, + progress: `Setting the wheel to ${wording}…`, + preview: (status) => { status.wheelMode = mode; }, + apply: writeStagedRatchet, + }); +} + +function applySmartShiftThreshold(threshold: number | null): void { + if (!activeClient) return; + stagedSmartShift = threshold; + const off = threshold === null || threshold === SMART_SHIFT_OFF; + stageChange({ + key: "smart-shift", + group: RATCHET_GROUP, + label: off ? "SmartShift off" : `SmartShift ${threshold}`, + command: off ? "Turn SmartShift off" : `Set the SmartShift threshold to ${threshold}`, + progress: off ? "Turning SmartShift off…" : `Setting the SmartShift threshold to ${threshold}…`, + preview: (status) => { status.smartShiftThreshold = threshold ?? SMART_SHIFT_OFF; }, + apply: writeStagedRatchet, + }); +} + +function applyHiResScroll(enabled: boolean): void { + if (!activeClient) return; + stagedHiRes = enabled; + stageChange({ + key: "hi-res-scroll", + group: WHEEL_MODE_GROUP, + label: `High-resolution scrolling ${enabled ? "on" : "off"}`, + command: `Turn high-resolution scrolling ${enabled ? "on" : "off"}`, + progress: `Turning high-resolution scrolling ${enabled ? "on" : "off"}…`, + preview: (status) => { status.hiResScroll = enabled; }, + apply: writeStagedWheelMode, + }); +} + +function applyInvertScroll(inverted: boolean): void { + if (!activeClient) return; + stagedInvertScroll = inverted; + stageChange({ + key: "invert-scroll", + group: WHEEL_MODE_GROUP, + label: inverted ? "Scroll inverted" : "Scroll normal", + command: `${inverted ? "Invert" : "Restore"} the scroll direction`, + progress: `${inverted ? "Inverting" : "Restoring"} the scroll direction…`, + preview: (status) => { status.invertScroll = inverted; }, + apply: writeStagedWheelMode, + }); +} + +function applyThumbWheelInverted(inverted: boolean): void { + if (!activeClient) return; + // Its own write, so it needs no group. + stageChange({ + key: "thumb-wheel-invert", + label: inverted ? "Thumb wheel inverted" : "Thumb wheel normal", + command: `${inverted ? "Invert" : "Restore"} the thumb-wheel direction`, + progress: `${inverted ? "Inverting" : "Restoring"} the thumb wheel…`, + preview: (status) => { status.thumbWheelInverted = inverted; }, + apply: async () => { + if (!activeClient) throw new Error("The mouse is no longer connected."); + await activeClient.setThumbWheelInverted(inverted); + }, + }); +} + +function applyFriendlyName(name: string): void { + if (!activeClient || !name) return; + stageChange({ + key: "friendly-name", + label: `Name "${name}"`, + command: `Rename the mouse to "${name}"`, + progress: `Renaming the mouse to "${name}"…`, + preview: (status) => { status.friendlyName = name; }, + apply: async () => { + if (!activeClient) throw new Error("The mouse is no longer connected."); + await activeClient.setFriendlyName(name); + }, + }); +} + async function writeStagedModeStatus(): Promise { if (!activeClient) throw new Error("The mouse is no longer connected."); await activeClient.setModeStatus({ diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts index a48ff0ca..0dffe0b7 100644 --- a/src/ui/device-images.ts +++ b/src/ui/device-images.ts @@ -61,6 +61,10 @@ function deviceKey(device: HIDDevice): string { export function deviceImage(device: HIDDevice | null | undefined, displayName = ""): string { const mapped = device ? DEVICE_IMAGES.get(deviceKey(device)) ?? null : null; if (mapped) return mapped; + // Matched on the name rather than the product id: a Logi Bolt receiver is + // 046d:c548 whichever mouse is paired to it, so keying the id would put this + // artwork on an MX Master 3S as well. + if (/mx\s*master\s*4/i.test(displayName)) return "/devices/logitech-mx-master-4.png"; if (/superlight/i.test(displayName)) return "/devices/logitech-pro-x-superlight-2c.png"; if (/\bop1\s*8k\b/i.test(displayName)) return "/devices/endgame-gear-op1-8k.png"; if (/\bviper\s*v2\s*pro\b/i.test(displayName)) return "/devices/razer-viper-v2-pro.png";