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
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"preview": "vite preview"
},
"dependencies": {
"@openmouse/protocol": "0.23.0",
"@openmouse/protocol": "0.24.0",
"lucide-react": "^1.43.0",
"preact": "^10.29.8"
},
Expand Down
3 changes: 3 additions & 0 deletions src/app/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Superstrike } from "./Superstrike";
import { DpiCard } from "./cards/DpiCard";
import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards";
import { LightingCard } from "./cards/LightingCard";
import { LunaFuryButtonsCard, LunaFuryWheelCard } from "./cards/LunaFuryCards";
import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards";
import { AtkButtonCard, AtkDongleCard, AtkProfileCard, AtkReceiverCard, AtkSensorCard } from "./cards/AtkCards";
import {
Expand Down Expand Up @@ -580,6 +581,8 @@ export function Workspace({
show(has.lowPower, ["advanced"]) ? <LowPowerCard key="lowpower" snapshot={snapshot} /> : null,
show(has.finalmouse, ["advanced"]) ? <FinalmouseCard key="finalmouse" snapshot={snapshot} /> : null,
show(has.incott, ["advanced"]) ? <IncottCard key="incott" snapshot={snapshot} /> : null,
show(has.lunafuryButtons, ["buttons"]) ? <LunaFuryButtonsCard key="lunafury-buttons" snapshot={snapshot} /> : null,
show(has.lunafuryWheelGuard, ["buttons"]) ? <LunaFuryWheelCard key="lunafury-wheel" snapshot={snapshot} /> : null,
device && show(has.dongleLed, ["advanced"]) ? <DongleLedCard key="dongle-led" snapshot={snapshot} /> : null,
show(has.atkProfile, ["profiles"]) ? <AtkProfileCard key="atk-profile" snapshot={snapshot} /> : null,
device && show(has.atkReceiver, ["advanced"]) ? <AtkReceiverCard key="atk-receiver" snapshot={snapshot} /> : null,
Expand Down
44 changes: 27 additions & 17 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { teevolutionSensorModeUi } from "@openmouse/protocol/teevolution";
import type { KsnakeMacroProfile, KsnakeMacroStep } from "@openmouse/protocol/ksnake";
import { isPulsarProProtocol } from "../../device/traits";
import { isNoirKsnakeStatus } from "../../device/noir.ts";
import { lunafurySettingLabel, lunafuryTrackingVisible } from "../../device/lunafury-labels.ts";
import * as control from "../../device/controller";
import { PULSAR_SLEEP_OPTIONS } from "../../device/controller";
import { selectableValues, sleepLabel, sleepParts, sleepTotalSeconds, valuesWithCurrent, KEYCHRON_SLEEP_MAX_HOURS, KEYCHRON_SLEEP_MAX_SECONDS, KEYCHRON_SLEEP_MIN_SECONDS } from "../../device/options";
Expand Down Expand Up @@ -374,16 +375,29 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
})
: null;

const angleSnappingLabel = status.ui?.family === "atk" ? t(locale, "adv.straightLine") : t(locale, "adv.angleSnapping");
const angleSnappingLabel = lunafurySettingLabel(status, locale, "angleSnapping")
?? (status.ui?.family === "atk" ? t(locale, "adv.straightLine") : t(locale, "adv.angleSnapping"));

// WLMouse calls the same sensor setting High-speed mode in its own tool.
const hyperLabel = status.brand === "WLMouse" ? t(locale, "adv.highSpeed") : t(locale, "adv.hyperMode");
const hyperLabel = lunafurySettingLabel(status, locale, "hyperMode")
?? (status.brand === "WLMouse" ? t(locale, "adv.highSpeed") : t(locale, "adv.hyperMode"));
// Pulsar Pro shows the angle with the rest of its Pro-only settings.
const angleTuning = isPulsarProProtocol(status) ? null : status.angleTuning;
const angleLabel = t(locale, status.brand === "LunaFury" ? "luna.sensorAngle" : "adv.angleTune");

const performanceLabel = status.brand === "CRDRAKO"
const performanceLabel = lunafurySettingLabel(status, locale, "performanceMode") ?? (status.brand === "CRDRAKO"
? t(locale, "adv.competitive")
: status.brand === "Teevolution" ? t(locale, "adv.highestPerf") : t(locale, "adv.perfMode");
: status.brand === "Teevolution" ? t(locale, "adv.highestPerf") : t(locale, "adv.perfMode"));

const hyperModeControl = (
<SwitchRow
id="hyper-mode-toggle"
label={hyperLabel}
value={status.hyperMode}
hidden={status.hyperMode == null}
onChange={(next) => control.applyPulsarToggle("hyperMode", next)}
/>
);

return (
<article id="processing-settings" className="setting-card">
Expand Down Expand Up @@ -430,7 +444,7 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea

<SwitchRow
id="motion-sync-toggle"
label="Motion Sync"
label={lunafurySettingLabel(status, locale, "motionSync") ?? "Motion Sync"}
value={status.motionSync}
hidden={ui?.hideMotionSync === true}
onChange={(next) => control.applyPulsarToggle("motionSync", next)}
Expand All @@ -444,7 +458,7 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
/>
<SwitchRow
id="ripple-control-toggle"
label="Ripple control"
label={lunafurySettingLabel(status, locale, "rippleControl") ?? "Ripple control"}
value={status.rippleControl}
hidden={ui?.hideRippleControl === true || traits.finalmouse}
onChange={(next) => control.applyPulsarToggle("rippleControl", next)}
Expand All @@ -456,21 +470,16 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
locale={locale}
/>
) : null}
{status.brand === "LunaFury" ? hyperModeControl : null}
<SwitchRow
id="performance-mode-toggle"
labelId="performance-mode-label"
label={performanceLabel}
value={status.performanceMode}
hidden={status.performanceMode == null || traits.eggFamily || traits.finalmouse}
hidden={status.performanceMode == null || traits.eggFamily || traits.finalmouse || !lunafuryTrackingVisible(status)}
onChange={(next) => control.applyPulsarToggle("performanceMode", next)}
/>
<SwitchRow
id="hyper-mode-toggle"
label={hyperLabel}
value={status.hyperMode}
hidden={status.hyperMode == null}
onChange={(next) => control.applyPulsarToggle("hyperMode", next)}
/>
{status.brand !== "LunaFury" ? hyperModeControl : null}
<SwitchRow
id="turbo-mode-toggle"
label={t(locale, "adv.turboMode")}
Expand Down Expand Up @@ -513,11 +522,11 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
<small className="setting-note">Precise horizontal movement regardless of mouse grip style. Rotation writes touch calibration and stay locked pending a USB capture — calibrate in ATK HUB for now.</small>
</>
) : capabilities?.angleTuningWritable
? <AngleTuningControl value={angleTuning} label={t(locale, "adv.angleTune")} />
? <AngleTuningControl value={angleTuning} label={angleLabel} disabled={snapshot.settingInProgress} />
: (
<StepperSlider
id="angle-tune-slider"
label={t(locale, "adv.angleTune")}
label={angleLabel}
value={angleTuning}
min={-30}
max={30}
Expand Down Expand Up @@ -583,7 +592,7 @@ function AtkAntiMistouchControl({ milliseconds, busy, locale }: { milliseconds:
);
}

function AngleTuningControl({ value, label }: { value: number; label: string }): ReactNode {
function AngleTuningControl({ value, label, disabled }: { value: number; label: string; disabled?: boolean }): ReactNode {
const apply = (next: number): void => control.applyAngleTuning(Math.max(-30, Math.min(30, next)));

return (
Expand All @@ -597,6 +606,7 @@ function AngleTuningControl({ value, label }: { value: number; label: string }):
scale={["−30°", "0°", "+30°"]}
formatValue={(shown) => `${shown > 0 ? "+" : ""}${shown}°`}
pendingKey="angle-tuning"
disabled={disabled}
onCommit={apply}
/>
);
Expand Down
94 changes: 94 additions & 0 deletions src/app/cards/LunaFuryCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ReactNode } from "react";
import type { LunaFuryButton } from "@openmouse/protocol/lamzu";
import * as control from "../../device/controller";
import type { ControlSnapshot } from "../../device/types";
import { t, type I18nKey } from "../../i18n";
import { Segmented, StepperSlider, SwitchRow } from "../ui";

export function LunaFuryButtonsCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const settings = snapshot.status?.lunafury;
if (!settings) return null;
const locale = snapshot.preferences.locale;
const busy = snapshot.settingInProgress || snapshot.settingsPending;
const staged = snapshot.pending.keys.some((key) => key.startsWith("lunafury-button-") || key === "lunafury-lightning");
const buttons: Array<{ button: LunaFuryButton; value: number | undefined; label: I18nKey; min: number; max: number }> = [
{ button: "left", value: settings.leftDebounceMs, label: "luna.leftDebounce", min: 0, max: 15 },
{ button: "right", value: settings.rightDebounceMs, label: "luna.rightDebounce", min: 0, max: 15 },
{ button: "middle", value: settings.middleDebounceMs, label: "luna.middleDebounce", min: 1, max: 30 },
];
return (
<article id="lunafury-buttons" className={`setting-card${staged ? " is-staged" : ""}`}
data-pending-key="lunafury-lightning lunafury-button-left lunafury-button-right lunafury-button-middle">
<div className="setting-heading compact"><div><p>LUNAFURY</p><h2>{t(locale, "luna.buttons")}</h2></div></div>
{settings.lightningMode != null ? (
<div className="field-label spaced">
<span>{t(locale, "luna.lightning")}</span>
<Segmented
ariaLabel={t(locale, "luna.lightning")}
className="three"
options={[
{ value: 1, label: t(locale, "luna.leftPriority") },
{ value: 0, label: t(locale, "common.off") },
{ value: 2, label: t(locale, "luna.rightPriority") },
]}
value={settings.lightningMode}
disabled={busy}
onChange={(mode) => control.applyLunaFuryLightningMode(mode as 0 | 1 | 2)}
/>
</div>
) : null}
{buttons.map(({ button, value, label, min, max }) => value == null ? null : (
<StepperSlider
key={button}
id={`lunafury-${button}-latency`}
label={t(locale, label)}
value={value}
min={min}
max={max}
step={1}
scale={[`${min} ms`, `${button === "middle" ? 15 : 7} ms`, `${max} ms`]}
formatValue={(shown) => `${shown} ms`}
disabled={busy || (button === "left" && settings.lightningMode === 1) || (button === "right" && settings.lightningMode === 2)}
pendingKey={`lunafury-button-${button}`}
onCommit={(milliseconds) => control.applyLunaFuryButtonDebounce(button, milliseconds)}
/>
))}
</article>
);
}

export function LunaFuryWheelCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const guard = snapshot.status?.lunafury?.wheelGuard;
if (!guard) return null;
const locale = snapshot.preferences.locale;
const busy = snapshot.settingInProgress || snapshot.settingsPending;
const staged = snapshot.pending.keys.includes("lunafury-wheel-guard");
return (
<article id="lunafury-wheel-guard" className={`setting-card${staged ? " is-staged" : ""}`}
data-pending-key="lunafury-wheel-guard">
<div className="setting-heading compact"><div><p>LUNAFURY</p><h2>{t(locale, "luna.wheelGuard")}</h2></div></div>
<SwitchRow
id="lunafury-wheel-guard-toggle"
label={t(locale, "luna.wheelGuard")}
value={guard.enabled}
disabled={busy}
onChange={(enabled) => control.applyLunaFuryWheelGuard({ enabled, windowMs: guard.windowMs || 100 })}
/>
{guard.enabled ? (
<StepperSlider
id="lunafury-wheel-guard-window"
label={t(locale, "luna.wheelGuardWindow")}
value={guard.windowMs}
min={20}
max={200}
step={20}
scale={["20 ms", "100 ms", "200 ms"]}
formatValue={(shown) => `${shown} ms`}
disabled={busy}
pendingKey="lunafury-wheel-guard"
onCommit={(windowMs) => control.applyLunaFuryWheelGuard({ enabled: true, windowMs })}
/>
) : null}
</article>
);
}
54 changes: 54 additions & 0 deletions src/app/cards/availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,41 @@ const CONTROL = {
mappedTo: 0x00c3, diverted: false, remappableTo: [0x0052], remapFlags: 0,
} as unknown as NonNullable<ControlSnapshot["buttons"]>[number];

test("LunaFury exposes only successfully read controls and replaces global debounce", () => {
const has = cardAvailability(snapshot({ status: {
brand: "LunaFury", ui: { family: "lunafury" }, debounceMs: 4, angleTuning: -12,
lunafury: { lightningMode: 0, leftDebounceMs: 0, middleDebounceMs: 1,
wheelGuard: { enabled: false, windowMs: 0 } },
} }));
assert.equal(has.lunafuryButtons, true);
assert.equal(has.lunafuryWheelGuard, true);
assert.equal(has.processing, true);
assert.equal(has.debounce, false);
});

test("unknown LunaFury controls are hidden without losing basic debounce", () => {
const has = cardAvailability(snapshot({ status: {
brand: "LunaFury", ui: { family: "lunafury" }, debounceMs: 4, lunafury: {},
} }));
assert.equal(has.lunafuryButtons, false);
assert.equal(has.lunafuryWheelGuard, false);
assert.equal(has.debounce, true);
const middleOnly = cardAvailability(snapshot({ status: {
brand: "LunaFury", ui: { family: "lunafury" }, debounceMs: 4, lunafury: { middleDebounceMs: 1 },
} }));
assert.equal(middleOnly.lunafuryButtons, true);
assert.equal(middleOnly.debounce, true);
});

test("LunaFury-specific cards are not offered to another CompX brand", () => {
const has = cardAvailability(snapshot({ status: {
brand: "Attack Shark", ui: { family: "attack-shark" },
lunafury: { lightningMode: 1, wheelGuard: { enabled: true, windowMs: 100 } },
} }));
assert.equal(has.lunafuryButtons, false);
assert.equal(has.lunafuryWheelGuard, false);
});

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.
Expand Down Expand Up @@ -500,6 +535,25 @@ test("the G-Wolves XVI generation opens sleep, debounce and processing, but not
assert.equal(cards.signal, false);
});

test("LunaFury exposes CompX advanced controls without a signal card", () => {
const cards = cardAvailability(snapshot({
status: {
brand: "LunaFury",
ui: { family: "lunafury" },
motionSync: true,
angleSnapping: false,
rippleControl: false,
debounceMs: 4,
sleepTimeout: 300,
},
}));
assert.equal(cards.advancedHost, true);
assert.equal(cards.sleep, true);
assert.equal(cards.debounce, true);
assert.equal(cards.processing, true);
assert.equal(cards.signal, false);
});

test("the Incott card follows the fields the device reported, not a brand trait", () => {
// Wireless: both controls are present.
const wireless = cardAvailability(snapshot({
Expand Down
10 changes: 10 additions & 0 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface CardAvailability {
teevolutionDpiLighting: boolean;
finalmouse: boolean;
incott: boolean;
lunafuryButtons: boolean;
lunafuryWheelGuard: boolean;
dongleLed: boolean;
eggFilter: boolean;
eggSpdt: boolean;
Expand Down Expand Up @@ -65,6 +67,8 @@ const NOTHING: CardAvailability = {
teevolutionDpiLighting: false,
finalmouse: false,
incott: false,
lunafuryButtons: false,
lunafuryWheelGuard: false,
dongleLed: false,
eggFilter: false,
eggSpdt: false,
Expand Down Expand Up @@ -154,6 +158,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {

signal: host && traits.signal,
debounce: host && traits.debounce
&& status.lunafury?.leftDebounceMs == null && status.lunafury?.rightDebounceMs == null
&& status.debounceMs !== null && status.debounceMs !== undefined,
sleep: host && (traits.sleep || capabilities?.sleepOptions != null || razerSleep)
&& ui?.hideSleepCard !== true,
Expand All @@ -172,6 +177,11 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
// actually reported.
incott: host
&& (status.incottFireKeyTimes != null || status.incottReceiverLedMode != null),
lunafuryButtons: host && status.brand === "LunaFury" && (
status.lunafury?.lightningMode != null || status.lunafury?.leftDebounceMs != null
|| status.lunafury?.rightDebounceMs != null || status.lunafury?.middleDebounceMs != null
),
lunafuryWheelGuard: host && status.brand === "LunaFury" && status.lunafury?.wheelGuard != null,
// Drivers report a boolean only for receivers that answer the LED command.
dongleLed: typeof status.dongleLedEnabled === "boolean",
eggFilter: eggs,
Expand Down
5 changes: 3 additions & 2 deletions src/app/useBridgeProfileApplier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as control from "../device/controller";
import type { BridgeProfile } from "../bridge";
import { subscribeBridgeStatus } from "../bridge-status-store";
import {
mergeSnapshots,
pickSnapshot,
sanitizeSnapshot,
type GameProfileSnapshot,
Expand Down Expand Up @@ -81,10 +82,10 @@ export function useBridgeProfileApplier(snapshot: ControlSnapshot): void {
const target = profileTarget(profile);
// Capture only fields not already held: when one game's profile gives way
// to another's, the originals from before the first one still win.
const prior = { ...pickSnapshot(status, target), ...priorSettings.current };
const prior = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {});
// Fields the previous profile changed but this one doesn't go back to
// their originals, so the new game starts from the user's own settings.
const toWrite = { ...priorSettings.current, ...target };
const toWrite = mergeSnapshots(priorSettings.current ?? {}, target);

writing.current = true;
void control.flashGameProfileSnapshot(toWrite).then((result) => {
Expand Down
Loading
Loading