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
17 changes: 11 additions & 6 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,6 @@ function Workspace({
? <NinjutsoClickCard key="ninjutso-click" snapshot={snapshot} /> : null,
show(has.lowPower, ["advanced"]) ? <LowPowerCard key="lowpower" snapshot={snapshot} /> : null,
show(has.processing, ["performance"]) ? <ProcessingCard key="processing" snapshot={snapshot} /> : null,
show(has.teevolutionDpiLighting, ["advanced"])
? <TeevolutionDpiLightingCard key="teevo" snapshot={snapshot} /> : null,
show(has.finalmouse, ["advanced"]) ? <FinalmouseCard key="finalmouse" snapshot={snapshot} /> : null,
show(has.eggFilter, ["performance"]) ? <EggFilterCard key="eggfilter" snapshot={snapshot} /> : null,
show(has.eggSpdt, ["buttons"]) ? <EggSpdtCard key="eggspdt" snapshot={snapshot} /> : null,
Expand All @@ -144,9 +142,12 @@ function Workspace({
].filter((node) => node !== null);

const lightingZones = status.lightingZones?.length ? status.lightingZones : status.lighting ? [status.lighting] : [];
const lighting = show(has.lighting, ["lighting"])
? [<LightingCard key="lighting-tab" snapshot={snapshot} variant="tab" zones={lightingZones} />]
: [];
const lighting = [
show(has.lighting, ["lighting"])
? <LightingCard key="lighting-tab" snapshot={snapshot} variant="tab" zones={lightingZones} /> : null,
show(has.teevolutionDpiLighting, ["lighting"])
? <TeevolutionDpiLightingCard key="teevo" snapshot={snapshot} /> : null,
].filter((node) => node !== null);

const showProfiles = show(has.profiles, ["profiles"]);
const showSuperstrike = show(has.superstrike, ["buttons"]);
Expand All @@ -160,6 +161,10 @@ function Workspace({
|| showProfiles || showSuperstrike || showLogitechDetails || showMxMaster || showDiagnostics || showOverview;

const slotsAvailable = snapshot.profile.slotsAvailable;
const stagesAvailable = Boolean(status.ui?.dpiStageEditor)
&& Array.isArray(status.dpiStages)
&& status.dpiStages.length > 0
&& !slotsAvailable;
const showSeparateDpiAxes = snapshot.traits.logitech
&& status.supportsSeparateDpiAxes === true
&& !slotsAvailable;
Expand All @@ -185,7 +190,7 @@ function Workspace({
className={[
"settings-grid device-data",
showSeparateDpiAxes ? "has-logitech-axis-controls" : "",
slotsAvailable ? "has-dpi-slots" : "",
slotsAvailable || stagesAvailable ? "has-dpi-slots" : "",
].filter(Boolean).join(" ")}
data-workspace-host
role="tabpanel"
Expand Down
96 changes: 94 additions & 2 deletions src/app/cards/DpiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,87 @@ function DpiSlots({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
);
}

/** Shared Compx/Keychron-style stages: one DPI value per stage + active highlight. */
function DpiStages({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const editor = status?.ui?.dpiStageEditor;
const stages = status?.dpiStages;
if (!status || !editor || !stages || stages.length === 0) return null;

const countEditable = editor.countEditable === true;
const active = Math.min(status.activeDpiStage ?? 0, stages.length - 1);
const disabled = snapshot.settingsPending;

return (
<div id="dpi-stages">
{countEditable ? (
<>
<div id="dpi-stage-count-header" className="dpi-slot-header">
<span>Stages in use</span>
<div id="dpi-stage-count" className="dpi-slot-count" role="group" aria-label="Number of DPI stages">
{Array.from({ length: editor.maxStages }, (_, step) => {
const value = step + 1;
const on = value === stages.length;
return (
<button
key={value}
type="button"
disabled={disabled}
className={on ? "selected" : ""}
aria-pressed={on}
onClick={() => control.applyDpiStageCount(value)}
>
{value}
</button>
);
})}
</div>
</div>
<div className="dpi-slot-rule" />
</>
) : null}
<div id="dpi-stage-list" className="dpi-slot-list dpi-stage-list">
<div className="dpi-slot-row dpi-slot-head">
<span /><span>DPI</span>
</div>
{stages.map((dpi, index) => {
const isActive = index === active;
return (
<div key={index} className={`dpi-slot-row${isActive ? " is-default" : ""}`}>
<button
type="button"
className="dpi-slot-index"
disabled={disabled}
title={isActive ? "Active stage" : "Make this the active stage"}
aria-pressed={isActive}
onClick={() => control.applyActiveDpiStage(index)}
>
{index + 1}
</button>
<input
type="number"
aria-label={`Stage ${index + 1} DPI`}
min={editor.minDpi}
max={editor.maxDpi}
step={editor.stepDpi}
defaultValue={dpi}
key={`stage-${index}-${dpi}`}
disabled={disabled}
onChange={(event) => control.applyDpiStageValue(index, Number(event.currentTarget.value))}
/>
</div>
);
})}
</div>
<small id="dpi-stage-note" className="setting-note">
{countEditable
? `Presets and Custom edit the highlighted stage. ${editor.minDpi.toLocaleString()}–${editor.maxDpi.toLocaleString()} DPI. The DPI button on the mouse cycles through these stages.`
: `Presets and Custom edit the highlighted stage. ${editor.minDpi.toLocaleString()}–${editor.maxDpi.toLocaleString()} DPI in steps of ${editor.stepDpi}. The DPI button on the mouse cycles through all ${editor.maxStages} stages.`}
</small>
</div>
);
}

function AxisControls({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status!;
const [x, setX] = useState(String(status.dpi));
Expand Down Expand Up @@ -211,8 +292,15 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
const status = snapshot.status;
const deviceStatus = snapshot.deviceStatus;
if (!status || !deviceStatus) return null;
const staged = snapshot.pending.keys.includes("dpi");
const staged = snapshot.pending.keys.includes("dpi")
|| snapshot.pending.keys.includes("dpi-stage-count")
|| snapshot.pending.keys.includes("dpi-active-stage")
|| snapshot.pending.keys.some((key) => key.startsWith("dpi-stage-"));
const slotsAvailable = snapshot.profile.slotsAvailable;
const stagesAvailable = Boolean(status.ui?.dpiStageEditor)
&& Array.isArray(status.dpiStages)
&& status.dpiStages.length > 0
&& !slotsAvailable;
const showSeparateDpiAxes = snapshot.traits.logitech
&& status.supportsSeparateDpiAxes === true
&& !slotsAvailable;
Expand All @@ -225,7 +313,10 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
: `${source.dpi.toLocaleString()} DPI`;

return (
<article className={`setting-card dpi-card${staged ? " is-staged" : ""}`} data-pending-key="dpi">
<article
className={`setting-card dpi-card${staged ? " is-staged" : ""}`}
data-pending-key="dpi dpi-stage-count dpi-active-stage"
>
<div className="setting-heading">
<div>
<p>DPI</p>
Expand Down Expand Up @@ -285,6 +376,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode

{showSeparateDpiAxes ? <AxisControls snapshot={snapshot} /> : null}
{slotsAvailable ? <DpiSlots snapshot={snapshot} /> : null}
{stagesAvailable ? <DpiStages snapshot={snapshot} /> : null}

<div className="setting-action">
<span id="dpi-pending">
Expand Down
4 changes: 4 additions & 0 deletions src/control-devices.css

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

8 changes: 8 additions & 0 deletions src/control-profiles.css
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@
.control-shell .settings-grid.has-dpi-slots { grid-template-rows:minmax(176px,auto) }
.control-shell .settings-grid.has-dpi-slots .dpi-card { grid-row:auto }

/* Compx/Keychron simple stages reuse the slot row chrome with a 2-column grid. */
#dpi-stages { margin-top: .65rem; }
.dpi-stage-list .dpi-slot-row { grid-template-columns:1.7rem minmax(0,1fr) }
.dpi-stage-list .dpi-slot-head { grid-template-columns:1.7rem minmax(0,1fr) }
@media (max-width:640px) {
.dpi-stage-list .dpi-slot-row { grid-template-columns:1.7rem minmax(0,1fr) }
}

/* `.segmented` and friends set display, which outranks the user-agent
`[hidden] { display: none }` rule — so hiding those rows by setting the
hidden property silently did nothing. Make the attribute win everywhere. */
Expand Down
88 changes: 88 additions & 0 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1496,6 +1496,12 @@ export function applyDpiValue(dpi: number): boolean {
preview: (status) => {
status.dpi = dpi;
if (status.dpiY !== undefined) status.dpiY = dpi;
// Stage-aware mice: presets write the active stage; keep the table in sync.
if (status.dpiStages && status.activeDpiStage != null && status.activeDpiStage < status.dpiStages.length) {
status.dpiStages = status.dpiStages.map((value, index) => (
index === status.activeDpiStage ? dpi : value
));
}
},
apply: async () => {
await requireClientMethod("setDpi", "DPI").setDpi(dpi);
Expand All @@ -1504,6 +1510,86 @@ export function applyDpiValue(dpi: number): boolean {
return true;
}

export function applyDpiStageCount(count: number): void {
if (!hasActiveClient()) return;
const editor = latestDeviceStatus?.ui?.dpiStageEditor;
if (!editor || editor.countEditable !== true) return;
if (!Number.isInteger(count) || count < 1 || count > editor.maxStages) return;
if (!("setDpiStageCount" in requireSettingsClient())) return;
stageChange({
key: "dpi-stage-count",
label: `${count} DPI stage${count === 1 ? "" : "s"}`,
command: `Set DPI stage count to ${count}`,
progress: `Setting ${count} DPI stages…`,
preview: (status) => {
const current = status.dpiStages?.slice() ?? [];
if (count <= current.length) status.dpiStages = current.slice(0, count);
else {
const padded = current.slice();
while (padded.length < count) padded.push(padded.at(-1) ?? status.dpi);
status.dpiStages = padded;
}
if ((status.activeDpiStage ?? 0) >= count) {
status.activeDpiStage = count - 1;
status.dpi = status.dpiStages[count - 1] ?? status.dpi;
}
},
apply: async () => {
await requireClientMethod("setDpiStageCount", "DPI stage count").setDpiStageCount(count);
},
});
}

export function applyActiveDpiStage(stage: number): void {
if (!hasActiveClient()) return;
const stages = latestDeviceStatus ? withPendingChanges(latestDeviceStatus).dpiStages : undefined;
if (!stages || !Number.isInteger(stage) || stage < 0 || stage >= stages.length) return;
stageChange({
key: "dpi-active-stage",
label: `DPI stage ${stage + 1}`,
command: `Set active DPI stage to ${stage + 1}`,
progress: `Selecting DPI stage ${stage + 1}…`,
preview: (status) => {
status.activeDpiStage = stage;
status.dpi = status.dpiStages?.[stage] ?? status.dpi;
},
apply: async () => {
await requireClientMethod("setActiveDpiStage", "DPI stage").setActiveDpiStage(stage);
},
});
}

export function applyDpiStageValue(stage: number, rawDpi: number): void {
if (!hasActiveClient()) return;
const stagedStatus = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null;
const stages = stagedStatus?.dpiStages ?? latestDeviceStatus?.dpiStages;
if (!stages || !Number.isInteger(stage) || stage < 0 || stage >= stages.length) return;
const dpi = closestDpiOption(dpiOptions, rawDpi) ?? rawDpi;
if (!dpiOptions.includes(dpi)) {
setReadStatus(`${rawDpi.toLocaleString()} DPI is not supported by this mouse.`);
emit();
return;
}
stageChange({
key: `dpi-stage-${stage}`,
label: `Stage ${stage + 1} · ${dpi.toLocaleString()} DPI`,
command: `Set DPI stage ${stage + 1} to ${dpi.toLocaleString()}`,
progress: `Setting stage ${stage + 1} to ${dpi.toLocaleString()} DPI…`,
preview: (status) => {
// Pad first so a newly added stage (only present via a staged count
// increase) still diffs against the device snapshot in matchesDeviceStatus.
const next = status.dpiStages?.slice() ?? [];
while (next.length <= stage) next.push(next.at(-1) ?? status.dpi);
next[stage] = dpi;
status.dpiStages = next;
if ((status.activeDpiStage ?? 0) === stage) status.dpi = dpi;
},
apply: async () => {
await requireClientMethod("setDpiStageValue", "DPI stage value").setDpiStageValue(stage, dpi);
},
});
}

export function applyLogitechAxisDpi(dpiX: number, dpiY: number): void {
if (!logitechClient()) return;
if (!dpiOptions.includes(dpiX) || !dpiOptions.includes(dpiY)) {
Expand Down Expand Up @@ -2858,6 +2944,8 @@ async function showFixturePreview(name: PreviewMode): Promise<void> {
return;
}
dpiOptions = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 32000];
// Populate brand capabilities so preview cards that gate on capabilities still render.
capabilities = readCapabilities();
applyStatus(fixture.status);
setConnectionButtons(true, "Preview mode");
setReadStatus(`Preview: ${fixture.label}. Nothing is written.`);
Expand Down
24 changes: 23 additions & 1 deletion src/preview-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,10 +387,23 @@ const RAZER_COBRA: MouseStatus = {
const TEEVOLUTION: MouseStatus = {
brand: "Teevolution",
name: "Terra Pro",
ui: { defaultDisplayName: "Terra Pro", hideUnsupportedPollingRates: true, hideSignalCard: true },
ui: {
defaultDisplayName: "Terra Pro",
hideUnsupportedPollingRates: true,
hideSignalCard: true,
dpiStageEditor: {
maxStages: 4,
countEditable: true,
minDpi: 50,
maxDpi: 42000,
stepDpi: 50,
},
},
batteryPercent: 52,
batteryState: "Discharging",
dpi: 1600,
dpiStages: [400, 800, 1600, 3200],
activeDpiStage: 2,
pollingRateHz: 1000,
supportedPollingRates: [125, 250, 500, 1000],
activeProfile: 1,
Expand Down Expand Up @@ -516,10 +529,19 @@ const KEYCHRON: MouseStatus = {
hideProcessingCard: true,
forceShowBattery: true,
pollingNote: "Nape Pro exposes polling through Keychron's misc HID commands when the firmware allows it.",
dpiStageEditor: {
maxStages: 5,
countEditable: false,
minDpi: 50,
maxDpi: 4000,
stepDpi: 50,
},
},
batteryPercent: 76,
batteryState: "Discharging",
dpi: 800,
dpiStages: [400, 800, 1600, 2400, 4000],
activeDpiStage: 1,
pollingRateHz: 1000,
supportedPollingRates: [500, 1000, 2000, 4000, 8000],
activeProfile: null,
Expand Down