From f8b666cb29d5a069a1508df25551d48abf04f4cc Mon Sep 17 00:00:00 2001 From: Onyx Date: Sun, 18 Oct 2026 03:22:11 +0200 Subject: [PATCH] Restore X/Y DPI editing on Logitech slots Add a Y column with a per-slot link button, snap typed DPI to the advertised values (nearest, ties up), keep the starting slot when a value is retyped, wait for a pause while typing, step the arrows to the next advertised value, and keep the slot editor visible while a flash re-reads profiles. --- src/device/controller.ts | 38 ++++++--- src/features/devices/cards/DpiCard.tsx | 105 ++++++++++++++++++++----- src/styles/areas/performance.css | 17 ++++ 3 files changed, 130 insertions(+), 30 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index bb82e689..7ce0bbf8 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -4449,8 +4449,7 @@ async function writeStagedProfileSector(): Promise { .filter((edit): edit is Extract => edit.kind === "macro") .map(({ layer, button, steps }) => ({ layer, button, steps })), }); - onboardProfiles = null; - await reloadOnboardProfiles(); + if (!await reloadOnboardProfiles()) onboardProfiles = null; } export function editedProfileEntry(): OnboardProfile | null { @@ -4558,12 +4557,24 @@ export function setDpiSlotCount(count: number): void { stageDpiSlots(); } +function snapSlotDpi(value: number, limits: Parameters[1]): number { + if (dpiOptions.length === 0 || !Number.isFinite(value)) return clampDpi(value, limits); + const target = Math.min(limits.maxDpi, Math.max(limits.minDpi, value)); + let best = dpiOptions[0]; + for (const option of dpiOptions) { + const gap = Math.abs(option - target); + const bestGap = Math.abs(best - target); + if (gap < bestGap || (gap === bestGap && option > best)) best = option; + } + return best; +} + export function setDpiSlotAxis(index: number, axis: "x" | "y", value: number): void { if (blockedByGameProfileDraft()) return; const limits = dpiSlotLimits(); const stage = dpiSlotPlan?.stages[index]; if (!stage || !limits || dpiSlotsLocked()) return; - const dpi = clampDpi(value, limits); + const dpi = snapSlotDpi(value, limits); stage[axis] = dpi; if (dpiAxisLockedAt(index)) stage[axis === "x" ? "y" : "x"] = dpi; stageDpiSlots(); @@ -4589,23 +4600,26 @@ export function setDpiSlotDefault(index: number): void { stageDpiSlots(); } -export function applyDpiSlotEditor(rows: { enabled: boolean; value: number; lod: number }[]): void { +export function applyDpiSlotEditor(rows: { enabled: boolean; value: number; valueY?: number; lod: number }[]): void { if (blockedByGameProfileDraft()) return; const limits = dpiSlotLimits(); if (!dpiSlotPlan || !limits || dpiSlotsLocked()) return; const enabled = rows.filter((row) => row.enabled && row.value > 0); if (enabled.length === 0) return; const previous = dpiSlotPlan.stages; - const oldDefault = previous[dpiSlotPlan.defaultIndex]?.x; + const oldDefaultIndex = dpiSlotPlan.defaultIndex; + const oldDefault = previous[oldDefaultIndex]; const stages = enabled.slice(0, limits.maxStages).map((row) => ({ - x: clampDpi(row.value, limits), - y: clampDpi(row.value, limits), + x: snapSlotDpi(row.value, limits), + y: snapSlotDpi(row.valueY ?? row.value, limits), lod: Number.isFinite(row.lod) && row.lod >= 0 ? Math.round(row.lod) : 2, })); - const matchedDefault = stages.findIndex((stage) => stage.x === oldDefault); + const matchedDefault = stages.length === previous.length + ? oldDefaultIndex + : stages.findIndex((stage) => stage.x === oldDefault?.x && stage.y === oldDefault?.y); dpiSlotPlan = { stages, - defaultIndex: matchedDefault === -1 ? 0 : matchedDefault, + defaultIndex: matchedDefault < 0 || matchedDefault >= stages.length ? 0 : matchedDefault, }; stageDpiSlots(); } @@ -4682,17 +4696,19 @@ export function describeProfileEntry(entry: OnboardProfile | null): { name: stri }; } -export async function reloadOnboardProfiles(): Promise { +export async function reloadOnboardProfiles(): Promise { const client = logitechClient(); - if (!client || onboardProfilesLoading) return; + if (!client || onboardProfilesLoading) return false; onboardProfilesLoading = true; setOnboardStatus(st("ctl.readingProfiles")); try { onboardProfiles = await client.readOnboardProfiles(); syncProfileDerivedState(); + return true; } catch (error) { recordDiagnosticError(error, st("ctl.unableProfiles")); setOnboardStatus(error instanceof Error ? error.message : "Unable to read onboard profiles."); + return false; } finally { onboardProfilesLoading = false; } diff --git a/src/features/devices/cards/DpiCard.tsx b/src/features/devices/cards/DpiCard.tsx index 8bd197f4..efd26b1e 100644 --- a/src/features/devices/cards/DpiCard.tsx +++ b/src/features/devices/cards/DpiCard.tsx @@ -11,11 +11,12 @@ import { isNoirE1Status, isNoirKsnakeStatus, isNoirS1Status, NOIR_DPI_STAGE_COLO import { closestDpiOption, dpiPresetValues } from "../../../device/dpi-presets"; import { t, tp } from "../../../i18n/index"; import { LiftOffDistance, hasLiftOff } from "./PerformanceCards"; +import { IconLinked, IconUnlinked } from "../../../shared/icons"; const MAX_EDITOR_ROWS = 4; const DEFAULT_LOD = 2; -type StageRow = { enabled: boolean; value: string; valueY: string; lod: number }; +type StageRow = { enabled: boolean; value: string; valueY: string; lod: number; linked?: boolean }; function isUniform(stages: readonly number[] | undefined): boolean { return stages !== undefined && stages.length > 1 && stages.every((value) => value === stages[0]); @@ -61,6 +62,8 @@ function DpiStageEditor({ const stageAxesEditable = mode === "stage" && snapshot.capabilities?.dpiStageAxesWritable === true && Array.isArray(status?.dpiStagesY); + const slotAxesEditable = mode === "logitech" && status?.supportsSeparateDpiAxes === true; + const showAxes = stageAxesEditable || slotAxesEditable; const stageColors = mode === "stage" ? status?.dpiStageColors ?? (isNoirKsnakeStatus(status) ? NOIR_DPI_STAGE_COLORS : null) : null; @@ -74,12 +77,12 @@ function DpiStageEditor({ const countCap = isLogitech ? (limits?.maxStages ?? 1) : stageEditor?.maxStages ?? 1; const rowCap = mode === "generic" ? MAX_EDITOR_ROWS : countCap; - const initRows = (): StageRow[] => { + const initRows = (prev?: StageRow[]): StageRow[] => { if (mode === "logitech" && snapshot.dpiSlotPlan) { const stages = snapshot.dpiSlotPlan.stages.slice(0, rowCap); const last = stages[stages.length - 1]?.x ?? status?.dpi ?? 800; - const rows = stages.map((stage) => ({ enabled: true, value: String(stage.x), valueY: String(stage.y ?? stage.x), lod: stage.lod })); - while (rows.length < rowCap) rows.push({ enabled: false, value: String(last), valueY: String(last), lod: DEFAULT_LOD }); + 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 })); + while (rows.length < rowCap) rows.push({ enabled: false, value: String(last), valueY: String(last), lod: DEFAULT_LOD, linked: true }); return rows; } if (mode === "stage" && status?.dpiStages) { @@ -149,7 +152,7 @@ function DpiStageEditor({ // Rebuild local rows only when the device source clearly changes while the // user is not mid-edit; disabled-but-kept rows are local and must survive. const sourceSig = mode === "logitech" - ? (snapshot.dpiSlotPlan?.stages.map((stage) => stage.x).join(",") ?? "-") + ? (snapshot.dpiSlotPlan?.stages.map((stage) => `${stage.x}/${stage.y}`).join(",") ?? "-") : mode === "stage" ? `${status?.dpiStages?.join(",") ?? "-"}|${status?.dpiStagesY?.join(",") ?? "-"}` : String(status?.dpi ?? 0); @@ -157,7 +160,7 @@ function DpiStageEditor({ useEffect(() => { if (sourceSig === lastSigRef.current) return; lastSigRef.current = sourceSig; - if (Date.now() - touchedRef.current > 1500) setRows(initRows()); + if (Date.now() - touchedRef.current > 1500 || (mode === "logitech" && !timerRef.current)) setRows(initRows(rowsRef.current)); // initRows is recreated each render on purpose so it always reads fresh snapshot data. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sourceSig]); @@ -193,6 +196,7 @@ function DpiStageEditor({ return { enabled: row.enabled, value: parseRow(row.value) ?? (limits?.minDpi ?? 100), + valueY: slotAxesEditable ? parseRow(row.valueY) ?? undefined : undefined, lod: planLods[position] ?? row.lod, }; }), @@ -235,13 +239,23 @@ function DpiStageEditor({ } }; - const markEdited = (next: StageRow[]): void => { + const markEdited = (next: StageRow[], delay = 450): void => { touchedRef.current = Date.now(); if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => commit(rowsRef.current), 450); + timerRef.current = setTimeout(() => { + timerRef.current = null; + commit(rowsRef.current); + }, delay); void next; }; + const flushEdit = (): void => { + if (!timerRef.current) return; + clearTimeout(timerRef.current); + timerRef.current = null; + commit(rowsRef.current); + }; + const setEnabled = (index: number, enabled: boolean): void => { let next = rows.map((row, i) => (i === index ? { ...row, enabled: !row.enabled } : row)); if (mode === "generic") { @@ -273,18 +287,48 @@ function DpiStageEditor({ markEdited(next); }; - const setValue = (index: number, raw: string): void => { - const next = rows.map((row, i) => (i === index ? { ...row, value: raw.replace(/[^\d]/g, "") } : row)); + const TYPING_DELAY = 1200; + + const setValue = (index: number, raw: string, delay?: number): void => { + const value = raw.replace(/[^\d]/g, ""); + const next = rows.map((row, i) => (i === index + ? { ...row, value, valueY: slotAxesEditable && row.linked !== false ? value : row.valueY } + : row)); + setRows(next); + rowsRef.current = next; + markEdited(next, delay); + }; + + const inputEdit = (event: Event, oldRaw: string): { raw: string; delay?: number } => { + const raw = (event.currentTarget as HTMLInputElement).value; + const type = (event as InputEvent).inputType; + if (type && type !== "insertReplacementText") return { raw, delay: TYPING_DELAY }; + const old = parseRow(oldRaw); + const wanted = Number(raw); + if (mode !== "logitech" || old === null || !Number.isFinite(wanted) || wanted === old) return { raw }; + const sorted = [...options].sort((a, b) => a - b); + const neighbour = wanted > old + ? sorted.find((option) => option > old) + : [...sorted].reverse().find((option) => option < old); + return { raw: String(neighbour ?? wanted) }; + }; + + 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 }; + }); setRows(next); rowsRef.current = next; markEdited(next); }; - const setValueY = (index: number, raw: string): void => { + const setValueY = (index: number, raw: string, delay?: number): void => { const next = rows.map((row, i) => (i === index ? { ...row, valueY: raw.replace(/[^\d]/g, "") } : row)); setRows(next); rowsRef.current = next; - markEdited(next); + markEdited(next, delay); }; const setSlider = (index: number, numeric: number): void => setValue(index, String(numeric)); @@ -350,7 +394,7 @@ function DpiStageEditor({ const enabledCount = rows.filter((row) => row.enabled).length; return ( -
+
{t(locale, mode === "logitech" ? "dpi.slotsInUse" : mode === "stage" ? "dpi.stagesInUse" : "dpi.presetsInUse")} @@ -358,7 +402,7 @@ function DpiStageEditor({ {enabledCount} / {rows.length}
- {stageAxesEditable ? ( + {showAxes ? (