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
38 changes: 27 additions & 11 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4449,8 +4449,7 @@ async function writeStagedProfileSector(): Promise<void> {
.filter((edit): edit is Extract<StagedProfileButtonEdit, { kind: "macro" }> => edit.kind === "macro")
.map(({ layer, button, steps }) => ({ layer, button, steps })),
});
onboardProfiles = null;
await reloadOnboardProfiles();
if (!await reloadOnboardProfiles()) onboardProfiles = null;
}

export function editedProfileEntry(): OnboardProfile | null {
Expand Down Expand Up @@ -4558,12 +4557,24 @@ export function setDpiSlotCount(count: number): void {
stageDpiSlots();
}

function snapSlotDpi(value: number, limits: Parameters<typeof clampDpi>[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();
Expand All @@ -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();
}
Expand Down Expand Up @@ -4682,17 +4696,19 @@ export function describeProfileEntry(entry: OnboardProfile | null): { name: stri
};
}

export async function reloadOnboardProfiles(): Promise<void> {
export async function reloadOnboardProfiles(): Promise<boolean> {
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;
}
Expand Down
105 changes: 86 additions & 19 deletions src/features/devices/cards/DpiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -149,15 +152,15 @@ 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);
const lastSigRef = useRef(sourceSig);
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]);
Expand Down Expand Up @@ -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,
};
}),
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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));
Expand Down Expand Up @@ -350,15 +394,15 @@ function DpiStageEditor({
const enabledCount = rows.filter((row) => row.enabled).length;

return (
<div id="dpi-stage-editor" className={`dpi-editor mode-${mode}${showStageColors ? " has-m2nex-stage-colors" : ""}${stageAxesEditable ? " has-stage-axes" : ""}`}>
<div id="dpi-stage-editor" className={`dpi-editor mode-${mode}${slotAxesEditable ? " has-slot-axes" : ""}${showStageColors ? " has-m2nex-stage-colors" : ""}${showAxes ? " has-stage-axes" : ""}`}>
<div className="dpi-editor-bar">
<span className="dpi-editor-bar-label">
{t(locale, mode === "logitech" ? "dpi.slotsInUse" : mode === "stage" ? "dpi.stagesInUse" : "dpi.presetsInUse")}
</span>
<span className="dpi-editor-bar-count">{enabledCount} / {rows.length}</span>
</div>
<div className="dpi-slot-rule" />
{stageAxesEditable ? (
{showAxes ? (
<div className="dpi-editor-axes-head" aria-hidden="true">
<span>{t(locale, "dpi.axisX")}</span>
<span>{t(locale, "dpi.axisY")}</span>
Expand Down Expand Up @@ -411,19 +455,42 @@ function DpiStageEditor({
step={slider.step}
value={row.value}
disabled={locked || (mode === "stage" && !stagesWritable)}
onChange={(event) => setValue(index, event.currentTarget.value)}
onChange={(event) => {
const edit = inputEdit(event, row.value);
setValue(index, edit.raw, edit.delay);
}}
onBlur={flushEdit}
onKeyDown={(event) => { if (event.key === "Enter") flushEdit(); }}
/>
{stageAxesEditable ? (
{slotAxesEditable ? (
<button
type="button"
className={`dpi-axis-lock${row.linked === false ? "" : " is-linked"}`}
disabled={locked || !row.enabled}
title={row.linked === false ? t(locale, "dpi.unlinkedHint") : t(locale, "dpi.linkedHint")}
aria-label={tp(locale, "dpi.linkXY", { n: index + 1 })}
aria-pressed={row.linked !== false}
onClick={() => toggleLink(index)}
>
{row.linked === false ? <IconUnlinked /> : <IconLinked />}
</button>
) : null}
{showAxes ? (
<input
type="number"
className="dpi-editor-value"
className="dpi-editor-value dpi-editor-value-y"
aria-label={tp(locale, "dpi.stageDpiY", { n: index + 1 })}
min={slider.min}
max={slider.max}
step={slider.step}
value={row.valueY}
disabled={locked || !stagesWritable}
onChange={(event) => setValueY(index, event.currentTarget.value)}
disabled={locked || (mode === "stage" && !stagesWritable) || (slotAxesEditable && row.linked !== false)}
onChange={(event) => {
const edit = inputEdit(event, row.valueY);
setValueY(index, edit.raw, edit.delay);
}}
onBlur={flushEdit}
onKeyDown={(event) => { if (event.key === "Enter") flushEdit(); }}
/>
) : null}
<input
Expand Down
17 changes: 17 additions & 0 deletions src/styles/areas/performance.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@
}
.dpi-editor-row.is-active { background: rgb(var(--accent-rgb) / 0.065); }
.dpi-editor.has-stage-axes .dpi-editor-row { grid-template-columns: 32px 36px 84px 84px minmax(0, 1fr); }
.dpi-editor.has-slot-axes .dpi-editor-row,
.dpi-editor.has-slot-axes .dpi-editor-axes-head { grid-template-columns: 32px 36px 84px 28px 84px minmax(0, 1fr); }
.dpi-editor.has-slot-axes .dpi-editor-axes-head > span:last-child { grid-column: 5; }
.dpi-axis-lock {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-sm);
color: var(--accent);
}
.dpi-axis-lock.is-linked { color: var(--text-3); }
.dpi-axis-lock:hover:not(:disabled) { background: var(--surface-2); color: var(--text-1); }
.dpi-axis-lock:disabled { opacity: 0.55; }
.dpi-editor-value-y:disabled { color: var(--text-3); }
.dpi-editor-axes-head {
display: grid;
grid-template-columns: 32px 36px 84px 84px minmax(0, 1fr);
Expand Down Expand Up @@ -326,6 +341,8 @@
.dpi-editor-row { grid-template-columns: 24px 28px 72px minmax(0, 1fr); gap: 8px; padding-inline: 0 8px; }
.dpi-editor.has-stage-axes .dpi-editor-row,
.dpi-editor-axes-head { grid-template-columns: 24px 28px 64px 64px minmax(0, 1fr); gap: 8px; padding-inline: 0 8px; }
.dpi-editor.has-slot-axes .dpi-editor-row,
.dpi-editor.has-slot-axes .dpi-editor-axes-head { grid-template-columns: 24px 28px 64px 24px 64px minmax(0, 1fr); }
.dpi-editor-check { width: 24px; }
.dpi-heading-label { width: 100%; justify-content: space-between; }
.dpi-header-actions { margin-inline-start: 0; width: 100%; justify-content: space-between; }
Expand Down
Loading