From 7f866c590e157029ca686901d5a65eaad063e565 Mon Sep 17 00:00:00 2001 From: Onyx Date: Sun, 18 Oct 2026 13:57:22 +0200 Subject: [PATCH] Keep an unlinked Logitech X/Y slot unlinked after a refresh The link state was derived from X equal to Y, so a row unlinked on purpose with equal values showed linked again after a reload. Remember unlinked rows in the browser per device and profile. --- src/device/controller.ts | 43 ++++++++++++++++++++++++++ src/features/devices/cards/DpiCard.tsx | 20 +++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index 7ce0bbf8..9efbe33f 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -4580,6 +4580,49 @@ export function setDpiSlotAxis(index: number, axis: "x" | "y", value: number): v stageDpiSlots(); } +const DPI_UNLINKED_STORAGE_KEY = "openmouse.dpi-unlinked"; + +function unlinkedStore(): Record { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(DPI_UNLINKED_STORAGE_KEY) ?? "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + +/** Identifies the device and profile the unlink choices belong to; two identical Bridge devices differ by session key. */ +export function dpiUnlinkKey(): string | null { + if (!activeDevice || editedProfile === null) return null; + const bridge = activeDevice as HIDDevice & { openMouseTransport?: string; key?: string }; + const device = bridge.openMouseTransport === "bridge" ? `bridge:${bridge.key ?? ""}` : deviceStorageKey(activeDevice); + return `${device}#${editedProfile}`; +} + +/** Slot rows whose X and Y were unlinked on purpose; the mouse cannot store that when the two are equal. */ +export function unlinkedDpiSlots(): number[] { + const key = dpiUnlinkKey(); + const saved = key ? unlinkedStore()[key] : undefined; + return Array.isArray(saved) ? saved : []; +} + +/** Replaces the whole unlinked list for the active device and profile. */ +export function replaceDpiUnlinkedSlots(indices: number[]): void { + const key = dpiUnlinkKey(); + if (!key) return; + try { + localStorage.setItem(DPI_UNLINKED_STORAGE_KEY, JSON.stringify({ ...unlinkedStore(), [key]: [...new Set(indices)].sort((a, b) => a - b) })); + } catch { + } +} + +export function setDpiSlotUnlinked(index: number, unlinked: boolean): void { + const rows = new Set(unlinkedDpiSlots()); + if (unlinked) rows.add(index); + else rows.delete(index); + replaceDpiUnlinkedSlots([...rows]); +} + export function dpiAxisLockedAt(index: number): boolean { return dpiAxisLocks[index] ?? true; } diff --git a/src/features/devices/cards/DpiCard.tsx b/src/features/devices/cards/DpiCard.tsx index efd26b1e..30967561 100644 --- a/src/features/devices/cards/DpiCard.tsx +++ b/src/features/devices/cards/DpiCard.tsx @@ -77,11 +77,12 @@ function DpiStageEditor({ const countCap = isLogitech ? (limits?.maxStages ?? 1) : stageEditor?.maxStages ?? 1; const rowCap = mode === "generic" ? MAX_EDITOR_ROWS : countCap; - const initRows = (prev?: StageRow[]): StageRow[] => { + const initRows = (): StageRow[] => { if (mode === "logitech" && snapshot.dpiSlotPlan) { const stages = snapshot.dpiSlotPlan.stages.slice(0, rowCap); + const unlinked = control.unlinkedDpiSlots(); const last = stages[stages.length - 1]?.x ?? status?.dpi ?? 800; - const rows = stages.map((stage, index) => ({ enabled: true, value: String(stage.x), valueY: String(stage.y ?? stage.x), lod: stage.lod, linked: (stage.y ?? stage.x) === stage.x && prev?.[index]?.linked !== false })); + const rows = stages.map((stage, index) => ({ enabled: true, value: String(stage.x), valueY: String(stage.y ?? stage.x), lod: stage.lod, linked: (stage.y ?? stage.x) === stage.x && !unlinked.includes(index) })); while (rows.length < rowCap) rows.push({ enabled: false, value: String(last), valueY: String(last), lod: DEFAULT_LOD, linked: true }); return rows; } @@ -160,7 +161,7 @@ function DpiStageEditor({ useEffect(() => { if (sourceSig === lastSigRef.current) return; lastSigRef.current = sourceSig; - if (Date.now() - touchedRef.current > 1500 || (mode === "logitech" && !timerRef.current)) setRows(initRows(rowsRef.current)); + if (Date.now() - touchedRef.current > 1500 || (mode === "logitech" && !timerRef.current)) setRows(initRows()); // initRows is recreated each render on purpose so it always reads fresh snapshot data. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sourceSig]); @@ -190,6 +191,10 @@ function DpiStageEditor({ // side panel), so a DPI-value commit must not clobber it with the local // row copy. Enabled rows map 1:1 to plan stages in order. const planLods = snapshot.dpiSlotPlan?.stages.map((stage) => stage.lod) ?? []; + // Disabled rows drop out of the plan, so saved unlink rows move to their compacted index. + control.replaceDpiUnlinkedSlots( + next.filter((row) => row.enabled).flatMap((row, position) => (row.linked === false ? [position] : [])), + ); control.applyDpiSlotEditor( next.map((row, index) => { const position = next.slice(0, index).filter((entry) => entry.enabled).length; @@ -314,11 +319,9 @@ function DpiStageEditor({ }; const toggleLink = (index: number): void => { - const next = rows.map((row, i) => { - if (i !== index) return row; - const linked = row.linked === false; - return { ...row, linked, valueY: linked ? row.value : row.valueY }; - }); + const linked = rows[index]?.linked === false; + control.setDpiSlotUnlinked(index, !linked); + const next = rows.map((row, i) => (i === index ? { ...row, linked, valueY: linked ? row.value : row.valueY } : row)); setRows(next); rowsRef.current = next; markEdited(next); @@ -705,6 +708,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode