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
3 changes: 2 additions & 1 deletion src/app/cards/PerformanceCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@openmouse/protocol/drivers/logitech/onboard-profiles";
import * as control from "../../device/controller";
import { isNoirKsnakeStatus } from "../../device/noir.ts";
import { pollingRateText } from "../../ui/polling-rate";
import { isNativeAttackSharkX11, RATE_STEPS_HZ } from "../../device/controller";
import type { ControlSnapshot, LiftOffLevel } from "../../device/types";
import { t, tp } from "../../i18n";
Expand Down Expand Up @@ -66,7 +67,7 @@ export function PollingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactN
<small id="polling-note" className="setting-note">{note}</small>
</div>
{!perProfile ? (
<output id="polling-value">{status.pollingRateHz.toLocaleString()} Hz</output>
<output id="polling-value">{pollingRateText(status.pollingRateHz)} Hz</output>
) : null}
</div>

Expand Down
15 changes: 4 additions & 11 deletions src/app/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { Check, ChevronDown, Minus, Plus } from "lucide-react";
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import { batteryFillWidth, batteryIconState, batteryLevel } from "../ui/battery-icon";
import { selectedPollingStep } from "../ui/polling-rate";
import { t } from "../i18n";
import type { InterfaceLocale } from "../interface-preferences";

Expand Down Expand Up @@ -387,22 +388,14 @@ export function RateSlider({
bubble?: boolean;
}): ReactNode {
if (options.length === 0) return <div id={id} className="rate-slider" hidden={hidden} />;
const selected = valueHz !== null && options.includes(valueHz)
? options.indexOf(valueHz)
: options.reduce(
(best, rate, step) =>
Math.abs(rate - (valueHz ?? options[0] ?? 0)) < Math.abs((options[best] ?? 0) - (valueHz ?? options[0] ?? 0))
? step
: best,
0,
);
const selected = selectedPollingStep(options, valueHz);

return (
<div id={id} className="rate-slider" hidden={hidden}>
{label ? (
<div className="rate-slider-head">
<span>{label}</span>
<output>{options[selected]?.toLocaleString() ?? "—"} Hz</output>
<output>{selected === null ? "—" : options[selected]?.toLocaleString() ?? "—"} Hz</output>
</div>
) : null}
<div className="rate-slider-buttons" role="group" aria-label={label ?? t(locale, "perf.reportRate")}>
Expand All @@ -423,7 +416,7 @@ export function RateSlider({
);
})}
</div>
{hoursAt?.(options[selected] ?? 0) != null ? (
{selected !== null && hoursAt?.(options[selected] ?? 0) != null ? (
<small className="rate-slider-hours">
About {Math.round(hoursAt?.(options[selected] ?? 0) as number)} h per full charge at {options[selected]?.toLocaleString()} Hz
</small>
Expand Down
5 changes: 3 additions & 2 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBattery, recordBatterySample, type BatteryMode } from "../battery-history";
import { applyBridgeNativeSettings } from "../bridge";
import { pollingRateText } from "../ui/polling-rate";
import {
clientSupportScore,
createSupportedClient,
Expand Down Expand Up @@ -2348,10 +2349,10 @@ function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void {
? st("ctl.connected")
: st("ctl.batteryPct", { n: deviceStatus.batteryPercent });
readStatus = status.ui?.valuesVerified
? [summary, `${deviceStatus.dpi.toLocaleString()} DPI`, `${deviceStatus.pollingRateHz.toLocaleString()} Hz`].join(" · ")
? [summary, `${deviceStatus.dpi.toLocaleString()} DPI`, `${pollingRateText(deviceStatus.pollingRateHz)} Hz`].join(" · ")
: summary;
} else if (!hasPendingChanges()) {
readStatus = st("ctl.currentLine", { dpi: deviceStatus.dpi.toLocaleString(), hz: deviceStatus.pollingRateHz.toLocaleString() });
readStatus = st("ctl.currentLine", { dpi: deviceStatus.dpi.toLocaleString(), hz: pollingRateText(deviceStatus.pollingRateHz) });
}

if (!customDpiEditing) customDpiText = `${status.dpi.toLocaleString()} DPI`;
Expand Down
30 changes: 30 additions & 0 deletions src/preview-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ const PULSAR: MouseStatus = {
firmware: ["1.04.11"],
};

const PULSAR_XS1_8K: MouseStatus = {
...PULSAR,
name: "Pulsar 8K Dongle",
ui: {
family: "pulsar",
hideUnsupportedPollingRates: true,
pollingReadOnly: true,
pollingNote: "The XS-1 polling readout is not hardware-verified. Polling changes are unavailable; check the rate in Pulsar's software.",
hideSleepCard: true,
hideSignalCard: true,
forceShowBattery: true,
},
pollingRateHz: 8000,
supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000],
activeProfile: null,
dongleLedEnabled: undefined,
connectionDetail: "XS-1 feature-report interface",
};

const PULSAR_XS1_UNKNOWN: MouseStatus = {
...PULSAR_XS1_8K,
pollingRateHz: 0,
ui: {
...PULSAR_XS1_8K.ui,
pollingNote: "The XS-1 interface did not return a recognized polling rate. Polling changes are unavailable; check the rate in Pulsar's software.",
},
};

const PULSAR_PRO: MouseStatus = {
...PULSAR,
name: "Pulsar X2 V2 Pro",
Expand Down Expand Up @@ -848,6 +876,8 @@ const RAWM_V4_GT: MouseStatus = {
export const PREVIEW_FIXTURES: Record<FixturePreviewMode, PreviewFixture> = {
pulsar: { label: "Pulsar X2 V2", status: PULSAR },
"pulsar-pro": { label: "Pulsar X2 V2 Pro", status: PULSAR_PRO },
"pulsar-xs1-8k": { label: "Pulsar XS-1 8K dongle", status: PULSAR_XS1_8K },
"pulsar-xs1-unknown": { label: "Pulsar XS-1 unknown polling", status: PULSAR_XS1_UNKNOWN },
"egg-op1": { label: "Endgame Gear OP1 8K", status: EGG_OP1 },
"egg-we": { label: "Endgame Gear OP1we", status: EGG_WE },
"egg-xm2we": { label: "Endgame Gear XM2we", status: EGG_XM2WE },
Expand Down
4 changes: 4 additions & 0 deletions src/preview-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const PREVIEW_KEYS = [
"superstrike",
"pulsar",
"pulsar-pro",
"pulsar-xs1-8k",
"pulsar-xs1-unknown",
"egg-op1",
"egg-we",
"egg-xm2we",
Expand Down Expand Up @@ -55,6 +57,8 @@ export function parsePreviewMode(value: string | null): PreviewMode | null {
case "superstrike": return "superstrike";
case "pulsar": return "pulsar";
case "pulsar-pro": return "pulsar-pro";
case "pulsar-xs1-8k": return "pulsar-xs1-8k";
case "pulsar-xs1-unknown": return "pulsar-xs1-unknown";
case "egg-op1": return "egg-op1";
case "egg-we": return "egg-we";
case "egg-xm2we": return "egg-xm2we";
Expand Down
21 changes: 21 additions & 0 deletions src/ui/polling-rate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { pollingRateText, selectedPollingStep } from "./polling-rate.ts";

const RATES = [125, 250, 500, 1000, 2000, 4000, 8000];

test("unavailable driver rates display a dash and never select the 125 Hz button", () => {
for (const value of [0, -1, NaN, Infinity]) {
assert.equal(pollingRateText(value), "—");
assert.equal(selectedPollingStep(RATES, value), null);
}
assert.equal(selectedPollingStep(RATES, null), null);
});

test("8K polling can select its real step and known off-grid rates keep nearest-step behavior", () => {
assert.equal(selectedPollingStep(RATES, 8000), 6);
assert.equal(selectedPollingStep(RATES, 1000), 3);
assert.equal(selectedPollingStep(RATES, 750), 2);
assert.equal(selectedPollingStep([], 1000), null);
assert.notEqual(pollingRateText(8000), "—");
});
13 changes: 13 additions & 0 deletions src/ui/polling-rate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Zero/non-finite rates represent a missing hardware read, not a setting. */
export function pollingRateText(rate: number): string {
return Number.isFinite(rate) && rate > 0 ? rate.toLocaleString() : "—";
}

/** Preserve nearest-step selection for known rates; leave unknown ones blank. */
export function selectedPollingStep(options: readonly number[], rate: number | null): number | null {
if (options.length === 0 || rate === null || !Number.isFinite(rate) || rate <= 0) return null;
const exact = options.indexOf(rate);
if (exact >= 0) return exact;
return options.reduce((best, candidate, index) =>
Math.abs(candidate - rate) < Math.abs(options[best]! - rate) ? index : best, 0);
}
Loading