diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index a6f2275e..a012268a 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -17,7 +17,11 @@ const BUDGET_BYTES: Record = { // Raised to 210 kB after the protocol 0.26.0 device batch left the measured // CSS aggregate at 194.2 kB — 843 bytes under the old 195 kB — so the next // card or theme change would have tripped the check for no reviewable reason. - ".css": 210_000, + // Raised to 225 kB for the Logitech Superstrike Buttons tab: the button map + // card (callouts, command list, scrollbar), the HITS presets/test/share dialogs + // and the Bunny Hop card add ~15 kB of scoped styles; the measured CSS is + // 210.6 kB, which was 631 bytes over the old 210 kB budget. + ".css": 225_000, // Raised from 510 kB for Bridge discovery, profile editing, automatic // reconnection, and recent device support, which have since grown further // with the supported-device page and MX Master remap controls. Preview @@ -183,7 +187,10 @@ const BUDGET_BYTES: Record = { // arrive through bridge-hid's SUPPORTED_HID_FILTERS as well as the // controllers. The measured aggregate is 2,084.2 kB, which left only 801 // bytes of headroom under the old 2,085 kB. - ".js": 2_150_000, + // Raised to 2.2 MB for the Superstrike Buttons tab (button map, HITS presets, + // speed test, setup codes, bounce check) merged with protocol 0.28.0: the + // measured aggregate is 2,150.4 kB, 350 bytes over the old 2.15 MB budget. + ".js": 2_200_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/app/BounceCheckDialog.tsx b/src/app/BounceCheckDialog.tsx new file mode 100644 index 00000000..df586528 --- /dev/null +++ b/src/app/BounceCheckDialog.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { BUNNY_HOP_LIMITS, clampBunnyHopMs } from "@openmouse/protocol/drivers/logitech/onboard-profiles"; +import * as control from "../device/controller"; +import { t } from "../i18n"; +import type { InterfaceLocale } from "../interface-preferences"; +import { BOUNCE_GAP_MS, bounceReport } from "../hits-test"; + +/** + * Click a button as fast as you can for a few seconds. A switch that bounces + * registers two presses a few milliseconds apart, which no finger can do; the + * longest such gap tells how long Bunny Hop needs to be to filter them. + */ +export function BounceCheckDialog({ + open, + locale, + bunnyHopMs, + canApply, + onClose, +}: { + open: boolean; + locale: InterfaceLocale; + /** The Bunny Hop time now on the profile, 0 when it is off. */ + bunnyHopMs: number; + canApply: boolean; + onClose: () => void; +}): ReactNode { + const dialog = useRef(null); + // Press times in ms, per mouse button (0 left, 2 right). + const [presses, setPresses] = useState>({}); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + if (!open) setPresses({}); + }, [open]); + + const reports = [ + { button: 0, label: "Left button" }, + { button: 2, label: "Right button" }, + ].map((entry) => ({ ...entry, report: bounceReport(presses[entry.button] ?? []) })); + const longest = Math.max(0, ...reports.map((entry) => entry.report.longestGap ?? 0)); + const anyBounce = reports.some((entry) => entry.report.bounces > 0); + // Room above the longest bounce seen, snapped to what the mouse accepts. + const suggested = clampBunnyHopMs(Math.ceil(longest * 2)); + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
+
+
+

Bunny hop

+

Bounce check

+
+ +
+
+ + Click a button as fast as you can in the field below for a few seconds. A switch that bounces registers a + second press within {BOUNCE_GAP_MS} ms, which a finger cannot do, so those are counted and Bunny Hop is + suggested to cover them. + + {bunnyHopMs > 0 ? ( + + Bunny Hop is on at {bunnyHopMs} ms, so the mouse is already hiding bounces. Turn it off to see them. + + ) : null} + +
    + {reports.map((entry) => ( +
  • + {entry.label}: {entry.report.clicks} clicks, {entry.report.bounces} bounced + {entry.report.longestGap !== null ? `, longest gap ${Math.round(entry.report.longestGap)} ms` : ""} +
  • + ))} +
+ {reports.some((entry) => entry.report.clicks >= 10) ? ( + + {anyBounce + ? `Bounce found. A Bunny Hop of ${suggested} ms (range ${BUNNY_HOP_LIMITS.minMs} to ${BUNNY_HOP_LIMITS.maxMs}) should filter it.` + : "No bounce found in these clicks."} + + ) : null} +
+ {anyBounce ? ( + + ) : null} + + +
+
+
+
+ ); +} diff --git a/src/app/ButtonMap.tsx b/src/app/ButtonMap.tsx new file mode 100644 index 00000000..cd8ddd3c --- /dev/null +++ b/src/app/ButtonMap.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import * as control from "../device/controller"; +import type { LogitechButtonAction } from "@openmouse/protocol/drivers/logitech/onboard-profiles"; +import type { ControlSnapshot } from "../device/types"; +import { buttonMapLayoutFor, type ButtonSpot } from "../logitech-button-map"; +import { COMMAND_SECTIONS, bindingFromKeyEvent, conflictHints, describeAssignment, isAssigned, shortcutText, type CommandEntry } from "../logitech-commands"; +import { connectionText } from "../i18n"; +import { SetupCodeDialog } from "./SetupCodeDialog"; +import { BatteryIcon } from "./ui"; + +type Layer = "primary" | "g-shift"; + + +/** What each unlocked button does out of the box; G-Shift starts with nothing on them. */ +const DEFAULT_ACTION: Record = { 2: "Middle click", 3: "Back", 4: "Forward" }; + +function hasButtonMap(snapshot: ControlSnapshot): boolean { + const status = snapshot.status; + return snapshot.traits.logitech + && status !== null + && buttonMapLayoutFor(status.name) !== null + && snapshot.profile.entry !== null + && snapshot.profileFormat?.writable === true; +} + +// While a flash runs, the profile is briefly unreadable. The card keeps showing the +// last good state for that mouse instead of vanishing and coming back. +let lastGood: ControlSnapshot | null = null; + +function stableSnapshot(snapshot: ControlSnapshot): ControlSnapshot | null { + if (hasButtonMap(snapshot)) { + lastGood = snapshot; + return snapshot; + } + return snapshot.settingInProgress && lastGood?.status?.name === snapshot.status?.name ? lastGood : null; +} + +/** + * On the Host profile the mouse runs from software and has no stored profile + * open, so there is nothing to assign yet. The card then stays, with a way back. + */ +function onHostProfile(snapshot: ControlSnapshot): boolean { + const status = snapshot.status; + return snapshot.traits.logitech + && status !== null + && buttonMapLayoutFor(status.name) !== null + && snapshot.editedProfile === "host" + && (snapshot.onboardProfiles?.length ?? 0) > 0; +} + +/** True when this mouse has a known button layout and a profile whose buttons can be written, or the Host profile is open on one that does. */ +export function buttonMapAvailable(snapshot: ControlSnapshot): boolean { + return stableSnapshot(snapshot) !== null || onHostProfile(snapshot); +} + +function HostProfileNotice({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const profiles = snapshot.onboardProfiles ?? []; + const target = profiles.find((profile) => profile.isCurrent) ?? profiles[0]; + return ( +
+
+

Button assignments

+

+ You are on the Host profile, which runs from software. Button assignments and Bunny Hop are stored in the + mouse's onboard profiles, so open one to edit them. +

+ {target ? ( + + ) : null} +
+
+ ); +} + +/** + * Button assignments as a picture: the mouse with a callout per button. Pick a + * button, then a command; the change goes through the same staged write as the + * Profiles tab, so instant flash and the Flash bar behave the same. + */ +export function ButtonMap({ snapshot: live }: { snapshot: ControlSnapshot }): ReactNode { + const snapshot = stableSnapshot(live) ?? live; + const [layer, setLayer] = useState("primary"); + const [selected, setSelected] = useState(null); + const [query, setQuery] = useState(""); + // What the user picked for a staged button, so the callout can say it before the flash. + const [pending, setPending] = useState>({}); + // While recording, the next key or shortcut pressed is assigned to the selected button. + const [recording, setRecording] = useState(false); + const [sharing, setSharing] = useState(false); + + const recordable = selected !== null && !(selected <= 1 && layer === "primary") && !live.settingInProgress; + useEffect(() => { + if (!recording || !recordable || selected === null) return; + const onKey = (event: KeyboardEvent): void => { + event.preventDefault(); + // A bare Escape cancels; Ctrl+Esc and the like are still recordable. + if (event.code === "Escape" && !event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { + setRecording(false); + return; + } + const binding = bindingFromKeyEvent(event); + if (!binding || binding.kind !== "keyboard") return; + void control.applyLogitechButtonAssignment(layer, selected, binding); + setPending((previous) => ({ ...previous, [`${layer}-${selected}`]: shortcutText(binding.key, binding.modifiers) })); + setRecording(false); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [recording, recordable, selected, layer]); + + const status = snapshot.status; + const entry = snapshot.profile.entry; + const estimate = status ? control.batteryEstimateParts(status, snapshot.preferences.locale) : null; + const layout = status ? buttonMapLayoutFor(status.name) : null; + if (!status || !entry || !layout) return onHostProfile(live) ? : null; + + const assignments = layer === "primary" ? entry.buttonAssignments : entry.gShiftAssignments; + const assignmentFor = (button: number) => assignments.find((assignment) => assignment.button === button); + const isLocked = (spot: ButtonSpot): boolean => spot.locked && layer === "primary"; + const textFor = (spot: ButtonSpot): string => { + const staged = snapshot.stagedProfileButtonAssignments.find((item) => item.layer === layer && item.button === spot.button); + if (staged) return pending[`${layer}-${spot.button}`] ?? "Pending"; + const assignment = assignmentFor(spot.button); + return assignment ? describeAssignment(assignment.action, assignment.raw) : "-"; + }; + + const selectedSpot = layout.spots.find((spot) => spot.button === selected) ?? null; + const canAssign = selectedSpot !== null && !isLocked(selectedSpot) && !live.settingInProgress; + const current = selected !== null ? assignmentFor(selected) : undefined; + + const assign = (command: CommandEntry): void => { + if (!canAssign || selected === null) return; + const binding = command.target.kind === "action" ? command.target.action : command.target; + void control.applyLogitechButtonAssignment(layer, selected, binding); + setPending((previous) => ({ ...previous, [`${layer}-${selected}`]: command.label })); + }; + + const defaultFor = (button: number): LogitechButtonAction => (layer === "primary" ? DEFAULT_ACTION[button] : "Disabled") as LogitechButtonAction; + const reset = async (buttons: number[]): Promise => { + for (const button of buttons) { + await control.applyLogitechButtonAssignment(layer, button, defaultFor(button)); + setPending((previous) => ({ ...previous, [`${layer}-${button}`]: defaultFor(button) })); + } + }; + const unlocked = layout.spots.filter((spot) => !isLocked(spot)).map((spot) => spot.button); + + const hints = layer === "primary" ? conflictHints(layout.spots.map((spot) => ({ label: spot.label, text: textFor(spot) }))) : []; + + const needle = query.trim().toLowerCase(); + const sections = COMMAND_SECTIONS + .map((section) => ({ + ...section, + entries: section.entries.filter((command) => !needle + || command.label.toLowerCase().includes(needle) + || (command.hint ?? "").toLowerCase().includes(needle)), + })) + .filter((section) => section.entries.length > 0); + + const help = selectedSpot === null + ? "Pick a button on the mouse, then a command." + : isLocked(selectedSpot) + ? "Primary and secondary click are locked so you cannot lose control of the mouse." + : `Pick a command for ${selectedSpot.label}.`; + + return ( +
+
+
+

Button assignments

+
+ {status.name} + {status.connectionType ? {connectionText(snapshot.preferences.locale, status.connectionType)} : null} + {status.batteryPercent !== null ? ( + + {status.batteryPercent}% + {estimate ? ` · ${estimate.time}` : ""} + + ) : null} +
+ +
+ setSharing(false)} /> +
+ +
+
+ {snapshot.deviceArtwork ? {status.name} : null} + {layout.spots.map((spot) => ( +
+
+ ))} +
+ {hints.length ?
    {hints.map((hint) =>
  • {hint}
  • )}
: null} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/DeleteHitsPresetDialog.tsx b/src/app/DeleteHitsPresetDialog.tsx new file mode 100644 index 00000000..e5f2dc72 --- /dev/null +++ b/src/app/DeleteHitsPresetDialog.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, type ReactNode } from "react"; +import { t } from "../i18n"; +import type { InterfaceLocale } from "../interface-preferences"; + +/** Asks before a saved HITS preset is deleted. */ +export function DeleteHitsPresetDialog({ + name, + locale, + onClose, + onConfirm, +}: { + /** The preset to delete; null keeps the dialog closed. */ + name: string | null; + locale: InterfaceLocale; + onClose: () => void; + onConfirm: () => void; +}): ReactNode { + const dialog = useRef(null); + const open = name !== null; + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + }, [open]); + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
+
+
+

HITS Tuning

+

Delete preset

+
+ +
+
+

+ Delete “{name}”? It is removed from this browser and cannot be brought back, unless you copied its code. +

+
+ + +
+
+
+
+ ); +} diff --git a/src/app/HitsTestDialog.tsx b/src/app/HitsTestDialog.tsx new file mode 100644 index 00000000..a8fbf441 --- /dev/null +++ b/src/app/HitsTestDialog.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import * as control from "../device/controller"; +import { t } from "../i18n"; +import type { InterfaceLocale } from "../interface-preferences"; +import { CONVENTIONAL_STEP, savedMs, stepTimes, type DepthSample } from "../hits-test"; + +const ROUNDS = 1; +// After the press, wait for the rest of its depth reports before comparing. +const SETTLE_MS = 300; + +type Phase = "idle" | "wait" | "go" | "settle" | "done"; +interface Round { reaction: number; saved: number | null; steps: (number | null)[] } + +const ms = (value: number): string => `${Math.round(value)} ms`; +const signed = (value: number): string => `${value > 0 ? "+" : ""}${Math.round(value)} ms`; + +/** + * Click as fast as you can when the field turns blue. Each press is compared with + * a conventional switch (which fires at step 5) using the press-depth stream the + * HITS card already keeps armed. + */ +export function HitsTestDialog({ + open, + locale, + actuation, + maxActuation, + canAdjust, + onAdjust, + onClose, +}: { + open: boolean; + locale: InterfaceLocale; + /** Actuation step of the left and right buttons. */ + actuation: [number, number]; + maxActuation: number; + /** False while a change would only be staged, not yet on the mouse. */ + canAdjust: boolean; + onAdjust: (side: 0 | 1, value: number) => void; + onClose: () => void; +}): ReactNode { + const dialog = useRef(null); + const [side, setSide] = useState<0 | 1>(0); + const [phase, setPhase] = useState("idle"); + const [rounds, setRounds] = useState([]); + const [note, setNote] = useState(""); + const roundsRef = useRef([]); + const samples = useRef([]); + const goAt = useRef(0); + const timer = useRef(undefined); + const sideRef = useRef(side); + sideRef.current = side; + const actuationRef = useRef(actuation); + actuationRef.current = actuation; + + const reset = (): void => { + window.clearTimeout(timer.current); + roundsRef.current = []; + setRounds([]); + setNote(""); + setPhase("idle"); + }; + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + if (!open) reset(); + }, [open]); + + useEffect(() => { + if (!open) return; + return control.subscribeAnalogPress((left, right) => { + samples.current.push({ t: performance.now(), depth: sideRef.current === 0 ? left : right }); + }); + }, [open]); + + const start = (): void => { + window.clearTimeout(timer.current); + setNote(""); + setPhase("wait"); + timer.current = window.setTimeout(() => { + goAt.current = performance.now(); + samples.current = []; + setPhase("go"); + }, 1000 + Math.random() * 2000); + }; + + const press = (event: MouseEvent): void => { + event.preventDefault(); + if (event.button !== (side === 0 ? 0 : 2)) return; + if (phase === "idle" || phase === "done") { + roundsRef.current = []; + setRounds([]); + start(); + } else if (phase === "wait") { + window.clearTimeout(timer.current); + setNote("Too early. Wait for blue."); + setPhase("idle"); + } else if (phase === "go") { + const reaction = event.timeStamp - goAt.current; + setPhase("settle"); + timer.current = window.setTimeout(() => { + const saved = savedMs(samples.current, actuationRef.current[sideRef.current]); + roundsRef.current = [...roundsRef.current, { reaction, saved, steps: stepTimes(samples.current, goAt.current) }]; + setRounds(roundsRef.current); + if (roundsRef.current.length >= ROUNDS) setPhase("done"); + else start(); + }, SETTLE_MS); + } + }; + + const label = phase === "idle" ? (note || "Click here to start") + : phase === "wait" ? "Wait for blue…" + : phase === "go" ? "Click now!" + : "…"; + const value = actuation[side]; + const last = rounds[rounds.length - 1]; + const at = (step: number): number | null => last?.steps[step - 1] ?? null; + const hitsTime = at(value); + const conventionalTime = at(CONVENTIONAL_STEP); + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
+
+
+

HITS Tuning

+

HITS speed test

+
+ +
+
+
+ + A normal mouse switch fires at one fixed point, about step 5 on the HITS scale. When the field turns blue, + click as fast as you can. Each press is timed against that step, so you see how much sooner your + actuation fires. Change the actuation and run it again to compare. + + +
+ Actuation point +
+ + {value} + +
+
+ {!canAdjust ? Turn on Instant flash to change the actuation here. : null} +
+ +
+
+ +
+
+
+ ); +} diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index deff4471..5ed7cd0d 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -23,6 +23,7 @@ import { KeychronNapeLayers } from "./KeychronNapeLayers"; import { Profiles } from "./Profiles"; import { MagneticButtons } from "./MagneticButtons"; import { Superstrike } from "./Superstrike"; +import { ButtonMap, buttonMapAvailable } from "./ButtonMap"; import { DpiCard } from "./cards/DpiCard"; import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards"; import { LightingCard } from "./cards/LightingCard"; @@ -137,6 +138,7 @@ function DeviceShowcase({ snapshot }: { if (!status) return null; const locale = snapshot.preferences.locale; const image = snapshot.deviceArtwork; + const batteryEstimate = control.batteryEstimateParts(status, locale); const [imageFailed, setImageFailed] = useState(false); const [artSize, setArtSize] = useState<{ w: number; h: number } | null>(null); @@ -208,6 +210,11 @@ function DeviceShowcase({ snapshot }: { {status.batteryPercent}% + {batteryEstimate ? ( + + {batteryEstimate.time} + + ) : null} ) : null} @@ -336,6 +343,7 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot const showButtonMarkers = snapshot.workspaceTab === "buttons" && isNoirKsnakeStatus(status) && status.buttonMappings != null; + const batteryEstimate = control.batteryEstimateParts(status, locale); return (
@@ -410,6 +418,9 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot {" · "} {status.batteryPercent}% + {batteryEstimate ? ( + {" · "}{batteryEstimate.time} + ) : null} ) : null}
@@ -605,6 +616,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 showButtonMap = show(buttonMapAvailable(snapshot), ["buttons"]); const showMagnetic = show(has.magnetic, ["buttons"]); const showLogitechDetails = device && show(has.logitechDetails, ["advanced"]); const showMxMaster = on(tab, ["advanced"]) @@ -641,24 +653,33 @@ export function Workspace({ {showTeevolutionProfiles ? : null} {showNapeLayers ? : null} - {performance.length > 0 ? ( + {performance.length > 0 || showSuperstrike || showButtonMap ? (
- {device ? ( + {device && !showButtonMap ? ( ) : null} -
- {performance} +
+ {/* The HITS card keeps its own card chrome, first, beside the mouse panel. */} + {showSuperstrike ? : null} + {showButtonMap ? : null} + {performance.length > 0 ? ( +
+ {performance} +
+ ) : null}
) : null} @@ -694,7 +715,6 @@ export function Workspace({ ) : null} - {showSuperstrike ? : null} {showMagnetic ? : null} {advanced.length > 0 ? ( diff --git a/src/app/SaveHitsPresetDialog.tsx b/src/app/SaveHitsPresetDialog.tsx new file mode 100644 index 00000000..aa7df980 --- /dev/null +++ b/src/app/SaveHitsPresetDialog.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { t } from "../i18n"; +import type { InterfaceLocale } from "../interface-preferences"; +import type { HitsButtonValues, HitsPreset } from "../hits-presets"; + +function describe(values: HitsButtonValues): string { + const rapid = values.rapidTriggerEnabled ? `rapid trigger ${values.rapidTrigger}` : "rapid trigger off"; + return `actuation ${values.actuation}, ${rapid}, haptics ${values.haptics}`; +} + +/** Name the current HITS tuning and save it as a preset. */ +export function SaveHitsPresetDialog({ + open, + locale, + values, + existingNames, + onClose, + onSave, +}: { + open: boolean; + locale: InterfaceLocale; + values: Pick; + existingNames: readonly string[]; + onClose: () => void; + onSave: (name: string) => void; +}): ReactNode { + const dialog = useRef(null); + const [name, setName] = useState(""); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + if (!open) setName(""); + }, [open]); + + const trimmed = name.trim().slice(0, 40); + const replaces = trimmed !== "" && existingNames.includes(trimmed); + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
{ + event.preventDefault(); + if (trimmed) onSave(trimmed); + }} + > +
+
+

HITS Tuning

+

Save preset

+
+ +
+
+ + + Left: {describe(values.left)}. Right: {describe(values.right)}. + + {replaces ? A preset with this name exists and will be replaced. : null} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/SetupCodeDialog.tsx b/src/app/SetupCodeDialog.tsx new file mode 100644 index 00000000..96a6e951 --- /dev/null +++ b/src/app/SetupCodeDialog.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import * as control from "../device/controller"; +import type { ControlSnapshot } from "../device/types"; +import { t } from "../i18n"; +import { bindingFromAssignment, decodeSetupCode, describeSetup, encodeSetupCode, type SetupCode } from "../setup-code"; + +// The two primary clicks are never written, so a shared setup cannot take them over. +const FIRST_WRITABLE_SLOT = 2; + +/** What this mouse has right now, as far as a setup code can carry it. */ +export function currentSetup(snapshot: ControlSnapshot): SetupCode { + const setup: SetupCode = {}; + const state = snapshot.analogTuning; + if (snapshot.status?.analogButtonTuning?.buttons.length === 2) { + const values = (tuning: typeof state.left) => ({ + actuation: tuning.actuation, + rapidTrigger: tuning.rapidTrigger, + haptics: tuning.haptics, + rapidTriggerEnabled: tuning.rapidTriggerEnabled !== false, + }); + setup.hits = state.mode === "both" + ? { left: values(state.both), right: values(state.both) } + : { left: values(state.left), right: values(state.right) }; + } + const entry = snapshot.profile.entry; + if (entry) { + if (snapshot.profile.bunnyHopSupported) setup.bunnyHopMs = entry.bunnyHoppingMs ?? 0; + setup.buttons = Array.from({ length: 5 }, (_, slot) => { + if (slot < FIRST_WRITABLE_SLOT) return null; + const assignment = entry.buttonAssignments.find((item) => item.button === slot); + return assignment ? bindingFromAssignment(assignment.action, assignment.raw) : null; + }); + } + if (snapshot.status?.dpiStages?.length) setup.dpiStages = [...snapshot.status.dpiStages]; + return setup; +} + +/** Stages each part of a decoded setup the same way the cards do, and says what it left out. */ +export function applySetup(setup: SetupCode, snapshot: ControlSnapshot): string[] { + const skipped: string[] = []; + if (setup.hits) { + if (snapshot.status?.analogButtonTuning?.buttons.length === 2) control.loadAnalogPreset({ ...setup.hits.left }, { ...setup.hits.right }); + else skipped.push("HITS tuning (this mouse has none)"); + } + if (setup.bunnyHopMs !== undefined) { + if (snapshot.profile.bunnyHopSupported && snapshot.profile.entry) control.applyBunnyHopMs(setup.bunnyHopMs); + else skipped.push("Bunny Hop (not available here)"); + } + if (setup.buttons) { + if (snapshot.profile.entry && snapshot.profileFormat?.writable === true) { + setup.buttons.forEach((binding, slot) => { + if (!binding || slot < FIRST_WRITABLE_SLOT) return; + void control.applyLogitechButtonAssignment("primary", slot, binding.kind === "action" ? binding.action : binding); + }); + } else skipped.push("button assignments (profile is read-only)"); + } + if (setup.dpiStages) { + const stages = snapshot.status?.dpiStages; + if (snapshot.status?.ui?.dpiStageEditor && stages?.length === setup.dpiStages.length) { + setup.dpiStages.forEach((dpi, stage) => control.applyDpiStageValue(stage, dpi)); + } else skipped.push("DPI stages (this mouse has a different number of stages)"); + } + return skipped; +} + +/** Copy the whole setup as one code, or paste one to apply it. */ +export function SetupCodeDialog({ + open, + snapshot, + onClose, +}: { + open: boolean; + snapshot: ControlSnapshot; + onClose: () => void; +}): ReactNode { + const dialog = useRef(null); + const [text, setText] = useState(""); + const [note, setNote] = useState(""); + const locale = snapshot.preferences.locale; + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + if (!open) { + setText(""); + setNote(""); + } + }, [open]); + + const mine = currentSetup(snapshot); + const code = open ? encodeSetupCode(mine) : ""; + const decoded = text.trim() ? decodeSetupCode(text) : null; + const problem = text.trim() && !decoded ? "That is not a valid setup code." : null; + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
+
+
+

Setup

+

Share setup

+
+ +
+
+