diff --git a/package-lock.json b/package-lock.json index 7ab1367d..0d53e21a 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.29.0", + "@openmouse/protocol": "0.30.0", "lucide-react": "^1.43.0", "preact": "^10.29.8" }, @@ -481,9 +481,9 @@ } }, "node_modules/@openmouse/protocol": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@openmouse/protocol/-/protocol-0.29.0.tgz", - "integrity": "sha512-0YcCdBZigRZlfJgVWxe9iGUQChilgUX/9J3lwHACRJ9goGQVw16IRW7DnSQ5R93NaMxVTMaOCIiloCGDbGR1Kw==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@openmouse/protocol/-/protocol-0.30.0.tgz", + "integrity": "sha512-gIRoMJF3y0qzXPut8sRTvg/4pmOP0EQG2DwtD4aHZL5iPT76IzDC0fI45902yFVTDRfpzrOo4VCCfP+XXMd2cA==", "license": "AGPL-3.0-or-later", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 9d30af44..b70d791b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "preview": "vite preview" }, "dependencies": { - "@openmouse/protocol": "0.29.0", + "@openmouse/protocol": "0.30.0", "lucide-react": "^1.43.0", "preact": "^10.29.8" }, diff --git a/public/devices/asus-rog-harpe-ace-aim-lab.png b/public/devices/asus-rog-harpe-ace-aim-lab.png new file mode 100644 index 00000000..634f6c49 Binary files /dev/null and b/public/devices/asus-rog-harpe-ace-aim-lab.png differ diff --git a/src/app/cards/DpiCard.tsx b/src/app/cards/DpiCard.tsx index 31ebaf32..c3a9b9cb 100644 --- a/src/app/cards/DpiCard.tsx +++ b/src/app/cards/DpiCard.tsx @@ -16,6 +16,21 @@ const DEFAULT_LOD = 2; type StageRow = { enabled: boolean; value: string; lod: number }; +function isUniform(stages: readonly number[] | undefined): boolean { + return stages !== undefined && stages.length > 1 && stages.every((value) => value === stages[0]); +} + +/** Single on a fixed-count stage mouse puts one DPI in every stage, so the DPI button has nothing to cycle. */ +function singleFillsStageTable(snapshot: ControlSnapshot): boolean { + const status = snapshot.status; + const logitechSlots = snapshot.profile.slotsAvailable && snapshot.dpiSlotPlan !== null; + return !logitechSlots + && (status?.dpiStages?.length ?? 0) > 0 + && status?.ui?.dpiStageEditor !== undefined + && status.ui.dpiStageEditor.countEditable !== true + && snapshot.capabilities?.dpiStagesWritable === true; +} + /** * One DPI editor for every device flavor. It always shows up to four rows, * each with a tickbox (include the stage in the DPI cycle), a typed value, and @@ -98,6 +113,32 @@ function DpiStageEditor({ }, []); const fixedStageCount = mode === "stage" && stageEditor?.countEditable !== true; + const fillsStageTable = singleFillsStageTable(snapshot); + const singleFillsStages = mode === "generic" && fillsStageTable; + + const stagesDifferFrom = (dpi: number): boolean => (status?.dpiStages ?? []).some((value) => value !== dpi); + + const applySingleDpi = (dpi: number): void => { + if (!singleFillsStages) { + control.applyDpiValue(dpi); + return; + } + // Keep the last real table so the Stages view can put it back. + const deviceStages = snapshot.deviceStatus?.dpiStages; + if (deviceStages && !isUniform(deviceStages)) control.rememberDpiStagesBeforeSingle(deviceStages); + (status?.dpiStages ?? []).forEach((value, stage) => { + if (value !== dpi) control.applyDpiStageValue(stage, dpi); + }); + }; + + const restoreStagesBeforeSingle = (): void => { + const backup = control.dpiStagesBeforeSingle(); + const stages = status?.dpiStages; + if (!backup || !stages || backup.length !== stages.length || !isUniform(stages)) return; + backup.forEach((dpi, stage) => { + if (stages[stage] !== dpi) control.applyDpiStageValue(stage, dpi); + }); + }; // A table the driver cannot write is inert: no value edits, no active-stage // switch. Kept distinct from `locked` (settings-pending) because the note // and the disabled reasons are different. @@ -126,6 +167,8 @@ function DpiStageEditor({ if (mode === lastModeRef.current) return; lastModeRef.current = mode; setRows(initRows()); + if (singleFillsStages && status?.dpi && stagesDifferFrom(status.dpi)) applySingleDpi(status.dpi); + if (mode === "stage" && fillsStageTable) restoreStagesBeforeSingle(); // initRows is recreated each render on purpose so it always reads fresh snapshot data. // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); @@ -171,8 +214,8 @@ function DpiStageEditor({ const active = next.find((row) => row.enabled); if (!active) return; const snap = closestDpiOption(options, parseRow(active.value) ?? status?.dpi ?? 0); - if (snap !== null && snap !== status?.dpi) { - control.applyDpiValue(snap); + if (snap !== null && (singleFillsStages ? stagesDifferFrom(snap) : snap !== status?.dpi)) { + applySingleDpi(snap); const next2 = next.map((row) => (row.enabled ? { ...row, value: String(snap) } : row)); setRows(next2); rowsRef.current = next2; @@ -195,7 +238,7 @@ function DpiStageEditor({ const selected = next.map((row, i) => (i === index ? { ...row, enabled: true } : { ...row, enabled: false })); setRows(selected); rowsRef.current = selected; - control.applyDpiValue(snap); + applySingleDpi(snap); touchedRef.current = Date.now(); return; } @@ -280,7 +323,9 @@ function DpiStageEditor({ ? tp(locale, "dpi.editorFixedNote", { total: status?.dpiStages?.length ?? 0 }) : mode === "stage" ? t(locale, "dpi.editorCountNote") - : t(locale, "dpi.editorGenericNote"); + : singleFillsStages + ? tp(locale, "dpi.singleOverwritesNote", { total: status?.dpiStages?.length ?? 0 }) + : t(locale, "dpi.editorGenericNote"); const enabledCount = rows.filter((row) => row.enabled).length; @@ -358,7 +403,7 @@ function DpiStageEditor({ ); })} - {note} + {note} ); } @@ -447,7 +492,10 @@ export function SlotLiftOffPanel({ snapshot }: { snapshot: ControlSnapshot }): R } export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { - const [editorView, setEditorView] = useState<"stage" | "single">("stage"); + // A table with one value in every stage is what Single saved, so reopen in that view. + const [editorView, setEditorView] = useState<"stage" | "single">(() => ( + singleFillsStageTable(snapshot) && isUniform(snapshot.deviceStatus?.dpiStages) ? "single" : "stage" + )); const status = snapshot.status; const deviceStatus = snapshot.deviceStatus; const locale = snapshot.preferences.locale; diff --git a/src/control.css b/src/control.css index b97b7ab4..7b39e47b 100644 --- a/src/control.css +++ b/src/control.css @@ -892,6 +892,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } #polling-card .title-row h2, #host-lod-row .title-row h2 { font-size: 1.15rem; } .setting-note { display: block; margin-top: 1rem; color: var(--text-soft); font-size: .72rem; line-height: 1.5; } .setting-note-stable { min-height: 3em; } +.setting-note-warning { color: var(--warning); } .setting-action { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-top: .7rem; color: var(--text-soft); font-size: .7rem; font-weight: 500; } .setting-action #dpi-pending { color: var(--text-strong); font-weight: 650; } .setting-action button { padding: .4rem .65rem; border: 1px solid var(--text); border-radius: 6px; background: var(--text); color: var(--ink); font-size: .65rem; font-weight: 700; } diff --git a/src/device/controller.ts b/src/device/controller.ts index f8938399..f29f10af 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -2452,6 +2452,30 @@ function rememberActiveDevice(device: HIDDevice): void { } } +const SINGLE_DPI_BACKUP_STORAGE_KEY = "openmouse.single-dpi-backup"; + +function singleDpiBackups(): Record { + try { + return JSON.parse(localStorage.getItem(SINGLE_DPI_BACKUP_STORAGE_KEY) ?? "{}") as Record; + } catch { + return {}; + } +} + +/** The stage table the active mouse had before the Single DPI view gave every stage one value. */ +export function dpiStagesBeforeSingle(): number[] | null { + return activeDevice ? singleDpiBackups()[deviceStorageKey(activeDevice)] ?? null : null; +} + +export function rememberDpiStagesBeforeSingle(stages: readonly number[]): void { + if (!activeDevice) return; + try { + const backups = { ...singleDpiBackups(), [deviceStorageKey(activeDevice)]: [...stages] }; + localStorage.setItem(SINGLE_DPI_BACKUP_STORAGE_KEY, JSON.stringify(backups)); + } catch { + } +} + async function waitForControllerIdle(): Promise { while (settingInProgress || refreshInProgress || activationInProgress) { await new Promise((resolve) => window.setTimeout(resolve, 25)); diff --git a/src/i18n-ar.ts b/src/i18n-ar.ts index b7b282ac..d369a573 100644 --- a/src/i18n-ar.ts +++ b/src/i18n-ar.ts @@ -322,6 +322,7 @@ export const ar: Record = { "dpi.editorFixedNote": "صف واحد لكل مرحلة DPI، {total} ثابتة. عدّل القيمة أو المنزلق لتغييرها. انقر رقمًا لجعله المرحلة النشطة.", "dpi.stagesReadOnly": "تأتي مراحل DPI هذه من الماوس للقراءة فقط، ولا يمكن تعديلها هنا. غيّرها في برنامج الشركة المصنّعة.", "dpi.editorGenericNote": "صف واحد لكل DPI مسبق. حدّد صفًا (أو انقر رقمه) لجعله DPI الحالي؛ تبقى الصفوف غير المحددة كاختيارات لاحقًا.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "المحور X", "dpi.yAxis": "المحور Y", "dpi.viewStages": "مراحل", diff --git a/src/i18n-de.ts b/src/i18n-de.ts index 6fd5fa1a..a6747bca 100644 --- a/src/i18n-de.ts +++ b/src/i18n-de.ts @@ -323,6 +323,7 @@ export const de: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "X-Achse", "dpi.yAxis": "Y-Achse", "dpi.viewStages": "Stufen", diff --git a/src/i18n-es.ts b/src/i18n-es.ts index 141439ad..d4a72dc9 100644 --- a/src/i18n-es.ts +++ b/src/i18n-es.ts @@ -323,6 +323,7 @@ export const es: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "Eje X", "dpi.yAxis": "Eje Y", "dpi.viewStages": "Etapas", diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts index ac150f22..5229cdd5 100644 --- a/src/i18n-fr.ts +++ b/src/i18n-fr.ts @@ -323,6 +323,7 @@ export const fr: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "Axe X", "dpi.yAxis": "Axe Y", "dpi.viewStages": "Étapes", diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts index ff4d4478..ad2475d3 100644 --- a/src/i18n-ja.ts +++ b/src/i18n-ja.ts @@ -323,6 +323,7 @@ export const ja: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "X軸", "dpi.yAxis": "Y軸", "dpi.viewStages": "ステージ", diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts index 9f8f4a6f..c1b9a338 100644 --- a/src/i18n-ko.ts +++ b/src/i18n-ko.ts @@ -323,6 +323,7 @@ export const ko: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "X축", "dpi.yAxis": "Y축", "dpi.viewStages": "단계", diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts index 709db666..a2126152 100644 --- a/src/i18n-pt.ts +++ b/src/i18n-pt.ts @@ -323,6 +323,7 @@ export const pt: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "Eixo X", "dpi.yAxis": "Eixo Y", "dpi.viewStages": "Estágios", diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts index 4a1641f6..8290d827 100644 --- a/src/i18n-ru.ts +++ b/src/i18n-ru.ts @@ -322,6 +322,7 @@ export const ru: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "Ось X", "dpi.yAxis": "Ось Y", "dpi.viewStages": "Ступени", diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts index c7b9ace4..6a828c2b 100644 --- a/src/i18n-vi.ts +++ b/src/i18n-vi.ts @@ -271,6 +271,7 @@ export const vi: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "Trục X", "dpi.yAxis": "Trục Y", "dpi.viewStages": "Stage", diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts index b5089f89..38e4c3b9 100644 --- a/src/i18n-zh.ts +++ b/src/i18n-zh.ts @@ -323,6 +323,7 @@ export const zh: Record = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "X 轴", "dpi.yAxis": "Y 轴", "dpi.viewStages": "阶段", diff --git a/src/i18n.ts b/src/i18n.ts index 4070f775..54963bf8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -327,6 +327,7 @@ const en = { "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", + "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", "dpi.xAxis": "X axis", "dpi.yAxis": "Y axis", "dpi.viewStages": "Stages", diff --git a/src/ui/device-images.test.ts b/src/ui/device-images.test.ts index 3607d8d3..64ad9957 100644 --- a/src/ui/device-images.test.ts +++ b/src/ui/device-images.test.ts @@ -107,6 +107,11 @@ test("Delux M800 Mini and M800 family resolve by name", () => { assert.equal(deviceImage(null, "Delux M800 Pro"), "/devices/delux-m800-mini.png"); }); +test("ROG Harpe Ace Aim Lab Edition resolves by name on every connection", () => { + assert.equal(deviceImage(null, "ROG Harpe Ace Aim Lab Edition"), "/devices/asus-rog-harpe-ace-aim-lab.png"); + assert.equal(deviceImage(null, "ROG Omni receiver"), CDN + "unknown-device.png"); +}); + test("Attack Shark R2 resolves by name (PID 0x402D is shared with the M5 Pro)", () => { // TEMPORARY: served from the repo override (see LOCAL_OVERRIDES in // device-images.ts / public/devices/README.md) until a maintainer uploads diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts index 01431f92..b29245a2 100644 --- a/src/ui/device-images.ts +++ b/src/ui/device-images.ts @@ -68,6 +68,8 @@ function resolveDeviceImageFilename(_device: HIDDevice | null | undefined, displ // don't match. if (/\battack\s*shark\s*x3\b/i.test(displayName)) return "attackshark-x3.png"; if (/\bm[23]k\b/i.test(displayName)) return "zaunkoenig-m3k.png"; + // Name, not PID: over the ROG Omni receiver the device is 0x1ACE. + if (/\bharpe\s*ace\s*aim\s*lab\b/i.test(displayName)) return "asus-rog-harpe-ace-aim-lab.png"; if (/\bmx\s*master\s*3s\b/i.test(displayName)) return "logitech-mx-master-3s.png"; if (/\bterra\s*pro\b/i.test(displayName)) return "teevolution-terra-pro.png"; if (/\bm-001\b/i.test(displayName)) return "wallhack-m-001.png"; @@ -205,6 +207,7 @@ const LOCAL_OVERRIDES: Readonly> = { "attackshark-r2.png": "/devices/attackshark-r2.png", "delux-m800-mini.png": "/devices/delux-m800-mini.png", "ipi-float-88.png": "/devices/ipi-float-88.png", + "asus-rog-harpe-ace-aim-lab.png": "/devices/asus-rog-harpe-ace-aim-lab.png", }; export function deviceImage(device: HIDDevice | null | undefined, displayName = ""): string {