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
2 changes: 1 addition & 1 deletion package-lock.json

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

37 changes: 36 additions & 1 deletion src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { teevolutionSensorModeUi } from "@openmouse/protocol/teevolution";
import * as control from "../../device/controller";
import { PULSAR_SLEEP_OPTIONS } from "../../device/controller";
import { isPulsarProProtocol } from "../../device/traits";
import { selectableValues, sleepLabel, sleepParts, sleepTotalSeconds, KEYCHRON_SLEEP_MAX_HOURS, KEYCHRON_SLEEP_MAX_SECONDS, KEYCHRON_SLEEP_MIN_SECONDS } from "../../device/options";
import type { ControlSnapshot } from "../../device/types";
import { Collapsible, Segmented, SwitchRow } from "../ui";
Expand Down Expand Up @@ -271,6 +272,11 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
: status.brand === "Teevolution" ? "Highest performance" : "Performance mode";
const angleSnappingLabel = status.ui?.family === "atk" ? "Straight-line correction" : "Angle snapping";

// WLMouse calls the same sensor setting High-speed mode in its own tool.
const hyperLabel = status.brand === "WLMouse" ? "High-speed mode" : "Hyper mode";
// Pulsar Pro shows the angle with the rest of its Pro-only settings.
const angleTuning = isPulsarProProtocol(status) ? null : status.angleTuning;

return (
<article id="processing-settings" className="setting-card">
<div className="setting-heading compact"><div><p>SENSOR</p><h2>Processing</h2></div></div>
Expand Down Expand Up @@ -329,18 +335,47 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
/>
<SwitchRow
id="hyper-mode-toggle"
label="Hyper mode"
label={hyperLabel}
value={status.hyperMode}
hidden={status.hyperMode == null}
onChange={(next) => control.applyPulsarToggle("hyperMode", next)}
/>
<SwitchRow
id="turbo-mode-toggle"
label="Turbo mode"
value={status.turboMode}
hidden={status.turboMode == null}
disabled={status.hyperMode === false}
onChange={(next) => control.applyPulsarToggle("turboMode", next)}
/>
<SwitchRow
id="button-combination-toggle"
label="Button combinations"
value={status.buttonCombination}
hidden={status.buttonCombination == null}
onChange={(next) => control.applyPulsarToggle("buttonCombination", next)}
/>
<SwitchRow
id="long-range-mode-toggle"
label="Ultra long range"
value={status.longRangeMode}
hidden={status.longRangeMode == null}
onChange={(next) => control.applyPulsarToggle("longRangeMode", next)}
/>
{angleTuning != null ? (
<label className="field-label spaced">
Angle tune
<select
id="angle-tune-select"
value={angleTuning}
onChange={(event) => control.applyAngleTuning(Number(event.currentTarget.value))}
>
{Array.from({ length: 61 }, (_, index) => index - 30).map((angle) => (
<option key={angle} value={angle}>{angle}°</option>
))}
</select>
</label>
) : null}

{traits.teevolution && teevolutionProfile ? (
<label id="teevolution-performance-duration-row" className="field-label spaced">
Expand Down
3 changes: 3 additions & 0 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
|| (status.rippleControl != null && ui?.hideRippleControl !== true)
|| (status.performanceMode != null && !traits.eggFamily && !traits.finalmouse)
|| status.hyperMode != null
|| status.turboMode != null
|| status.buttonCombination != null
|| status.angleTuning != null
|| status.longRangeMode != null
|| status.sensorMode != null || status.performanceDuration != null
);
Expand Down
38 changes: 24 additions & 14 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3108,6 +3108,8 @@ function settingLabel(setting: PulsarToggleSetting): string {
rippleControl: "ripple control",
performanceMode: teevolutionClient() ? "highest performance" : "performance mode",
hyperMode: "Hyper mode",
turboMode: "turbo mode",
buttonCombination: "button combinations",
longRangeMode: "ultra long range",
} as const)[setting];
}
Expand All @@ -3118,6 +3120,8 @@ const PULSAR_TOGGLE_METHOD: Record<PulsarToggleSetting, string> = {
rippleControl: "setRippleControl",
performanceMode: "setPerformanceMode",
hyperMode: "setHyperMode",
turboMode: "setTurboMode",
buttonCombination: "setButtonCombination",
longRangeMode: "setLongRangeMode",
};

Expand All @@ -3137,6 +3141,26 @@ export function applyPulsarToggle(setting: PulsarToggleSetting, enabled: boolean
});
}

/**
* Sensor angle in degrees. Pulsar Pro reaches the same setting through
* `applyProSetting`, which also carries settings only that protocol has.
*/
export function applyAngleTuning(degrees: number): void {
if (!hasActiveClient()) return;
stageChange({
key: "angle-tuning",
label: `Angle tune ${degrees}°`,
command: `Set the sensor angle to ${degrees}°`,
progress: `Setting the sensor angle to ${degrees}°…`,
preview: (status) => {
status.angleTuning = degrees;
},
apply: async () => {
await requireClientMethod("setAngleTuning", "angle tuning").setAngleTuning(degrees);
},
});
}

export function applyPulsarValue(setting: "debounce" | "sleep", value: number): void {
if (!(pulsarClient() ?? dmClient() ?? orbitalClient() ?? razerClient()
?? viperClient() ?? teevolutionClient() ?? vgnClient() ?? keychronNapeClient() ?? wallhackMouseClient())) return;
Expand Down Expand Up @@ -3505,20 +3529,6 @@ export function applyPowerMode(mode: string): void {
});
}

/** Set sensor angle tuning on any driver that exposes `setAngleTuning`. */
export function applyAngleTuning(degrees: number): void {
stageChange({
key: "angle-tuning",
label: `Angle tuning ${degrees}00b0`,
command: "Change the angle tuning",
progress: "Changing angle tuning…",
preview: (status) => { status.angleTuning = degrees; },
apply: async () => {
await requireClientMethod("setAngleTuning", "angle tuning").setAngleTuning(degrees);
},
});
}

/**
* Reassign a physical button on any driver that exposes `setButtonMapping`.
* Named for the device-level map to keep it distinct from `applyButtonMapping`
Expand Down
2 changes: 2 additions & 0 deletions src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type PulsarToggleSetting =
| "rippleControl"
| "performanceMode"
| "hyperMode"
| "turboMode"
| "buttonCombination"
| "longRangeMode";

export interface TeevolutionProfile {
Expand Down
13 changes: 12 additions & 1 deletion src/preview-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,29 @@ const EGG_XM2WE: MouseStatus = {
const WLMOUSE: MouseStatus = {
brand: "WLMouse",
name: "BEAST X",
ui: { family: "wlmouse", hideUnsupportedPollingRates: true, forceShowBattery: true },
ui: {
family: "wlmouse",
hideUnsupportedPollingRates: true,
forceShowBattery: true,
dpiStageEditor: { maxStages: 6, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 50 },
},
batteryPercent: 91,
batteryState: "Charging",
dpi: 800,
dpiY: 800,
dpiStages: [400, 800, 1600, 3200],
activeDpiStage: 1,
supportsSeparateDpiAxes: true,
pollingRateHz: 8000,
supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000],
activeProfile: 1,
liftOffDistance: "Low",
connectionType: "Wireless",
motionSync: true,
hyperMode: true,
turboMode: false,
buttonCombination: true,
angleTuning: -8,
debounceMs: 2,
sleepTimeout: 60,
firmware: ["0.9.2"],
Expand Down
8 changes: 8 additions & 0 deletions src/ui/device-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ test("test-needed and unsupported models are not given new artwork", () => {
assert.equal(deviceImage(null, "Razer Viper 8KHz"), CDN + "unknown-device.png");
});

test("a mouse behind a shared WLMouse receiver resolves by name", () => {
// 0xa882 is the 1K receiver's own id; the model comes from the driver's name.
const receiver = { vendorId: 0x36a7, productId: 0xa882 } as HIDDevice;
assert.equal(deviceImage(receiver, "WLmouse Beast Max"), CDN + "wlmouse-beast-max.png");
assert.equal(deviceImage(receiver, "WLmouse Beast G"), CDN + "wlmouse-beast-g.png");
assert.equal(deviceImage(receiver, "WLmouse Beast Mini"), CDN + "unknown-device.png");
});

test("K-snake X11 wired and dongle share the same artwork", () => {
const wired = { vendorId: 0xa8a4, productId: 0x2255 } as HIDDevice;
const dongle = { vendorId: 0xa8a5, productId: 0x2255 } as HIDDevice;
Expand Down
5 changes: 5 additions & 0 deletions src/ui/device-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ function resolveDeviceImageFilename(device: HIDDevice | null | undefined, displa
if (/\bviper\s*v4\b/i.test(displayName)) return "razer-viper-v4-pro.png";
if (/\bxm2\s*8k\b/i.test(displayName)) return "endgame-gear-xm2-8k.png";
if (/\bxm2w\b/i.test(displayName)) return "endgame-gear-xm2w.png";
// WLMouse receivers are shared across models — the 1K dongle enumerates under
// one product id whatever it is paired with — so the model only arrives in the
// name the driver reads back from the mouse.
if (/\bbeast\s*max\b/i.test(displayName)) return "wlmouse-beast-max.png";
if (/\bbeast\s*g\b/i.test(displayName)) return "wlmouse-beast-g.png";
if (/\bbeast\s*x\s*pro\b/i.test(displayName)) return "unknown-device.png";
if (/\bbeast\s*mini\b/i.test(displayName)) return "unknown-device.png";
if (/\bbeast\s*x\b/i.test(displayName)) return "unknown-device.png";
Expand Down