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
4 changes: 3 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { BatteryIcon } from "./ui";
import { DpiCard } from "./cards/DpiCard";
import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards";
import { LightingCard } from "./cards/LightingCard";
import { MxMasterCards } from "./cards/MxMasterCards";
import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards";
import {
DebounceCard,
EggButtonCard,
Expand Down Expand Up @@ -140,6 +140,8 @@ function Workspace({
show(has.eggPolling, ["performance"]) ? <EggPollingCard key="eggpolling" snapshot={snapshot} /> : null,
show(has.eggCpi, ["performance"]) ? <EggCpiCard key="eggcpi" snapshot={snapshot} /> : null,
show(has.eggButtons, ["buttons"]) ? <EggButtonCard key="eggbuttons" snapshot={snapshot} /> : null,
show(has.mxMasterButtons, ["buttons"])
? <MxMasterButtonsCard key="mxmaster-buttons" snapshot={snapshot} /> : null,
show(has.pulsarPro, ["profiles"]) ? <PulsarProCard key="pulsarpro" snapshot={snapshot} /> : null,
].filter((node) => node !== null);

Expand Down
87 changes: 87 additions & 0 deletions src/app/cards/MxMasterCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,93 @@
);
}

/**
* Reprogrammable controls.
*
* Only the controls the device says are reprogrammable get a dropdown, and the
* targets in it come from the device's own group mask. Left and right click
* report an empty mask, so the firmware — not this card — is what keeps the
* primary buttons where they are; they are still listed with a note, so the
* absence of a dropdown reads as a restriction rather than a missing button.
*
* Virtual controls are left out entirely. A device reports them (the MX Master
* 4's "virtual gesture button" is the event its gesture button emits when held
* and dragged), but they are not something anyone can press, so a row for one
* is noise rather than a restriction worth showing.
*/
export function MxMasterButtonsCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const all = snapshot.buttons;
if (!all || all.length === 0) return null;
const controls = all.filter((button) => !button.virtual);
if (controls.length === 0) return null;
const diverted = controls.filter((button) => button.diverted);
const firmwareLocked = controls.filter((button) => !button.reprogrammable);
const busy = snapshot.settingInProgress;
const nameOf = (controlId: number): string =>
all.find((candidate) => candidate.controlId === controlId)?.name
?? `Control 0x${controlId.toString(16).padStart(4, "0").toUpperCase()}`;
const anyStaged = snapshot.pending.keys.some((key) => key.startsWith("button-"));
return (
<article className={`setting-card${anyStaged ? " is-staged" : ""}`}>
<div className="setting-heading compact">
<div><p>BUTTONS</p><h2>Remapping</h2></div>
<output>{controls.length}</output>
</div>
<div className="button-remap-list">
{controls.map((button) => {
const staged = snapshot.pending.keys.includes(`button-${button.controlId}`);
const canRemap = button.reprogrammable && button.remappableTo.length > 0;
return (
<label
key={button.controlId}
className={`button-remap-row${staged ? " is-staged" : ""}`}
data-pending-key={`button-${button.controlId}`}
>
<span>{button.name}</span>
{canRemap ? (
<select
value={snapshot.stagedButtonMappings[button.controlId] ?? button.mappedTo}
disabled={busy}
onChange={(event) => control.applyButtonMapping(
button.controlId,
Number(event.currentTarget.value),
)}
>
{button.remappableTo.map((target) => (

Check failure on line 170 in src/app/cards/MxMasterCards.tsx

View workflow job for this annotation

GitHub Actions / check

Parameter 'target' implicitly has an 'any' type.
<option key={target} value={target}>{nameOf(target)}</option>
))}
</select>
) : (
<output>{button.taskName}</output>
)}
</label>
);
})}
</div>
{firmwareLocked.length > 0 ? (
<small className="setting-note">
{firmwareLocked.map((button) => button.name).join(" and ")}
{firmwareLocked.length === 1 ? " is" : " are"} locked by the mouse's firmware and cannot be
remapped.
</small>
) : null}
{diverted.length > 0 ? (
<div className="button-remap-diverted">
<p>
{diverted.length === 1
? `${diverted[0]!.name} is being handled by another application, so it does nothing here.`
: `${diverted.map((button) => button.name).join(", ")} are being handled by another `
+ "application, so they do nothing here."}
</p>
<button type="button" disabled={busy} onClick={() => void control.restoreDivertedButtons()}>
Restore to hardware control
</button>
</div>
) : null}
</article>
);
}

export function MxMasterCards({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
return <><HapticsCard snapshot={snapshot} /><WheelCard snapshot={snapshot} /><DeviceNameCard snapshot={snapshot} /><EasySwitchCard snapshot={snapshot} /></>;
}
22 changes: 22 additions & 0 deletions src/app/cards/availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,39 @@ function snapshot(overrides: {
capabilities?: Partial<DeviceCapabilities>;
settingsPending?: boolean;
showExperimental?: boolean;
buttons?: ControlSnapshot["buttons"];
}): ControlSnapshot {
const status = overrides.status === null ? null : { ...STATUS, ...overrides.status };
return {
status,
traits: traitsFor(status),
capabilities: overrides.capabilities ?? null,
settingsPending: overrides.settingsPending ?? false,
buttons: overrides.buttons ?? null,
preferences: { showExperimental: overrides.showExperimental ?? true },
} as unknown as ControlSnapshot;
}

const CONTROL = {
controlId: 0x00c3, taskId: 0x009c, flags: 0x31, group: 2, groupMask: 3,
name: "Gesture button", taskName: "Gesture button", reprogrammable: true,
mappedTo: 0x00c3, diverted: false, remappableTo: [0x0052], remapFlags: 0,
} as unknown as NonNullable<ControlSnapshot["buttons"]>[number];

test("the button card appears only when the mouse reports controls", () => {
// The driver answers with an empty list on a mouse without 0x1B04, so this
// must key on the controls themselves rather than on the brand.
assert.equal(cardAvailability(snapshot({})).mxMasterButtons, false);
assert.equal(cardAvailability(snapshot({ buttons: [] })).mxMasterButtons, false);
assert.equal(cardAvailability(snapshot({ buttons: [CONTROL] })).mxMasterButtons, true);
});

test("a non-Logitech mouse never gets the button card", () => {
assert.equal(cardAvailability(snapshot({
status: { brand: "Pulsar" }, buttons: [CONTROL],
})).mxMasterButtons, false);
});

test("no device offers no cards at all", () => {
const has = cardAvailability(snapshot({ status: null }));
assert.equal(Object.values(has).every((value) => value === false), true);
Expand Down
8 changes: 8 additions & 0 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface CardAvailability {
eggPolling: boolean;
eggCpi: boolean;
eggButtons: boolean;
mxMasterButtons: boolean;
pulsarPro: boolean;
profiles: boolean;
logitechDetails: boolean;
Expand Down Expand Up @@ -52,6 +53,7 @@ const NOTHING: CardAvailability = {
eggPolling: false,
eggCpi: false,
eggButtons: false,
mxMasterButtons: false,
pulsarPro: false,
profiles: false,
logitechDetails: false,
Expand Down Expand Up @@ -118,6 +120,12 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
eggCpi: eggs,
eggButtons: eggs
&& status.eggMulticlickFilters !== undefined && status.eggButtonMappings !== undefined,
// The driver reports an empty list for a mouse without 0x1B04, which the
// controller stores as null — so this is "the device has controls", not
// "the device is an MX Master".
// Not gated on `host`: Logitech opts out of the shared advanced section,
// and this card lives in the buttons tab regardless.
mxMasterButtons: traits.logitech && (snapshot.buttons?.length ?? 0) > 0,
pulsarPro: host && isPulsarProProtocol(status),
};
}
10 changes: 10 additions & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -1169,3 +1169,13 @@ body { height: 100vh; overflow: hidden; }
.easy-switch-confirm p { margin: 0 0 .5rem; color: #e2c489; font-size: .66rem; line-height: 1.45; }
.easy-switch-confirm-actions { display: flex; gap: .4rem; }
.easy-switch-confirm-actions button { padding: .32rem .6rem; border: 1px solid #7a6234; border-radius: 6px; background: #2e2718; color: #f0d9a4; font-size: .64rem; }

.button-remap-list { display: flex; flex-direction: column; gap: .3rem; }
.button-remap-row { display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding: .3rem .45rem; border: 1px solid transparent; border-radius: 6px; color: var(--faint); font-size: .66rem; }
.button-remap-row select { max-width: 11rem; }
/* A control the firmware will not let anything point at reads as a fact about
the mouse, not a disabled input the user should try to enable. */
.button-remap-row output { color: var(--dim); font-size: .64rem; }
.button-remap-diverted { margin-top: .6rem; padding: .55rem .65rem; border: 1px solid #5c4a2a; border-radius: 7px; background: #241f16; }
.button-remap-diverted p { margin: 0 0 .5rem; color: #e2c489; font-size: .66rem; line-height: 1.45; }
.button-remap-diverted button { padding: .32rem .6rem; border: 1px solid #7a6234; border-radius: 6px; background: #2e2718; color: #f0d9a4; font-size: .64rem; }
106 changes: 106 additions & 0 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@
LOGITECH_HAPTIC_EFFECTS,
LOGITECH_HAPTIC_PRESETS,
LOGITECH_SMART_SHIFT_OFF,
logitechControlName,

Check failure on line 82 in src/device/controller.ts

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/logitech"' has no exported member 'logitechControlName'.
type LogitechHapticPreset,
type LogitechReprogrammableControl,

Check failure on line 84 in src/device/controller.ts

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/logitech"' has no exported member 'LogitechReprogrammableControl'.
} from "@openmouse/protocol/logitech";
import { PulsarProHidClient } from "@openmouse/protocol/drivers/pulsar/pulsar-pro-hid";
import { OrbitalHidClient } from "@openmouse/protocol/drivers/orbital/hid";
Expand Down Expand Up @@ -172,6 +174,7 @@
: null;

let onboardProfiles: OnboardProfile[] | null = null;
let buttons: LogitechReprogrammableControl[] | null = null;
let onboardProfilesLoading = false;
let lastDeviceMode: MouseStatus["deviceMode"] = "Unknown";
let lastProfileFormat: MouseStatus["onboardProfileFormat"] = null;
Expand Down Expand Up @@ -327,6 +330,7 @@
customDpiEditing,
customDpiText,
onboardProfiles,
buttons,
editedProfile,
profilesExpanded,
deviceMode: lastDeviceMode,
Expand All @@ -336,6 +340,7 @@
stagedBunnyHopMs,
stagedProfileRates,
stagedProfileName,
stagedButtonMappings: Object.fromEntries(stagedButtonMappings),
analogTuning,
eggPollingDivider,
pending: {
Expand Down Expand Up @@ -623,6 +628,99 @@
}
}

/**
* Reads the reprogrammable controls, if the mouse has any.
*
* Two round-trips per control puts this well outside what the refresh poll can
* afford, so it runs on connect and after a write. A mouse without 0x1B04
* answers with an empty list and the card stays hidden.
*/
async function readButtons(): Promise<void> {
const client = logitechClient();
if (!client) {
buttons = null;
return;
}
try {
const controls = await client.readButtons();

Check failure on line 645 in src/device/controller.ts

View workflow job for this annotation

GitHub Actions / check

Property 'readButtons' does not exist on type 'LogitechHidppClient'.
buttons = controls.length > 0 ? controls : null;
} catch {
// A mouse that will not answer keeps the card hidden rather than showing
// an empty one; the next connect tries again.
buttons = null;
}
}

const BUTTON_GROUP = "logitech-buttons";
const stagedButtonMappings = new Map<number, number>();

/**
* Stages a remap.
*
* There is no preview: the controls are not part of MouseStatus, so the card
* renders the staged mapping itself rather than mirroring it onto a status
* snapshot. Every staged remap shares one group and is written together, in
* the order it was staged.
*/
export function applyButtonMapping(controlId: number, targetControlId: number): void {
if (!logitechClient()) return;
const control = buttons?.find((candidate) => candidate.controlId === controlId);
if (!control) return;
if (control.mappedTo === targetControlId) {
stagedButtonMappings.delete(controlId);
dropPendingChange(`button-${controlId}`);
emit();
return;
}
stagedButtonMappings.set(controlId, targetControlId);
const target = logitechControlName(targetControlId);
stageChange({
key: `button-${controlId}`,
group: BUTTON_GROUP,
label: `${control.name} → ${target}`,
command: `Remap ${control.name} (0x${controlId.toString(16).padStart(4, "0")}) to ${target}`,
progress: `Remapping ${control.name} to ${target}…`,
apply: writeStagedButtonMappings,
});
}

async function writeStagedButtonMappings(): Promise<void> {
const client = logitechClient();
if (!client) return;
try {
for (const [controlId, targetControlId] of stagedButtonMappings) {
buttons = await client.setButtonMapping(controlId, targetControlId);

Check failure on line 692 in src/device/controller.ts

View workflow job for this annotation

GitHub Actions / check

Property 'setButtonMapping' does not exist on type 'LogitechHidppClient'.
}
} finally {
stagedButtonMappings.clear();
}
}

/**
* Hands diverted buttons back to the hardware.
*
* Immediate rather than staged, unlike a remap. A diversion is state another
* application left in the mouse, not a preference this user expressed, and the
* button does nothing at all until it is cleared — staging a repair behind a
* flash step would leave a dead button dead for no reason.
*/
export async function restoreDivertedButtons(): Promise<void> {
const client = logitechClient();
if (!client || settingInProgress) return;
settingInProgress = true;
setReadStatus("Restoring buttons to hardware control…");
emit();
try {
buttons = await client.clearButtonDiversion();

Check failure on line 714 in src/device/controller.ts

View workflow job for this annotation

GitHub Actions / check

Property 'clearButtonDiversion' does not exist on type 'LogitechHidppClient'.
setReadStatus("Buttons restored to hardware control.");
} catch (error) {
setReadStatus(error instanceof Error ? error.message : "Unable to restore the buttons.");
} finally {
settingInProgress = false;
emit();
}
}

async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise<void> {
if (interfacePreferences.reducedMotion) return;
await wait(milliseconds);
Expand Down Expand Up @@ -1145,6 +1243,7 @@
latestDeviceStatus = null;
clearActiveClients();
if (activeDevice !== client.device) onboardProfiles = null;
buttons = null;
activeDevice = client.device;
recordDiagnosticCommand("Read device status");
lastRenderedStatusKey = null;
Expand All @@ -1163,6 +1262,7 @@
deviceStatuses.set(client.device, status);
capabilities = readCapabilities();
applyStatus(status);
await readButtons();
if (dm) {
await dm.startNotifications(() => {
void refreshStatus();
Expand Down Expand Up @@ -2763,6 +2863,12 @@
if (!isPendingChange(BUNNY_HOP_KEY)) stagedBunnyHopMs = null;
if (!isPendingChange(PROFILE_RATE_KEY)) stagedProfileRates = { wireless: null, wired: null };
if (!isPendingChange(PROFILE_NAME_KEY)) stagedProfileName = null;
// A remap dropped from the pending bar (reverted, or the group flashed)
// must leave the side map, or its select stays on the staged value and a
// later flash would write a mapping the user took back.
for (const controlId of stagedButtonMappings.keys()) {
if (!isPendingChange(`button-${controlId}`)) stagedButtonMappings.delete(controlId);
}
if (!isPendingChange(DPI_SLOTS_KEY)) {
syncDpiSlotPlan();
}
Expand Down
9 changes: 9 additions & 0 deletions src/device/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import type { LogitechReprogrammableControl } from "@openmouse/protocol/logitech";

Check failure on line 2 in src/device/types.ts

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/logitech"' has no exported member 'LogitechReprogrammableControl'.
import type { DpiStageCapabilities, DpiStagePlan, OnboardProfile } from "@openmouse/protocol/drivers/logitech/onboard-profiles";
import type { InterfacePreferences } from "../interface-preferences";
import type { PreviewMode } from "../preview-modes";
Expand Down Expand Up @@ -124,6 +125,12 @@
customDpiText: string;

onboardProfiles: OnboardProfile[] | null;
/**
* Reprogrammable controls, or null on a mouse that has none. Two round-trips
* per control is too much for the refresh poll, so this is read on connect
* and after a write rather than alongside the status.
*/
buttons: LogitechReprogrammableControl[] | null;
editedProfile: number | "host" | null;
profilesExpanded: boolean;
deviceMode: MouseStatus["deviceMode"];
Expand All @@ -134,6 +141,8 @@
stagedBunnyHopMs: number | null;
stagedProfileRates: { wireless: number | null; wired: number | null };
stagedProfileName: string | null;
/** Control id → staged remap target, for controls with an unflashed remap. */
stagedButtonMappings: Record<number, number>;
analogTuning: AnalogTuningState;
eggPollingDivider: number | null;

Expand Down
Loading