From e72ed00f8f7a1cc1403942246771d18bcbdb78c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 11:20:57 +0000 Subject: [PATCH 1/2] feat: add a Magnetic Buttons section for actuation, rapid trigger and magnetic/optical Shown under Buttons for any mouse that reports magneticButtons (G-Wolves HTS Plus Pro, RAWM Leviathan V4 GT), in the same layout as the HITS tuning card. Needs the @openmouse/protocol release that carries the matching drivers. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AWRZwXZXwJRT6o6xN7uv9v --- src/app/MagneticButtons.tsx | 326 +++++++++++++++++++++++++++++ src/app/OverviewPage.tsx | 5 +- src/app/cards/availability.test.ts | 11 + src/app/cards/availability.ts | 3 + src/app/workspace-tabs.test.ts | 6 + src/app/workspace-tabs.ts | 2 +- src/control-devices.css | 97 +++++---- src/device/controller.ts | 174 ++++++++++++++- src/device/types.ts | 10 + src/preview-fixtures.ts | 60 ++++++ src/preview-modes.ts | 4 + 11 files changed, 652 insertions(+), 46 deletions(-) create mode 100644 src/app/MagneticButtons.tsx 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) => ( + + ))} +
+
+ ); +} + +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) => ( + + ))} +
+
+ ); +} + +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 ( +
+ + {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) => ( + + ))} +
+
+
+ ) : 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."} +

+
+ +
+ {view.phase !== "idle" ? ( +
+

{view.message}

+ {running ? ( +
+ {(["Left", "Right"] as const).map((side, index) => ( +
+ {side} +
+ {Array.from({ length: 10 }, (_, step) => ( + + ))} +
+
+ ))} +
+ ) : null} + {running + ? + : } +
+ ) : 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 ( +
+
+

Magnetic Buttons

+ {magnetic.liveDepth ? : null} +
+ {(["both", "independent"] as const).map((value) => ( + + ))} +
+
+
+ {([0, 1] as const).map((side) => ( +
+ + + {side === 0 ? t(locale, "adv.leftButton") : t(locale, "adv.rightButton")} + + +
+ ))} +
+
+ {t(locale, "super.bothPrimary")} +

{t(locale, "super.bothBody")}

+ +
+
+ +
+
+ ); +} 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; } From be78920616bbd53ee547b93e72a4cbae5a02f991 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:59:41 -0600 Subject: [PATCH 2/2] chore(protocol): bump @openmouse/protocol to 0.27.0 The Magnetic Buttons section needs MagneticButtonStatus, MagneticButtonsStatus and @openmouse/protocol/drivers/magnetic, which first ship in 0.27.0. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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" },