diff --git a/package-lock.json b/package-lock.json
index 5c18f748..38e968fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "2.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
- "@openmouse/protocol": "0.26.0",
+ "@openmouse/protocol": "0.27.0",
"lucide-react": "^1.43.0",
"preact": "^10.29.8"
},
@@ -481,9 +481,9 @@
}
},
"node_modules/@openmouse/protocol": {
- "version": "0.26.0",
- "resolved": "https://registry.npmjs.org/@openmouse/protocol/-/protocol-0.26.0.tgz",
- "integrity": "sha512-VNj2tlFpGCuwQP+XFAxc438pjxHjnEAGF3GjacRWXlTTHzpZ8uPTnWBpaLCUolCF2LKJLyHgJRwDgSBG3h/GAw==",
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/@openmouse/protocol/-/protocol-0.27.0.tgz",
+ "integrity": "sha512-pxl6KwZORt0QNGcLvZaaFk8D+6ranTxYbCKPdkQJpmYdUT8ytKGWWwVYnq0/LrE4PvrIgjbkj17iBtpkuFcr3Q==",
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=20"
diff --git a/package.json b/package.json
index e79a32c6..a95e13d4 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@openmouse/protocol": "0.26.0",
+ "@openmouse/protocol": "0.27.0",
"lucide-react": "^1.43.0",
"preact": "^10.29.8"
},
diff --git a/src/app/MagneticButtons.tsx b/src/app/MagneticButtons.tsx
new file mode 100644
index 00000000..9afd786f
--- /dev/null
+++ b/src/app/MagneticButtons.tsx
@@ -0,0 +1,326 @@
+import { useEffect, useState, type ReactNode } from "react";
+import * as control from "../device/controller";
+import { t } from "../i18n";
+import type { InterfaceLocale } from "../interface-preferences";
+import type { ControlSnapshot } from "../device/types";
+import type { MagneticButtonStatus, MagneticButtonsStatus } from "@openmouse/protocol/drivers/mouse-types";
+
+function Steps({
+ id,
+ min,
+ max,
+ value,
+ onChange,
+ disabled = false,
+ label,
+}: {
+ id: string;
+ min: number;
+ max: number;
+ value: number | null;
+ onChange: (next: number) => void;
+ disabled?: boolean;
+ label: string;
+}): ReactNode {
+ return (
+
+
+ {Array.from({ length: max - min + 1 }, (_, index) => min + index).map((step) => (
+ onChange(step)}>
+ {step}
+
+ ))}
+
+
+ );
+}
+
+function Toggle({
+ id,
+ on,
+ onChange,
+ disabled = false,
+ label,
+ locale,
+}: {
+ id: string;
+ on: boolean | null;
+ onChange: (next: boolean) => void;
+ disabled?: boolean;
+ label: string;
+ locale: InterfaceLocale;
+}): ReactNode {
+ return (
+
+
+ {([false, true] as const).map((value) => (
+ onChange(value)}>
+ {t(locale, value ? "common.on" : "common.off")}
+
+ ))}
+
+
+ );
+}
+
+function nextToTrigger(trigger: number | null, min: number, max: number): number {
+ const value = Math.min(max, Math.max(min, trigger ?? min));
+ return value === 9 ? 10 : value;
+}
+
+function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }): ReactNode {
+ return (
+
+ {label}{hint ? {hint} : null}
+ {children}
+
+ );
+}
+
+/** Press depth runs 0 to 100; the bar has ten segments and marks where the click registers. */
+function DepthMeter({ magnetic }: { magnetic: MagneticButtonsStatus }): ReactNode {
+ const [depth, setDepth] = useState<[number, number]>([0, 0]);
+ useEffect(() => {
+ const stop = control.subscribeButtonDepth((left, right) => setDepth([left, right]));
+ const suppressContextMenu = (event: MouseEvent) => event.preventDefault();
+ window.addEventListener("contextmenu", suppressContextMenu);
+ return () => {
+ stop();
+ window.removeEventListener("contextmenu", suppressContextMenu);
+ };
+ }, []);
+ const span = magnetic.triggerPointRange.max;
+ return (
+
+ {(["Left", "Right"] as const).map((side, index) => {
+ const trigger = magnetic.buttons[index]?.triggerPoint;
+ const mark = trigger ? Math.max(0, Math.min(9, Math.round((trigger / span) * 10) - 1)) : -1;
+ return (
+
+
{side}
+
+ {Array.from({ length: 10 }, (_, step) => (
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+function ButtonControls({
+ group,
+ magnetic,
+ state,
+ locale,
+ brand,
+}: {
+ group: 0 | 1 | "both";
+ magnetic: MagneticButtonsStatus;
+ state: MagneticButtonStatus;
+ locale: InterfaceLocale;
+ brand: string;
+}): ReactNode {
+ const targets: Array<0 | 1> = group === "both" ? [0, 1] : [group];
+ const slug = String(group);
+ const { triggerPointRange: trigger, releasePointRange: release, rapidTriggerRange: rapid } = magnetic;
+ const optical = state.switchType === "optical";
+ const separateRelease = state.releasePoint != null;
+ const rapidLevel = state.rapidTrigger ?? rapid?.min ?? 1;
+ const unit = magnetic.rapidTriggerUnit === "ms" ? " ms" : "";
+ const calibrated = magnetic.calibration !== "needed";
+ // Release 9 is how the mouse says "follows the trigger point", so a separate release starts beside it.
+ const firstRelease = release ? nextToTrigger(state.triggerPoint, release.min, release.max) : 1;
+ return (
+ <>
+ {magnetic.canChooseSwitchType ? (
+
+
+
+ {(["magnetic", "optical"] as const).map((type) => (
+ targets.forEach((button) => control.applyMagneticSwitchType(button, type))}
+ >
+ {type === "magnetic" ? "Magnetic" : "Optical"}
+
+ ))}
+
+
+
+ ) : null}
+
+ targets.forEach((button) => control.applyMagneticTriggerPoint(button, next))}
+ />
+
+ {release ? (
+
+
+ targets.forEach((button) => control.applyMagneticReleasePoint(button, on ? firstRelease : null))}
+ />
+ {separateRelease ? (
+ targets.forEach((button) => control.applyMagneticReleasePoint(button, next))}
+ />
+ ) : null}
+
+
+ ) : null}
+ {rapid ? (
+
+
+ targets.forEach((button) => control.applyMagneticRapidTrigger(button, on, rapidLevel, unit))}
+ />
+ targets.forEach((button) => control.applyMagneticRapidTrigger(button, true, next, unit))}
+ />
+
+
+ ) : null}
+ {rapid && brand === "G-Wolves" ? (
+ Rapid trigger is factory calibrated. Changing it may cause double or dropped inputs.
+ ) : null}
+ >
+ );
+}
+
+function Calibration({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
+ const view = snapshot.magneticCalibration;
+ const magnetic = snapshot.status?.magneticButtons;
+ if (!magnetic || magnetic.calibration === null) {
+ return Calibrate the magnetic switches with the mouse on its cable.
;
+ }
+ const running = view.phase === "running";
+ return (
+
+
+
+
Button Calibration
+
+ {magnetic.calibration === "needed"
+ ? "The magnetic switches need calibrating."
+ : "Only calibrate when your buttons have problems. Keep the mouse on its cable and still while it runs."}
+
+
+
void control.startMagneticCalibration()}
+ >
+ Start Calibration
+
+
+ {view.phase !== "idle" ? (
+
+
{view.message}
+ {running ? (
+
+ {(["Left", "Right"] as const).map((side, index) => (
+
+
{side}
+
+ {Array.from({ length: 10 }, (_, step) => (
+
+ ))}
+
+
+ ))}
+
+ ) : null}
+ {running
+ ?
Cancel
+ :
Dismiss }
+
+ ) : null}
+
+ );
+}
+
+export function MagneticButtons({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
+ const magnetic = snapshot.status?.magneticButtons;
+ const same = magnetic && JSON.stringify(magnetic.buttons[0]) === JSON.stringify(magnetic.buttons[1]);
+ const [mode, setMode] = useState<"both" | "independent" | null>(null);
+ if (!magnetic || magnetic.buttons.length !== 2) return null;
+ const locale = snapshot.preferences.locale;
+ const brand = snapshot.status?.brand ?? "";
+ const shown = mode ?? (same ? "both" : "independent");
+
+ return (
+
+ );
+}
diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx
index ad27ac5c..deff4471 100644
--- a/src/app/OverviewPage.tsx
+++ b/src/app/OverviewPage.tsx
@@ -21,6 +21,7 @@ import { t, tp, connectionText, type I18nKey } from "../i18n";
import { Diagnostics, LogitechDetails } from "./Diagnostics";
import { KeychronNapeLayers } from "./KeychronNapeLayers";
import { Profiles } from "./Profiles";
+import { MagneticButtons } from "./MagneticButtons";
import { Superstrike } from "./Superstrike";
import { DpiCard } from "./cards/DpiCard";
import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards";
@@ -604,6 +605,7 @@ export function Workspace({
const showTeevolutionProfiles = show(snapshot.traits.teevolution && has.onboardProfiles, ["profiles"]);
const showNapeLayers = show(has.keychronNapeLayers, ["profiles"]);
const showSuperstrike = show(has.superstrike, ["buttons"]);
+ const showMagnetic = show(has.magnetic, ["buttons"]);
const showLogitechDetails = device && show(has.logitechDetails, ["advanced"]);
const showMxMaster = on(tab, ["advanced"])
&& (status.hapticIntensity != null || status.wheelMode != null || status.friendlyName != null || status.hostCount != null);
@@ -611,7 +613,7 @@ export function Workspace({
const showOverview = on(tab, ["overview"]);
const anyPanel = performance.length > 0 || macro.length > 0 || advanced.length > 0 || lighting.length > 0
- || showProfiles || showTeevolutionProfiles || showNapeLayers || showSuperstrike
+ || showProfiles || showTeevolutionProfiles || showNapeLayers || showSuperstrike || showMagnetic
|| showLogitechDetails || showMxMaster || showDiagnostics || showOverview;
const slotsAvailable = snapshot.profile.slotsAvailable;
@@ -693,6 +695,7 @@ export function Workspace({
) : null}
{showSuperstrike ? : null}
+ {showMagnetic ? : null}
{advanced.length > 0 ? (
{
assert.equal(cardAvailability(snapshot({})).superstrike, false);
});
+test("the magnetic card follows the mouse's own report, whatever the brand", () => {
+ const range = { triggerPointRange: { min: 1, max: 10 }, rapidTriggerRange: null, rapidTriggerUnit: "ms" as const, rapidTriggerSwitch: false, canChooseSwitchType: false, calibration: null, liveDepth: false };
+ assert.equal(cardAvailability(snapshot({
+ status: { brand: "G-Wolves", magneticButtons: { ...range, buttons: [{}, {}] as never } },
+ })).magnetic, true);
+ assert.equal(cardAvailability(snapshot({
+ status: { brand: "G-Wolves", magneticButtons: { ...range, buttons: [{}] as never } },
+ })).magnetic, false);
+ assert.equal(cardAvailability(snapshot({ status: { brand: "G-Wolves" } })).magnetic, false);
+});
+
test("debounce is offered only by the families that store it", () => {
assert.equal(cardAvailability(snapshot({
status: { brand: "Pulsar", ui: { family: "pulsar" }, debounceMs: 4 },
diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts
index 9dd8ea78..204561b9 100644
--- a/src/app/cards/availability.ts
+++ b/src/app/cards/availability.ts
@@ -9,6 +9,7 @@ export interface CardAvailability {
sensor: boolean;
lightforce: boolean;
superstrike: boolean;
+ magnetic: boolean;
lighting: boolean;
lightingAdvanced: boolean;
signal: boolean;
@@ -54,6 +55,7 @@ const NOTHING: CardAvailability = {
sensor: false,
lightforce: false,
superstrike: false,
+ magnetic: false,
lighting: false,
lightingAdvanced: false,
signal: false,
@@ -141,6 +143,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
sensor: sensor && ready,
lightforce: Boolean(status.lightforceSwitchMode),
superstrike: traits.logitech && status.analogButtonTuning?.buttons.length === 2,
+ magnetic: status.magneticButtons?.buttons.length === 2,
lighting: Boolean(status.lighting || status.lightingZones?.length),
lightingAdvanced: host && !isNoirKsnake && Boolean(status.lighting || status.lightingZones?.length),
onboardProfiles: (status.profileCount ?? 0) > 1 && status.activeProfile != null,
diff --git a/src/app/workspace-tabs.test.ts b/src/app/workspace-tabs.test.ts
index f569ad4e..60e44f09 100644
--- a/src/app/workspace-tabs.test.ts
+++ b/src/app/workspace-tabs.test.ts
@@ -23,6 +23,12 @@ test("a connected mouse exposes only tabs backed by available controls", () => {
]);
});
+test("magnetic buttons put their settings under the buttons tab", () => {
+ assert.deepEqual(availableWorkspaceTabs(true, availability({ magnetic: true })), [
+ "overview", "performance", "buttons", "advanced",
+ ]);
+});
+
test("every profile surface retains the profiles tab", () => {
for (const surface of ["profiles", "keychronNapeLayers", "atkProfile", "onboardProfiles", "pulsarPro"] as const) {
assert.equal(availableWorkspaceTabs(true, availability({ [surface]: true })).includes("profiles"), true, surface);
diff --git a/src/app/workspace-tabs.ts b/src/app/workspace-tabs.ts
index 7c2f9c38..d9d31fa7 100644
--- a/src/app/workspace-tabs.ts
+++ b/src/app/workspace-tabs.ts
@@ -8,7 +8,7 @@ export function availableWorkspaceTabs(
): readonly WorkspaceTab[] {
if (!connected) return WORKSPACE_TAB_ORDER;
const hasButtons = has.eggButtons || has.razerButtons || has.mxMasterButtons || has.atkButtons
- || has.buttonMapping || has.debounce || has.lightforce || has.eggSpdt || has.superstrike
+ || has.buttonMapping || has.debounce || has.lightforce || has.eggSpdt || has.superstrike || has.magnetic
|| has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard;
const hasProfiles = has.profiles || has.keychronNapeLayers || has.atkProfile
|| has.onboardProfiles || has.pulsarPro;
diff --git a/src/control-devices.css b/src/control-devices.css
index fe7c18e8..fbdf0947 100644
--- a/src/control-devices.css
+++ b/src/control-devices.css
@@ -33,42 +33,42 @@
#logitech-device-details { flex: 0 0 auto; margin-top: .65rem; }
#logitech-device-details .setting-card { min-height: 0; }
#logitech-detail-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .55rem; }
-#logitech-analog-button-settings { flex: 0 0 auto; margin-top: .65rem; }
-#logitech-analog-button-settings .superstrike-tuning-card { min-height: 0; padding: 1rem; border-color: color-mix(in srgb, var(--ui-accent) 20%, var(--border)); background: linear-gradient(135deg, color-mix(in srgb, var(--ui-accent) 5%, var(--surface-menu)), var(--surface-panel-strong) 62%); }
-#logitech-analog-button-settings .superstrike-tuning-heading { margin-bottom: .2rem; }
-#logitech-analog-button-settings .superstrike-tuning-description { max-width: 540px; margin: .1rem 0 0; color: var(--text-soft); line-height: 1.45; }
-#logitech-analog-button-settings .superstrike-tabs { display: inline-flex; gap: .2rem; margin-top: .7rem; padding: .2rem; border: 1px solid var(--border); border-radius: 7px; background: var(--well); }
-#logitech-analog-button-settings .superstrike-tabs button { padding: .32rem .52rem; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: .58rem; font-weight: 700; }
-#logitech-analog-button-settings .superstrike-tabs button[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }
-#logitech-analog-button-settings .superstrike-tuning-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; margin-top: 1rem; }
-#logitech-analog-button-settings fieldset.superstrike-both-panel { display: none; margin-top: 1rem; padding: 0 !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; }
-#logitech-analog-button-settings fieldset.superstrike-both-panel legend { display: none; }
-#logitech-analog-button-settings fieldset.superstrike-both-panel::before { display: block; margin: 0 0 .55rem; color: var(--text-strong); font-size: .68rem; font-weight: 700; content: "● Both primary buttons"; }
-#logitech-analog-button-settings .superstrike-tuning-panels[data-superstrike-mode="both"] .superstrike-independent-panel { display: none; }
-#logitech-analog-button-settings .superstrike-tuning-panels[data-superstrike-mode="both"] .superstrike-both-panel { display: block; }
-#logitech-analog-button-settings .superstrike-both-panel > p { margin: 0 0 .65rem; color: var(--muted); font-size: .61rem; line-height: 1.4; }
-#logitech-analog-button-settings .superstrike-button-card { min-width: 0; margin: 0; padding: .78rem; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-superstrike); }
-#logitech-analog-button-settings .superstrike-button-card legend { display: flex; align-items: center; gap: .38rem; padding: 0 .1rem; color: var(--text-strong); font-size: .68rem; font-weight: 700; }
-#logitech-analog-button-settings .superstrike-button-dot { width: .42rem; height: .42rem; border-radius: 50%; background: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-accent-soft); }
-#logitech-analog-button-settings .superstrike-control-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(10rem, 1.15fr); align-items: center; gap: .9rem; padding: .58rem 0; border-bottom: 1px solid var(--line); }
-#logitech-analog-button-settings .superstrike-control-row:first-of-type { margin-top: .22rem; }
-#logitech-analog-button-settings .superstrike-control-row label { min-width: 0; color: var(--text-strong); font-size: .65rem; font-weight: 600; }
-#logitech-analog-button-settings .superstrike-control-row label small { display: block; margin-top: .12rem; color: var(--faint); font-size: .52rem; font-weight: 500; }
-#logitech-analog-button-settings .superstrike-control-row label small span::before { content: " · "; }
-#logitech-analog-button-settings .superstrike-steps input { display: none; }
-#logitech-analog-button-settings .superstrike-steps > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(1.45rem, 1fr)); gap: .22rem; }
-#logitech-analog-button-settings .superstrike-steps button { min-width: 0; padding: .34rem .12rem; border: 1px solid var(--border); border-radius: 5px; background: var(--input); color: var(--text-soft); font: 650 .59rem var(--font-mono); }
-#logitech-analog-button-settings .superstrike-steps button:hover { border-color: var(--ghost); color: var(--text-hover); }
-#logitech-analog-button-settings .superstrike-steps button:disabled { opacity: .4; cursor: not-allowed; }
-#logitech-analog-button-settings .superstrike-rapid-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; min-width: 0; }
-#logitech-analog-button-settings .superstrike-rapid-controls .superstrike-switch { flex: 0 0 auto; }
-#logitech-analog-button-settings .superstrike-rapid-controls .superstrike-switch > div { grid-template-columns: repeat(2, 2.7rem); }
-#logitech-analog-button-settings .superstrike-rapid-controls > .superstrike-steps:not(.superstrike-switch) { flex: 1 1 8rem; min-width: 0; }
-#logitech-analog-button-settings .superstrike-steps button[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); }
-#logitech-analog-button-settings .superstrike-apply-button { width: 100%; margin-top: .7rem; padding: .48rem .62rem; border: 1px solid color-mix(in srgb, var(--ui-accent) 62%, var(--border)); border-radius: 6px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: .62rem; font-weight: 750; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
-#logitech-analog-button-settings .superstrike-apply-button:hover { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); transform: translateY(-1px); }
-#logitech-analog-button-settings .superstrike-apply-button:disabled,
-#logitech-analog-button-settings .superstrike-control-row input:disabled { cursor: wait; opacity: .5; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) { flex: 0 0 auto; margin-top: .65rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tuning-card { min-height: 0; padding: 1rem; border-color: color-mix(in srgb, var(--ui-accent) 20%, var(--border)); background: linear-gradient(135deg, color-mix(in srgb, var(--ui-accent) 5%, var(--surface-menu)), var(--surface-panel-strong) 62%); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tuning-heading { margin-bottom: .2rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tuning-description { max-width: 540px; margin: .1rem 0 0; color: var(--text-soft); line-height: 1.45; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tabs { display: inline-flex; gap: .2rem; margin-top: .7rem; padding: .2rem; border: 1px solid var(--border); border-radius: 7px; background: var(--well); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tabs button { padding: .32rem .52rem; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: .58rem; font-weight: 700; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tabs button[aria-selected="true"] { background: var(--ui-accent-soft); color: var(--ui-accent); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tuning-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; margin-top: 1rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) fieldset.superstrike-both-panel { display: none; margin-top: 1rem; padding: 0 !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) fieldset.superstrike-both-panel legend { display: none; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) fieldset.superstrike-both-panel::before { display: block; margin: 0 0 .55rem; color: var(--text-strong); font-size: .68rem; font-weight: 700; content: "● Both primary buttons"; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tuning-panels[data-superstrike-mode="both"] .superstrike-independent-panel { display: none; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-tuning-panels[data-superstrike-mode="both"] .superstrike-both-panel { display: block; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-both-panel > p { margin: 0 0 .65rem; color: var(--muted); font-size: .61rem; line-height: 1.4; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-button-card { min-width: 0; margin: 0; padding: .78rem; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-superstrike); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-button-card legend { display: flex; align-items: center; gap: .38rem; padding: 0 .1rem; color: var(--text-strong); font-size: .68rem; font-weight: 700; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-button-dot { width: .42rem; height: .42rem; border-radius: 50%; background: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-accent-soft); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-control-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(10rem, 1.15fr); align-items: center; gap: .9rem; padding: .58rem 0; border-bottom: 1px solid var(--line); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-control-row:first-of-type { margin-top: .22rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-control-row label { min-width: 0; color: var(--text-strong); font-size: .65rem; font-weight: 600; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-control-row label small { display: block; margin-top: .12rem; color: var(--faint); font-size: .52rem; font-weight: 500; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-control-row label small span::before { content: " · "; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-steps input { display: none; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-steps > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(1.45rem, 1fr)); gap: .22rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-steps button { min-width: 0; padding: .34rem .12rem; border: 1px solid var(--border); border-radius: 5px; background: var(--input); color: var(--text-soft); font: 650 .59rem var(--font-mono); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-steps button:hover { border-color: var(--ghost); color: var(--text-hover); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-steps button:disabled { opacity: .4; cursor: not-allowed; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-rapid-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; min-width: 0; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-rapid-controls .superstrike-switch { flex: 0 0 auto; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-rapid-controls .superstrike-switch > div { grid-template-columns: repeat(2, 2.7rem); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-rapid-controls > .superstrike-steps:not(.superstrike-switch) { flex: 1 1 8rem; min-width: 0; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-steps button[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-apply-button { width: 100%; margin-top: .7rem; padding: .48rem .62rem; border: 1px solid color-mix(in srgb, var(--ui-accent) 62%, var(--border)); border-radius: 6px; background: var(--ui-accent-soft); color: var(--ui-accent); font-size: .62rem; font-weight: 750; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-apply-button:hover { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); transform: translateY(-1px); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-apply-button:disabled,
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-control-row input:disabled { cursor: wait; opacity: .5; }
#device-debug-details { flex: 0 0 auto; margin-top: .65rem; }
.device-debug-card { min-height: 0 !important; }
@@ -94,10 +94,21 @@
#egg-button-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
-#logitech-analog-button-settings .superstrike-press-meters { display: grid; gap: .35rem; margin: .2rem 0 .8rem; }
-#logitech-analog-button-settings .superstrike-press-meter { display: grid; grid-template-columns: 2.6rem 1fr; align-items: center; gap: .6rem; font: 650 .62rem var(--font-mono); color: var(--text-soft); }
-#logitech-analog-button-settings .superstrike-press-meter > div { display: grid; grid-template-columns: repeat(10, 1fr); gap: .2rem; }
-#logitech-analog-button-settings .superstrike-press-meter i { height: .55rem; border-radius: 2px; background: var(--input); border: 1px solid var(--border); }
-#logitech-analog-button-settings .superstrike-press-meter i[data-on="true"] { background: var(--accent, #2f8cff); border-color: transparent; }
-#logitech-analog-button-settings .superstrike-press-meter i[data-actuation="true"] { border-color: var(--warning); border-width: 2px; }
-#logitech-analog-button-settings .superstrike-press-meter i[data-actuation="true"][data-on="true"] { background: var(--warning); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meters { display: grid; gap: .35rem; margin: .2rem 0 .8rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meter { display: grid; grid-template-columns: 2.6rem 1fr; align-items: center; gap: .6rem; font: 650 .62rem var(--font-mono); color: var(--text-soft); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meter > div { display: grid; grid-template-columns: repeat(10, 1fr); gap: .2rem; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meter i { height: .55rem; border-radius: 2px; background: var(--input); border: 1px solid var(--border); }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meter i[data-on="true"] { background: var(--accent, #2f8cff); border-color: transparent; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meter i[data-actuation="true"] { border-color: var(--warning); border-width: 2px; }
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .superstrike-press-meter i[data-actuation="true"][data-on="true"] { background: var(--warning); }
+
+:is(#logitech-analog-button-settings, #magnetic-button-settings) .magnetic-note { margin: .6rem 0 0; color: var(--muted); font-size: .58rem; line-height: 1.4; }
+#magnetic-button-settings .magnetic-calibration { margin-top: 1rem; padding-top: .8rem; border-top: 1px solid var(--line-strong); }
+#magnetic-button-settings .magnetic-calibration-head { display: flex; align-items: center; justify-content: space-between; gap: .9rem; }
+#magnetic-button-settings .magnetic-calibration-head strong { color: var(--text-strong); font-size: .68rem; }
+#magnetic-button-settings .magnetic-calibration-head p { margin: .15rem 0 0; color: var(--muted); font-size: .58rem; line-height: 1.4; }
+#magnetic-button-settings .magnetic-calibration-head .superstrike-apply-button { width: auto; margin: 0; white-space: nowrap; }
+#magnetic-button-settings .magnetic-calibration-progress { margin-top: .7rem; padding: .7rem; border-radius: 8px; background: var(--well); }
+#magnetic-button-settings .magnetic-calibration-progress p { margin: 0 0 .5rem; color: var(--text-soft); font-size: .62rem; white-space: pre-line; }
+#magnetic-button-settings .magnetic-calibration-progress[data-phase="done"] p { color: var(--ui-accent); }
+#magnetic-button-settings .magnetic-calibration-progress[data-phase="failed"] p { color: var(--warning); }
diff --git a/src/device/controller.ts b/src/device/controller.ts
index 0c961f78..1ae066c1 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -104,7 +104,8 @@ import {
decodeProfileKey, encodeProfileKey, profileKeyMatchesDevice, type ProfileKeyPayload,
} from "./profile-key";
import { hasCapturedFactoryProfiles } from "./logitech-factory";
-import type { MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import type { MagneticButtonStatus, MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
+import { isMagneticButtonClient, type MagneticButtonClient } from "@openmouse/protocol/drivers/magnetic";
import type { KsnakeMacroProfile } from "@openmouse/protocol/ksnake";
import {
cloneM2NexProfile,
@@ -197,6 +198,7 @@ import { traitsFor } from "./traits";
import type {
AnalogTuning,
AnalogTuningState,
+ MagneticCalibrationView,
ControlSnapshot,
ProfileView,
DeviceCapabilities,
@@ -390,6 +392,10 @@ let pendingBusy = false;
let pendingStatusText: string | null = null;
let profilesExpanded = false;
let eggPollingDivider: number | null = null;
+const MAGNETIC_IDLE: MagneticCalibrationView = { phase: "idle", message: "", left: 0, right: 0, step: 0, steps: 0 };
+let magneticCalibration: MagneticCalibrationView = MAGNETIC_IDLE;
+let magneticCalibrationAbort: AbortController | null = null;
+let stagedMagneticTypes: ["magnetic" | "optical", "magnetic" | "optical"] | null = null;
let analogTuning: AnalogTuningState = {
mode: "both",
left: { actuation: 1, rapidTrigger: 1, haptics: 0 },
@@ -537,6 +543,7 @@ function buildSnapshot(): ControlSnapshot {
stagedNapeAssignments: [...stagedNapeAssignments.values()],
editedNapeLayer,
analogTuning,
+ magneticCalibration,
eggPollingDivider,
pending: {
count: changes.length,
@@ -3204,6 +3211,127 @@ export function applyLogitechAnalogButton(button: 0 | 1): void {
stageAnalogButton(button, button === 0 ? analogTuning.left : analogTuning.right);
}
+function magneticClient(): MagneticButtonClient | null {
+ const client = activeSettingsClient();
+ return isMagneticButtonClient(client) ? client : null;
+}
+
+const MAGNETIC_SIDE = ["Left", "Right"] as const;
+
+function stageMagneticButton(
+ button: 0 | 1,
+ setting: string,
+ label: string,
+ preview: (state: MagneticButtonStatus) => void,
+ apply: (client: MagneticButtonClient) => Promise,
+): void {
+ if (!magneticClient()) return;
+ const name = `${MAGNETIC_SIDE[button]} click ${label}`;
+ stageChange({
+ key: `magnetic-${setting}-${button}`,
+ label: name,
+ command: `Set the ${name.toLowerCase()}`,
+ progress: `Setting the ${name.toLowerCase()}…`,
+ preview: (status) => {
+ const state = status.magneticButtons?.buttons[button];
+ if (state) preview(state);
+ },
+ apply: async () => {
+ const client = magneticClient();
+ if (!client) throw new Error(st("ctl.gone"));
+ await apply(client);
+ },
+ });
+}
+
+export function applyMagneticTriggerPoint(button: 0 | 1, point: number): void {
+ stageMagneticButton(button, "trigger", `trigger point ${point}`, (state) => { state.triggerPoint = point; },
+ (client) => client.setMagneticTriggerPoint(button, point));
+}
+
+export function applyMagneticReleasePoint(button: 0 | 1, point: number | null): void {
+ const client = magneticClient();
+ if (!client?.setMagneticReleasePoint) return;
+ stageMagneticButton(button, "release", point === null ? "release follows trigger" : `release point ${point}`,
+ (state) => { state.releasePoint = point; },
+ (active) => active.setMagneticReleasePoint!(button, point));
+}
+
+export function applyMagneticRapidTrigger(button: 0 | 1, enabled: boolean, level: number, unit: string): void {
+ stageMagneticButton(button, "rapid", enabled ? `rapid trigger ${level}${unit}` : "rapid trigger off",
+ (state) => { state.rapidTriggerEnabled = enabled; state.rapidTrigger = level; },
+ (client) => client.setMagneticRapidTrigger(button, enabled, level));
+}
+
+export function applyMagneticSwitchType(button: 0 | 1, type: "magnetic" | "optical"): void {
+ const client = magneticClient();
+ const buttons = latestDeviceStatus?.magneticButtons?.buttons;
+ if (!client?.setMagneticSwitchTypes || !buttons) return;
+ const types = stagedMagneticTypes ?? [buttons[0]?.switchType ?? "optical", buttons[1]?.switchType ?? "optical"] as ["magnetic" | "optical", "magnetic" | "optical"];
+ const next: ["magnetic" | "optical", "magnetic" | "optical"] = [types[0], types[1]];
+ next[button] = type;
+ stagedMagneticTypes = next;
+ stageChange({
+ key: "magnetic-switch",
+ label: `Switch: left ${next[0]}, right ${next[1]}`,
+ command: "Choose the magnetic or optical switch",
+ progress: "Choosing the switch type…",
+ preview: (status) => {
+ const state = status.magneticButtons?.buttons;
+ if (state?.[0]) state[0].switchType = next[0];
+ if (state?.[1]) state[1].switchType = next[1];
+ },
+ apply: async () => {
+ const active = magneticClient();
+ if (!active?.setMagneticSwitchTypes) throw new Error(st("ctl.gone"));
+ await active.setMagneticSwitchTypes(stagedMagneticTypes ?? next);
+ },
+ });
+}
+
+export function subscribeButtonDepth(listener: (left: number, right: number) => void): () => void {
+ return magneticClient()?.onButtonDepth?.(listener) ?? (() => {});
+}
+
+export async function startMagneticCalibration(): Promise {
+ const client = magneticClient();
+ if (!client?.calibrateMagneticButtons || settingInProgress || magneticCalibration.phase === "running") return;
+ if (hasPendingChanges()) {
+ pushToast("error", "Flash or discard the pending changes first", "Calibration cannot run while changes are waiting.");
+ emit();
+ return;
+ }
+ settingInProgress = true;
+ magneticCalibrationAbort = new AbortController();
+ magneticCalibration = { ...MAGNETIC_IDLE, phase: "running", message: "Starting…" };
+ emit();
+ try {
+ await client.calibrateMagneticButtons((progress) => {
+ magneticCalibration = { phase: "running", ...progress };
+ emit();
+ }, magneticCalibrationAbort.signal);
+ magneticCalibration = { ...magneticCalibration, phase: "done", message: "Calibration Successful" };
+ } catch (error) {
+ magneticCalibration = { ...magneticCalibration, phase: "failed", message: error instanceof Error ? error.message : "Calibration Failed" };
+ } finally {
+ magneticCalibrationAbort = null;
+ settingInProgress = false;
+ emit();
+ }
+ lastRenderedStatusKey = null;
+ void refreshStatus();
+}
+
+export function cancelMagneticCalibration(): void {
+ magneticCalibrationAbort?.abort();
+}
+
+export function dismissMagneticCalibration(): void {
+ if (magneticCalibration.phase === "running") return;
+ magneticCalibration = MAGNETIC_IDLE;
+ emit();
+}
+
export function applyLogitechAnalogButtons(): void {
if (blockedByGameProfileDraft()) return;
if (!logitechClient()) return;
@@ -5094,6 +5222,48 @@ async function loadPreviewEntries(): Promise {
emit();
}
+/** A stand-in that keeps magnetic settings in memory, so the preview behaves like a connected mouse. */
+function magneticPreviewClient(initial: MouseStatus): SupportedClient {
+ const status = structuredClone(initial);
+ const buttons = (): MagneticButtonStatus[] => status.magneticButtons!.buttons;
+ let depthListener: ((left: number, right: number) => void) | null = null;
+ const client = {
+ device: {} as HIDDevice,
+ pollIntervalMs: 0,
+ open: async () => undefined,
+ close: async () => undefined,
+ readStatus: async () => structuredClone(status),
+ setMagneticTriggerPoint: async (button: 0 | 1, point: number) => { buttons()[button]!.triggerPoint = point; return point; },
+ setMagneticReleasePoint: async (button: 0 | 1, point: number | null) => { buttons()[button]!.releasePoint = point; return point; },
+ setMagneticRapidTrigger: async (button: 0 | 1, enabled: boolean, level: number) => {
+ Object.assign(buttons()[button]!, { rapidTriggerEnabled: enabled, rapidTrigger: level });
+ return { enabled, level };
+ },
+ setMagneticSwitchTypes: async (types: readonly ["magnetic" | "optical", "magnetic" | "optical"]) => {
+ buttons().forEach((entry, index) => { entry.switchType = types[index]!; });
+ },
+ calibrateMagneticButtons: async (onProgress: (progress: { left: number; right: number; message: string; step: number; steps: number }) => void) => {
+ const frames = [[0, 0, "Press and hold both buttons fully"], [60, 40, "Press and hold both buttons fully"], [100, 100, "Release both buttons"], [0, 0, "Calibration Successful"]] as const;
+ for (const [index, [left, right, message]] of frames.entries()) {
+ onProgress({ left, right, message, step: index + 1, steps: frames.length });
+ await new Promise((resolve) => window.setTimeout(resolve, 700));
+ }
+ status.magneticButtons!.calibration = "calibrated";
+ },
+ onButtonDepth: (listener: (left: number, right: number) => void) => {
+ depthListener = listener;
+ let tick = 0;
+ const timer = window.setInterval(() => {
+ tick += 1;
+ const wave = Math.abs(Math.sin(tick / 8)) * 100;
+ depthListener?.(Math.round(wave), Math.round(Math.abs(Math.cos(tick / 11)) * 100));
+ }, 120);
+ return () => { window.clearInterval(timer); depthListener = null; };
+ },
+ };
+ return client as unknown as SupportedClient;
+}
+
function previewClient(): LogitechHidppClient {
const refuse = async (): Promise => {
throw new Error(st("ctl.previewNoMouse"));
@@ -5268,6 +5438,7 @@ async function showFixturePreview(name: PreviewMode): Promise {
if (name === "g703") showG703PreviewProfiles();
// Populate brand capabilities so preview cards that gate on capabilities still render.
capabilities = readCapabilities();
+ if (fixture.status.magneticButtons) active = magneticPreviewClient(fixture.status);
applyStatus(fixture.status);
if (name === "nape-pro") {
const layer = fixture.status.napeLayer ?? 1;
@@ -5301,6 +5472,7 @@ export function start(): void {
if (!isPendingChange("haptic-enabled")) stagedHapticEnabled = null;
if (!isPendingChange("haptic-battery-saving")) stagedHapticBatterySaving = null;
if (!isPendingChange("wheel-mode")) stagedWheelMode = null;
+ if (!isPendingChange("magnetic-switch")) stagedMagneticTypes = null;
if (!isPendingChange("smart-shift")) stagedSmartShift = undefined;
if (!isPendingChange("hi-res-scroll")) stagedHiRes = null;
if (!isPendingChange("invert-scroll")) stagedInvertScroll = null;
diff --git a/src/device/types.ts b/src/device/types.ts
index d9555ab9..8d7c029a 100644
--- a/src/device/types.ts
+++ b/src/device/types.ts
@@ -89,6 +89,15 @@ export interface PendingView {
keys: readonly string[];
}
+export interface MagneticCalibrationView {
+ phase: "idle" | "running" | "done" | "failed";
+ message: string;
+ left: number;
+ right: number;
+ step: number;
+ steps: number;
+}
+
export interface AnalogTuning {
actuation: number;
rapidTrigger: number;
@@ -220,6 +229,7 @@ export interface ControlSnapshot {
/** Nape Pro layer currently open in the Profiles tab, or null when unread. */
editedNapeLayer: number | null;
analogTuning: AnalogTuningState;
+ magneticCalibration: MagneticCalibrationView;
eggPollingDivider: number | null;
pending: PendingView;
diff --git a/src/preview-fixtures.ts b/src/preview-fixtures.ts
index 33f5a39b..0a653a9b 100644
--- a/src/preview-fixtures.ts
+++ b/src/preview-fixtures.ts
@@ -783,6 +783,64 @@ const LOGITECH_MX_MASTER_3S: MouseStatus = {
firmware: ["RQK 70.00"],
};
+const GWOLVES_PRO: MouseStatus = {
+ brand: "G-Wolves",
+ name: "G-Wolves HTS Plus Pro",
+ batteryPercent: 76,
+ batteryState: "Discharging",
+ dpi: 1600,
+ pollingRateHz: 1000,
+ supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000],
+ activeProfile: null,
+ liftOffDistance: "Medium",
+ connectionType: "Wired",
+ connectionDetail: "USB · 8K protocol",
+ magneticButtons: {
+ buttons: [
+ { switchType: "magnetic", triggerPoint: 5, rapidTrigger: 3, rapidTriggerEnabled: true },
+ { switchType: "magnetic", triggerPoint: 5, rapidTrigger: 3, rapidTriggerEnabled: true },
+ ],
+ triggerPointRange: { min: 1, max: 20 },
+ rapidTriggerRange: { min: 1, max: 10 },
+ rapidTriggerUnit: "level",
+ rapidTriggerSwitch: true,
+ canChooseSwitchType: false,
+ calibration: "unknown",
+ liveDepth: true,
+ },
+ firmware: ["Mouse v1.23"],
+};
+
+const RAWM_V4_GT: MouseStatus = {
+ brand: "RAWM",
+ name: "RAWM Leviathan V4 GT",
+ batteryPercent: 82,
+ batteryState: "Discharging",
+ dpi: 1600,
+ dpiY: 1600,
+ pollingRateHz: 8000,
+ supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000],
+ activeProfile: 1,
+ liftOffDistance: "Medium",
+ connectionType: "Wireless",
+ connectionDetail: "2.4 GHz receiver",
+ magneticButtons: {
+ buttons: [
+ { switchType: "magnetic", triggerPoint: 4, releasePoint: null, rapidTrigger: null, rapidTriggerEnabled: false },
+ { switchType: "magnetic", triggerPoint: 4, releasePoint: null, rapidTrigger: null, rapidTriggerEnabled: false },
+ ],
+ triggerPointRange: { min: 1, max: 10 },
+ releasePointRange: { min: 1, max: 10 },
+ rapidTriggerRange: { min: 1, max: 15 },
+ rapidTriggerUnit: "ms",
+ rapidTriggerSwitch: false,
+ canChooseSwitchType: true,
+ calibration: "calibrated",
+ liveDepth: false,
+ },
+ firmware: ["Mouse 0.0.0.28"],
+};
+
/**
* Keyed by the `?preview=` value. `superstrike` and `slots` are handled
* separately because they drive panels beyond a plain status.
@@ -813,5 +871,7 @@ export const PREVIEW_FIXTURES: Record = {
"nape-pro": { label: "Keychron Nape Pro", status: KEYCHRON },
"mx-master-3s": { label: "Logitech MX Master 3S", status: LOGITECH_MX_MASTER_3S },
g703: { label: "Logitech G703", status: LOGITECH_G703 },
+ "gwolves-pro": { label: "G-Wolves HTS Plus Pro", status: GWOLVES_PRO },
+ "rawm-v4-gt": { label: "RAWM Leviathan V4 GT", status: RAWM_V4_GT },
"logitech-legacy": { label: "Logitech G402 (legacy DPI)", status: LOGITECH_LEGACY },
};
diff --git a/src/preview-modes.ts b/src/preview-modes.ts
index baf8f998..a33c3c25 100644
--- a/src/preview-modes.ts
+++ b/src/preview-modes.ts
@@ -32,6 +32,8 @@ export const PREVIEW_KEYS = [
"nape-pro",
"mx-master-3s",
"g703",
+ "gwolves-pro",
+ "rawm-v4-gt",
"terra-pro",
"logitech-legacy",
] as const;
@@ -78,6 +80,8 @@ export function parsePreviewMode(value: string | null): PreviewMode | null {
case "nape-pro": return "nape-pro";
case "mx-master-3s": return "mx-master-3s";
case "g703": return "g703";
+ case "gwolves-pro": return "gwolves-pro";
+ case "rawm-v4-gt": return "rawm-v4-gt";
case "logitech-legacy": return "logitech-legacy";
default: return null;
}