Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Binary file added public/devices/asus-rog-harpe-ace-aim-lab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 54 additions & 6 deletions src/app/cards/DpiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -358,7 +403,7 @@ function DpiStageEditor({
);
})}
</div>
<small className="setting-note">{note}</small>
<small className={`setting-note${singleFillsStages ? " setting-note-warning" : ""}`}>{note}</small>
</div>
);
}
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
24 changes: 24 additions & 0 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2452,6 +2452,30 @@ function rememberActiveDevice(device: HIDDevice): void {
}
}

const SINGLE_DPI_BACKUP_STORAGE_KEY = "openmouse.single-dpi-backup";

function singleDpiBackups(): Record<string, number[]> {
try {
return JSON.parse(localStorage.getItem(SINGLE_DPI_BACKUP_STORAGE_KEY) ?? "{}") as Record<string, number[]>;
} 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<void> {
while (settingInProgress || refreshInProgress || activationInProgress) {
await new Promise<void>((resolve) => window.setTimeout(resolve, 25));
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export const ar: Record<I18nKey, string> = {
"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": "مراحل",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const de: Record<I18nKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const es: Record<I18nKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const fr: Record<I18nKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const ja: Record<I18nKey, string> = {
"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": "ステージ",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const ko: Record<I18nKey, string> = {
"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": "단계",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const pt: Record<I18nKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export const ru: Record<I18nKey, string> = {
"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": "Ступени",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export const vi: Record<I18nKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const zh: Record<I18nKey, string> = {
"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": "阶段",
Expand Down
1 change: 1 addition & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/ui/device-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/ui/device-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -205,6 +207,7 @@ const LOCAL_OVERRIDES: Readonly<Record<string, string>> = {
"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 {
Expand Down