diff --git a/build/lunafury-options.test.ts b/build/lunafury-options.test.ts new file mode 100644 index 00000000..838d1408 --- /dev/null +++ b/build/lunafury-options.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; + +let controller: any; +const timers = new Set>(); + +after(() => { for (const timer of timers) clearTimeout(timer); }); + +before(async () => { + const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; + (globalThis as any).localStorage = storage; + (globalThis as any).window = { localStorage: storage, location: { search: "" }, + matchMedia: () => ({ matches: false }), clearTimeout, + setTimeout: (callback: () => void, delay?: number) => { + const timer = setTimeout(callback, delay); timers.add(timer); return timer; + } }; + (globalThis as any).document = { title: "OpenMouse test" }; + const entry = fileURLToPath(new URL("../src/device/controller.ts", import.meta.url)); + const result: any = await build({ + configFile: false, logLevel: "silent", + define: { __BUILD_CHANNEL__: '"stable"', __APP_VERSION__: '"test"', __BUILD_ID__: '"test"' }, + plugins: [{ name: "lunafury-options-test", enforce: "pre", + transform(source, id) { + if (id.replaceAll("\\", "/") !== entry.replaceAll("\\", "/")) return null; + // Stub UI notifications; keep the real staging, validation and dispatch. + return source.replace("function emit(): void {", "function emit(): void { return;") + ` + export const testWrites = []; + export const testBankWrites = []; + let testProfile, testBanks, testReader; + export function testSetup(settings, brand = "LunaFury", draft = false) { + clearPendingChanges(); testWrites.length = 0; testBankWrites.length = 0; toasts.length = 0; + testProfile = 1; testReader = null; + testBanks = new Map([1, 2, 3].map(profile => [profile, structuredClone(settings)])); + latestDeviceStatus = { brand, name: "Test mouse", dpi: 800, pollingRateHz: 1000, + batteryPercent: null, batteryState: "Unknown", firmware: [], + activeProfile: 1, profileCount: 3, lunafury: structuredClone(settings) }; + matchesBaseline = null; settingInProgress = false; + dpiOptions = [800, 1600]; + interfacePreferences = { ...DEFAULT_INTERFACE_PREFERENCES, locale: "en", + instantFlash: false, reducedMotion: true }; + gameProfileDraft = draft ? { touched: new Set() } : null; + const write = (kind, field, value) => { + testWrites.push([kind, value]); testBankWrites.push([kind, testProfile, value]); + testBanks.get(testProfile)[field] = value; + }; + active = { + setLunaFuryButtonPollingRate: async value => write("rate", "buttonPollingRateHz", value), + setLunaFuryReceiverLightMode: async value => { + testWrites.push(["light", value]); + for (const bank of testBanks.values()) bank.receiverLightMode = value; + }, + setLunaFuryBottomButtonMode: async value => write("bottom", "bottomButtonMode", value), + setProfile: async profile => { testProfile = profile; testWrites.push(["profile", profile]); }, + readStatus: async () => { + const status = { ...latestDeviceStatus, activeProfile: testProfile, + ui: { ...latestDeviceStatus.ui, settingsReady: true }, + lunafury: structuredClone(testBanks.get(testProfile)) }; + return testReader ? await testReader(status) : status; + }, + }; + } + export function testPending() { return pendingChanges(); } + export function testPreview() { return withPendingChanges(latestDeviceStatus); } + export function testWarnings() { return toasts.filter(toast => toast.kind === "warning"); } + export function testBank(profile, settings) { testBanks.set(profile, structuredClone(settings)); } + export function testReadWith(reader) { testReader = reader; } + `; + }, + }], + build: { write: false, minify: false, lib: { entry, formats: ["es"] }, + rollupOptions: { output: { inlineDynamicImports: true } } }, + }); + const outputs = (Array.isArray(result) ? result : [result]).flatMap(item => item.output); + const code = outputs.find((item: any) => item.type === "chunk").code; + controller = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`); +}); + +const SETTINGS = { buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000, 2000, 4000, 8000], + receiverLightMode: 6, bottomButtonMode: 2 }; + +test("LunaFury controls stage independently and dispatch through the published driver API", async () => { + controller.testSetup(structuredClone(SETTINGS)); + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryReceiverLightMode(0); + controller.applyLunaFuryBottomButtonMode(1); + assert.deepEqual(controller.testWrites, []); + const preview = controller.testPreview(); + assert.equal(preview.pollingRateHz, 1000); // USB polling stays independent. + assert.equal(preview.lunafury.buttonPollingRateHz, 4000); + assert.equal(preview.lunafury.receiverLightMode, 0); + assert.equal(preview.lunafury.bottomButtonMode, 1); + for (const pending of controller.testPending()) await pending.apply(); + assert.deepEqual(controller.testWrites, [["rate", 4000], ["light", 0], ["bottom", 1]]); +}); + +test("unread controls, other brands and invalid values cannot stage writes", () => { + for (const [settings, brand] of [[{}, "LunaFury"], [SETTINGS, "Lamzu"]] as const) { + controller.testSetup(settings, brand); + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryReceiverLightMode(0); + controller.applyLunaFuryBottomButtonMode(1); + assert.deepEqual(controller.testPending(), []); + } + controller.testSetup(SETTINGS); + controller.applyLunaFuryButtonPollingRate(125); + controller.applyLunaFuryReceiverLightMode(3); + controller.applyLunaFuryBottomButtonMode(0); + assert.deepEqual(controller.testPending(), []); + controller.testSetup({ ...SETTINGS, supportedButtonPollingRates: [1000] }); + controller.applyLunaFuryButtonPollingRate(8000); + assert.deepEqual(controller.testPending(), []); +}); + +test("hardware-only controls stay out of Games and bank edits wait for profile selection", () => { + controller.testSetup(SETTINGS, "LunaFury", true); + controller.applyLunaFuryReceiverLightMode(0); + controller.applyLunaFuryBottomButtonMode(1); + assert.deepEqual(controller.testPending(), []); + controller.testSetup(SETTINGS); + controller.applyProfileSelection(2); + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryBottomButtonMode(1); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]); +}); + +test("LunaFury profile switching rejects staged bank edits in reverse order", () => { + for (const stage of [ + () => controller.applyLunaFuryButtonPollingRate(4000), + () => controller.applyLunaFuryBottomButtonMode(1), + () => controller.applyDpiValue(1600), + ]) { + controller.testSetup(SETTINGS); + stage(); + const keys = controller.testPending().map((pending: any) => pending.key); + assert.equal(keys.length, 1); + controller.applyProfileSelection(2); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), keys); + assert.equal(controller.testPreview().activeProfile, 1); + assert.equal(controller.testWarnings().length, 1); + assert.ok(controller.testWarnings()[0].title.length > 0); + assert.deepEqual(controller.testWrites, []); + controller.revertPendingChanges(); + controller.applyProfileSelection(2); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]); + } +}); + +test("profile selection can be revised and preserves independent receiver edits", () => { + controller.testSetup(SETTINGS); + controller.applyProfileSelection(2); + controller.applyProfileSelection(3); + assert.equal(controller.testPreview().activeProfile, 3); + assert.equal(controller.testPending().length, 1); + controller.applyProfileSelection(1); + assert.deepEqual(controller.testPending(), []); + controller.applyLunaFuryReceiverLightMode(0); + controller.applyProfileSelection(2); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), + ["lunafury-receiver-light", "onboard-profile"]); + controller.applyLunaFuryReceiverLightMode(11); + assert.equal(controller.testPending().length, 2); + + controller.testSetup(SETTINGS, "Lamzu"); + controller.applyDpiValue(1600); + controller.applyProfileSelection(2); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["dpi", "onboard-profile"]); +}); + +test("LunaFury edits wait for profile read-back and then target the new bank", async () => { + controller.testSetup(SETTINGS); + controller.testBank(2, { ...SETTINGS, buttonPollingRateHz: 2000, bottomButtonMode: 1 }); + let started!: () => void; + const reading = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + controller.testReadWith(async (status: unknown) => { started(); await gate; return status; }); + controller.applyProfileSelection(2); + const applying = controller.flashPendingChanges(); + await reading; + try { + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryBottomButtonMode(1); + controller.applyDpiValue(1600); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]); + assert.deepEqual(controller.testBankWrites, []); + } finally { + release(); + await applying; + } + assert.equal(controller.testPreview().activeProfile, 2); + assert.equal(controller.testPreview().lunafury.buttonPollingRateHz, 2000); + assert.deepEqual(controller.testPending(), []); + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryBottomButtonMode(2); + await controller.flashPendingChanges(); + assert.deepEqual(controller.testBankWrites, [["rate", 2, 4000], ["bottom", 2, 2]]); +}); + +test("failed LunaFury profile read-back keeps bank edits blocked until a successful retry", async () => { + for (const revert of [false, true]) { + controller.testSetup(SETTINGS); + controller.testReadWith(() => { throw new Error("Profile read-back failed"); }); + controller.applyProfileSelection(2); + await controller.flashPendingChanges(); + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryBottomButtonMode(1); + assert.deepEqual(controller.testPending().map((pending: any) => pending.key), ["onboard-profile"]); + assert.deepEqual(controller.testBankWrites, []); + if (revert) { + controller.revertPendingChanges(); + controller.applyLunaFuryButtonPollingRate(4000); + controller.applyLunaFuryBottomButtonMode(1); + controller.applyDpiValue(1600); + assert.deepEqual(controller.testPending(), []); + controller.applyProfileSelection(2); + } + controller.testReadWith(null); + await controller.flashPendingChanges(); + assert.deepEqual(controller.testPending(), []); + assert.equal(controller.testPreview().activeProfile, 2); + controller.applyLunaFuryBottomButtonMode(1); + await controller.flashPendingChanges(); + assert.deepEqual(controller.testBankWrites, [["bottom", 2, 1]]); + } +}); diff --git a/src/device/controller.ts b/src/device/controller.ts index 7ce0bbf8..79d4e232 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1402,6 +1402,22 @@ function stageChange(change: PendingChange): void { setReadStatus(st("ctl.waitFlash")); return; } + if (latestDeviceStatus?.brand === "LunaFury" && change.key !== "lunafury-receiver-light") { + const profileSwitch = change.key === "onboard-profile"; + if (!profileSwitch && latestDeviceStatus.ui?.settingsReady === false) { + setReadStatus(st("ctl.waitingRefresh")); + return; + } + // Receiver lighting is independent of the bank; other edits use the last read profile. + const otherEdits = profileSwitch && pendingChanges().some((pending) => + pending.key !== "onboard-profile" && pending.key !== "lunafury-receiver-light"); + if (otherEdits || (!profileSwitch && isPendingChange("onboard-profile"))) { + const message = st("ctl.atkPending"); + setReadStatus(message); + pushToast("warning", message); + return; + } + } if (gameProfileDraft) { const fields = gameProfileFieldsOf(change); if (!fields) { @@ -1841,6 +1857,7 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void { else applyAngleTuning(s.angleTuning); } stageLunaFuryProfile(s.lunafury, status.brand === "LunaFury" ? status.lunafury : undefined, { + buttonRate: applyLunaFuryButtonPollingRate, lightning: applyLunaFuryLightningMode, button: applyLunaFuryButtonDebounce, wheel: applyLunaFuryWheelGuard, @@ -6194,7 +6211,14 @@ export function applyProfileSelection(profile: number): void { progress: st("ctl.progProfile"), preview: (status) => { status.activeProfile = profile; }, apply: async () => { - await requireClientMethod("setProfile", "the active profile").setProfile(profile); + const client = requireClientMethod("setProfile", "the active profile"); + const refreshProfile = latestDeviceStatus?.brand === "LunaFury"; + if (refreshProfile && latestDeviceStatus) { + applyStatus({ ...latestDeviceStatus, ui: { ...latestDeviceStatus.ui, settingsReady: false } }); + } + await client.setProfile(profile); + // Keep the switch pending, and stale controls locked, until its bank is read back. + if (refreshProfile) applyStatus(await statusAfterWrite(client)); }, }); } @@ -6569,6 +6593,48 @@ export function applyIncottFireKey(times: number, intervalMs: number): void { }); } +export function applyLunaFuryButtonPollingRate(hertz: 1000 | 2000 | 4000 | 8000): void { + const settings = latestDeviceStatus?.lunafury; + if (latestDeviceStatus?.brand !== "LunaFury" || settings?.buttonPollingRateHz == null + || !settings.supportedButtonPollingRates?.includes(hertz)) return; + stageChange({ + key: "lunafury-button-rate", + label: `${st("luna.buttonPollingRate")} · ${hertz} Hz`, + command: `Set button polling rate to ${hertz} Hz`, + progress: `Setting button polling rate to ${hertz} Hz…`, + preview: (status) => { if (status.lunafury) status.lunafury.buttonPollingRateHz = hertz; }, + apply: () => callClientMethod("setLunaFuryButtonPollingRate", "button polling rate", hertz), + }); +} + +export function applyLunaFuryBottomButtonMode(mode: 1 | 2): void { + if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury" + || latestDeviceStatus.lunafury?.bottomButtonMode == null || (mode !== 1 && mode !== 2)) return; + const label = st(mode === 1 ? "luna.bottomPower" : "luna.bottomPowerDpi"); + stageChange({ + key: "lunafury-bottom-button", + label: `${st("luna.bottomButton")} · ${label}`, + command: `Set bottom-button mode to ${mode}`, + progress: "Setting bottom-button mode…", + preview: (status) => { if (status.lunafury) status.lunafury.bottomButtonMode = mode; }, + apply: () => callClientMethod("setLunaFuryBottomButtonMode", "bottom-button behavior", mode), + }); +} + +export function applyLunaFuryReceiverLightMode(mode: 0 | 2 | 6 | 11): void { + if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury" + || latestDeviceStatus.lunafury?.receiverLightMode == null || ![0, 2, 6, 11].includes(mode)) return; + const label = st(mode === 2 ? "luna.lightMixed" : mode === 6 ? "luna.lightBattery" : mode === 11 ? "luna.lightDpi" : "common.off"); + stageChange({ + key: "lunafury-receiver-light", + label: `${st("luna.receiverLight")} · ${label}`, + command: `Set receiver light mode to ${mode}`, + progress: "Setting receiver light mode…", + preview: (status) => { if (status.lunafury) status.lunafury.receiverLightMode = mode; }, + apply: () => callClientMethod("setLunaFuryReceiverLightMode", "receiver lighting", mode), + }); +} + export function applyLunaFuryLightningMode(mode: 0 | 1 | 2): void { if (latestDeviceStatus?.lunafury?.lightningMode == null) return; stageChange({ diff --git a/src/device/lunafury-profile.test.ts b/src/device/lunafury-profile.test.ts index 3fa937e2..ce1b11b8 100644 --- a/src/device/lunafury-profile.test.ts +++ b/src/device/lunafury-profile.test.ts @@ -33,6 +33,7 @@ test("invalid imported values never become protocol writes", () => { test("profile staging calls only supported controls and ignores unknown commands", () => { const calls: unknown[] = []; const actions: LunaFuryProfileActions = { + buttonRate: () => {}, lightning: (mode) => { calls.push(["lightning", mode]); }, button: (button, milliseconds) => { calls.push([button, milliseconds]); }, wheel: (guard) => { calls.push(["wheel", guard]); }, @@ -48,6 +49,7 @@ test("profile staging calls only supported controls and ignores unknown commands test("apply and restore preserve zero values and do not write unrelated peers", () => { const current = structuredClone(settings); const actions: LunaFuryProfileActions = { + buttonRate: () => {}, lightning: (mode) => { current.lightningMode = mode; }, button: (button, milliseconds) => { current[`${button}DebounceMs`] = milliseconds; }, wheel: (guard) => { current.wheelGuard = { ...guard }; }, @@ -70,6 +72,7 @@ test("pending batches honor lightning disable/latency/enable ordering for game p }); try { stageLunaFuryProfile({ lightningMode: mode, leftDebounceMs: 1, rightDebounceMs: 2 }, settings, { + buttonRate: () => {}, lightning: (value) => stage("lightning", lunaFuryLightningPriority(value)), button: (button) => stage(button), wheel: () => stage("wheel"), @@ -81,3 +84,23 @@ test("pending batches honor lightning disable/latency/enable ordering for game p } } }); + +test("button polling profiles use supported rates and exclude hardware-only settings", () => { + assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: 4000, receiverLightMode: 0, bottomButtonMode: 1 }), + { buttonPollingRateHz: 4000 }); + for (const rate of [0, 125, 16000, "1000", NaN]) { + assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: rate }), {}); + } + const calls: number[] = []; + const actions: LunaFuryProfileActions = { + buttonRate: rate => { calls.push(rate); }, lightning: () => {}, button: () => {}, wheel: () => {}, + }; + stageLunaFuryProfile({ buttonPollingRateHz: 4000 }, + { buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000] }, actions); + stageLunaFuryProfile({ buttonPollingRateHz: 4000 }, + { buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000, 4000] }, actions); + assert.deepEqual(calls, [4000]); + const current = { lunafury: { buttonPollingRateHz: 1000, receiverLightMode: 6 } } as MouseStatus; + assert.deepEqual(pickSnapshot(current, { lunafury: { buttonPollingRateHz: 4000 } }), + { lunafury: { buttonPollingRateHz: 1000 } }); +}); diff --git a/src/device/lunafury-profile.ts b/src/device/lunafury-profile.ts index d803016e..e4b06527 100644 --- a/src/device/lunafury-profile.ts +++ b/src/device/lunafury-profile.ts @@ -1,7 +1,7 @@ -import type { LunaFuryButton, LunaFuryLightningMode, LunaFurySettings, LunaFuryWheelGuard } from "@openmouse/protocol/lamzu"; +import type { LunaFuryButton, LunaFuryButtonPollingRate, LunaFuryLightningMode, LunaFurySettings, LunaFuryWheelGuard } from "@openmouse/protocol/lamzu"; export const LUNAFURY_PROFILE_FIELDS = [ - "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard", + "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard", "buttonPollingRateHz", ] as const satisfies readonly (keyof LunaFurySettings)[]; type Field = (typeof LUNAFURY_PROFILE_FIELDS)[number]; @@ -17,6 +17,9 @@ export function sanitizeLunaFurySettings(raw: unknown): LunaFurySettings { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const value = raw as Record; const clean: LunaFurySettings = {}; + if ([1000, 2000, 4000, 8000].includes(value.buttonPollingRateHz as number)) { + clean.buttonPollingRateHz = value.buttonPollingRateHz as LunaFuryButtonPollingRate; + } if (integer(value.lightningMode, 0, 2)) clean.lightningMode = value.lightningMode as LunaFuryLightningMode; if (integer(value.leftDebounceMs, 0, 15)) clean.leftDebounceMs = value.leftDebounceMs; if (integer(value.rightDebounceMs, 0, 15)) clean.rightDebounceMs = value.rightDebounceMs; @@ -51,6 +54,7 @@ export function pickLunaFurySettings(current: LunaFurySettings | undefined, targ } export interface LunaFuryProfileActions { + buttonRate(hertz: LunaFuryButtonPollingRate): void; lightning(mode: LunaFuryLightningMode): void; button(button: LunaFuryButton, milliseconds: number): void; wheel(guard: LunaFuryWheelGuard): void; @@ -64,6 +68,10 @@ export function lunaFuryLightningPriority(mode: LunaFuryLightningMode): number { export function stageLunaFuryProfile(raw: unknown, current: LunaFurySettings | undefined, actions: LunaFuryProfileActions): void { if (!current) return; const target = sanitizeLunaFurySettings(raw); + if (target.buttonPollingRateHz !== undefined && current.buttonPollingRateHz !== undefined + && current.supportedButtonPollingRates?.includes(target.buttonPollingRateHz)) { + actions.buttonRate(target.buttonPollingRateHz); + } if (target.lightningMode !== undefined && current.lightningMode !== undefined) actions.lightning(target.lightningMode); for (const button of ["left", "right", "middle"] as const) { const field = `${button}DebounceMs` as const; diff --git a/src/device/preview/preview-fixtures.ts b/src/device/preview/preview-fixtures.ts index b0d74d87..8eb9d3ee 100644 --- a/src/device/preview/preview-fixtures.ts +++ b/src/device/preview/preview-fixtures.ts @@ -1,4 +1,5 @@ import type { MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; +import { LUNAFURY_BUTTON_ACTIONS } from "@openmouse/protocol/lamzu"; import { PREVIEW_KEYS, type FixturePreviewMode } from "./preview-modes.ts"; export { PREVIEW_KEYS }; @@ -294,6 +295,37 @@ const LAMZU: MouseStatus = { firmware: ["1.1.4"], }; +const LUNAFURY: MouseStatus = { + ...LAMZU, + brand: "LunaFury", + name: "LUNA33", + ui: { ...LAMZU.ui, family: "lunafury", + dpiStageEditor: { maxStages: 6, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 50 } }, + dpi: 800, + dpiY: 1600, + dpiStages: [400, 800, 1600, 3200, 6400, 30000], + dpiStagesY: [800, 1600, 3200, 3200, 6400, 30000], + activeDpiStage: 1, + activeProfile: 2, + profileCount: 3, + pollingRateHz: 8000, + supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000], + buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Middle Click", Back: "Back", Forward: "Macro:16:1" }, + buttonOptions: [...LUNAFURY_BUTTON_ACTIONS], + fixedButtons: ["Left", "Forward"], + angleSnapping: false, + rippleControl: false, + hyperMode: true, + performanceMode: true, + lunafury: { + buttonPollingRateHz: 4000, supportedButtonPollingRates: [1000, 2000, 4000, 8000], + bottomButtonMode: 2, receiverLightMode: 6, separateDpiAxes: true, + lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7, middleDebounceMs: 10, + wheelGuard: { enabled: true, windowMs: 100 }, + }, + firmware: ["Synthetic preview"], +}; + const CRDRAKO: MouseStatus = { brand: "CRDRAKO", name: "CRDRAKO KO-ONE", @@ -1085,6 +1117,7 @@ export const PREVIEW_FIXTURES: Record = { "egg-xm2we": { label: "Endgame Gear XM2we", status: EGG_XM2WE }, wlmouse: { label: "WLMouse BEAST X", status: WLMOUSE }, lamzu: { label: "Lamzu Atlantis OG V2", status: LAMZU }, + lunafury: { label: "LunaFury LUNA33 (synthetic)", status: LUNAFURY }, "attack-shark": { label: "Attack Shark R5 Ultra", status: ATTACKSHARK }, crdrako: { label: "CRDRAKO KO-ONE", status: CRDRAKO }, m3k: { label: "Zaunkoenig M3K", status: M3K }, diff --git a/src/device/preview/preview-modes.ts b/src/device/preview/preview-modes.ts index fe185d70..bba8b034 100644 --- a/src/device/preview/preview-modes.ts +++ b/src/device/preview/preview-modes.ts @@ -19,6 +19,7 @@ export const PREVIEW_KEYS = [ "egg-xm2we", "wlmouse", "lamzu", + "lunafury", "attack-shark", "crdrako", "m3k", @@ -69,6 +70,7 @@ export function parsePreviewMode(value: string | null): PreviewMode | null { case "egg-xm2we": return "egg-xm2we"; case "wlmouse": return "wlmouse"; case "lamzu": return "lamzu"; + case "lunafury": return "lunafury"; case "attack-shark": return "attack-shark"; case "crdrako": return "crdrako"; case "m3k": return "m3k"; diff --git a/src/features/devices/OverviewPage.tsx b/src/features/devices/OverviewPage.tsx index a2ffe46b..04a94361 100644 --- a/src/features/devices/OverviewPage.tsx +++ b/src/features/devices/OverviewPage.tsx @@ -27,7 +27,7 @@ import { KeyboardPanel } from "./cards/KeyboardPanel"; import { FirmwareCard } from "./cards/FirmwareCard"; import { DynamicSensitivityCard, LightforceCard, PollingCard, SensorCard, SensorScanningCard } from "./cards/PerformanceCards"; import { LightingCard } from "./cards/LightingCard"; -import { LunaFuryButtonsCard, LunaFuryWheelCard } from "./cards/LunaFuryCards"; +import { LunaFuryBottomButtonCard, LunaFuryButtonsCard, LunaFuryReceiverLightCard, LunaFuryWheelCard } from "./cards/LunaFuryCards"; import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards"; import { AtkButtonCard, AtkDongleCard, AtkProfileCard, AtkReceiverCard, AtkSensorCard } from "./cards/AtkCards"; import { @@ -394,6 +394,9 @@ export function Workspace({ ? : null, show(has.ksnakeScroll, ["buttons"]) ? : null, + show(has.lunafuryButtons, ["buttons"]) ? : null, + show(has.lunafuryWheelGuard, ["buttons"]) ? : null, + device && show(has.lunafuryBottomButton, ["buttons"]) ? : null, ].filter((node) => node !== null); const macro = [ @@ -411,8 +414,7 @@ export function Workspace({ show(has.finalmouse, ["advanced"]) ? : null, show(has.finalmouseProfiles, ["advanced"]) ? : null, show(has.incott, ["advanced"]) ? : null, - show(has.lunafuryButtons, ["buttons"]) ? : null, - show(has.lunafuryWheelGuard, ["buttons"]) ? : null, + device && show(has.lunafuryReceiverLight, ["advanced"]) ? : null, device && show(has.dongleLed, ["advanced"]) ? : null, show(has.atkProfile, ["profiles"]) ? : null, device && show(has.atkReceiver, ["advanced"]) ? : null, diff --git a/src/features/devices/cards/LunaFuryCards.tsx b/src/features/devices/cards/LunaFuryCards.tsx index 92483410..3580f62a 100644 --- a/src/features/devices/cards/LunaFuryCards.tsx +++ b/src/features/devices/cards/LunaFuryCards.tsx @@ -1,15 +1,62 @@ import type { ReactNode } from "react"; -import type { LunaFuryButton } from "@openmouse/protocol/lamzu"; +import type { LunaFuryButton, LunaFuryReceiverLightMode } from "@openmouse/protocol/lamzu"; import * as control from "../../../device/controller"; import type { ControlSnapshot } from "../../../device/types"; import { t, type I18nKey } from "../../../i18n/index"; -import { Segmented, StepperSlider, SwitchRow } from "../../../shared/ui"; +import { OptionMenu, RateSlider, Segmented, StepperSlider, SwitchRow } from "../../../shared/ui"; + +export function LunaFuryBottomButtonCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const mode = snapshot.status?.lunafury?.bottomButtonMode; + if (snapshot.status?.brand !== "LunaFury" || mode == null || snapshot.gameProfileDraft) return null; + const locale = snapshot.preferences.locale; + const staged = snapshot.pending.keys.includes("lunafury-bottom-button"); + const busy = snapshot.settingInProgress || snapshot.settingsPending || snapshot.pending.busy + || snapshot.pending.keys.includes("onboard-profile"); + return ( +
+

{t(locale, "luna.bottomButton")}

+ control.applyLunaFuryBottomButtonMode(value as 1 | 2)} /> + {t(locale, "luna.bottomButtonNote")} +
+ ); +} + +export function LunaFuryReceiverLightCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const mode = snapshot.status?.lunafury?.receiverLightMode; + if (snapshot.status?.brand !== "LunaFury" || mode == null || snapshot.gameProfileDraft) return null; + const locale = snapshot.preferences.locale; + const staged = snapshot.pending.keys.includes("lunafury-receiver-light"); + return ( +
+

{t(locale, "luna.receiverLight")}

+ control.applyLunaFuryReceiverLightMode(value as LunaFuryReceiverLightMode)} /> + {t(locale, "luna.receiverLightNote")} +
+ ); +} 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 busy = snapshot.settingInProgress || snapshot.settingsPending || snapshot.pending.busy; + const rateBusy = busy || snapshot.pending.keys.includes("onboard-profile"); 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 }, @@ -18,8 +65,17 @@ export function LunaFuryButtonsCard({ snapshot }: { snapshot: ControlSnapshot }) ]; return (
+ data-pending-key="lunafury-lightning lunafury-button-left lunafury-button-right lunafury-button-middle lunafury-button-rate">

{t(locale, "luna.buttons")}

+ {settings.buttonPollingRateHz != null && settings.supportedButtonPollingRates?.length ? ( +
+ control.applyLunaFuryButtonPollingRate(value as 1000 | 2000 | 4000 | 8000)} /> + {t(locale, "luna.buttonPollingNote")} +
+ ) : null} {settings.lightningMode != null ? (
{t(locale, "luna.lightning")} diff --git a/src/features/devices/cards/availability.test.ts b/src/features/devices/cards/availability.test.ts index e3b08b60..08df5507 100644 --- a/src/features/devices/cards/availability.test.ts +++ b/src/features/devices/cards/availability.test.ts @@ -65,6 +65,24 @@ test("unknown LunaFury controls are hidden without losing basic debounce", () => assert.equal(middleOnly.debounce, true); }); +test("LunaFury optional controls follow successful reads, including receiver off", () => { + const cards = (brand: MouseStatus["brand"], lunafury: MouseStatus["lunafury"]) => cardAvailability(snapshot({ + status: { brand, ui: { family: "lunafury" }, lunafury }, + })); + const reported = { buttonPollingRateHz: 1000 as const, supportedButtonPollingRates: [1000 as const], + receiverLightMode: 0 as const, bottomButtonMode: 1 as const }; + const available = cards("LunaFury", reported); + assert.equal(available.lunafuryButtons, true); + assert.equal(available.lunafuryReceiverLight, true); + assert.equal(available.lunafuryBottomButton, true); + for (const missing of [cards("LunaFury", {}), cards("Lamzu", reported)]) { + assert.equal(missing.lunafuryButtons, false); + assert.equal(missing.lunafuryReceiverLight, false); + assert.equal(missing.lunafuryBottomButton, false); + } + assert.equal(cards("LunaFury", { buttonPollingRateHz: 1000 }).lunafuryButtons, false); +}); + test("LunaFury-specific cards are not offered to another CompX brand", () => { const has = cardAvailability(snapshot({ status: { brand: "Attack Shark", ui: { family: "attack-shark" }, diff --git a/src/features/devices/cards/availability.ts b/src/features/devices/cards/availability.ts index 0bbe82c7..92803ed0 100644 --- a/src/features/devices/cards/availability.ts +++ b/src/features/devices/cards/availability.ts @@ -29,6 +29,8 @@ export interface CardAvailability { incott: boolean; lunafuryButtons: boolean; lunafuryWheelGuard: boolean; + lunafuryReceiverLight: boolean; + lunafuryBottomButton: boolean; dongleLed: boolean; eggFilter: boolean; eggSpdt: boolean; @@ -83,6 +85,8 @@ const NOTHING: CardAvailability = { incott: false, lunafuryButtons: false, lunafuryWheelGuard: false, + lunafuryReceiverLight: false, + lunafuryBottomButton: false, dongleLed: false, eggFilter: false, eggSpdt: false, @@ -212,8 +216,11 @@ function computeCardAvailability(snapshot: ControlSnapshot): CardAvailability { lunafuryButtons: host && status.brand === "LunaFury" && ( status.lunafury?.lightningMode != null || status.lunafury?.leftDebounceMs != null || status.lunafury?.rightDebounceMs != null || status.lunafury?.middleDebounceMs != null + || (status.lunafury?.buttonPollingRateHz != null && Boolean(status.lunafury.supportedButtonPollingRates?.length)) ), lunafuryWheelGuard: host && status.brand === "LunaFury" && status.lunafury?.wheelGuard != null, + lunafuryReceiverLight: host && status.brand === "LunaFury" && status.lunafury?.receiverLightMode != null, + lunafuryBottomButton: host && status.brand === "LunaFury" && status.lunafury?.bottomButtonMode != null, dongleLed: typeof status.dongleLedEnabled === "boolean", eggFilter: eggs, eggSpdt: eggs, diff --git a/src/features/devices/workspace-tabs.test.ts b/src/features/devices/workspace-tabs.test.ts index f861fa46..a299f8df 100644 --- a/src/features/devices/workspace-tabs.test.ts +++ b/src/features/devices/workspace-tabs.test.ts @@ -16,7 +16,7 @@ function availability(enabled: Partial): CardAvailability { } test("LunaFury response and wheel guard retain the Buttons tab without global debounce", () => { - for (const field of ["lunafuryButtons", "lunafuryWheelGuard"] as const) { + for (const field of ["lunafuryButtons", "lunafuryWheelGuard", "lunafuryBottomButton"] as const) { assert.deepEqual(availableWorkspaceTabs(true, availability({ [field]: true })), [ "overview", "performance", "buttons", "advanced", ]); @@ -30,6 +30,17 @@ test("a connected mouse exposes only tabs backed by available controls", () => { ]); }); +test("Games omits device-only bottom-button controls without hiding editable button settings", () => { + const deviceOnly = availability({ lunafuryBottomButton: true }); + assert.equal(availableWorkspaceTabs(true, deviceOnly).includes("buttons"), true); + assert.equal(availableWorkspaceTabs(true, deviceOnly, { gameProfile: true }).includes("buttons"), false); + for (const field of ["lunafuryButtons", "lunafuryWheelGuard", "buttonMapping"] as const) { + assert.equal(availableWorkspaceTabs(true, availability({ + lunafuryBottomButton: true, [field]: true, + }), { gameProfile: true }).includes("buttons"), true); + } +}); + test("magnetic buttons put their settings under the buttons tab", () => { assert.deepEqual(availableWorkspaceTabs(true, availability({ magnetic: true })), [ "overview", "performance", "buttons", "advanced", diff --git a/src/features/devices/workspace-tabs.ts b/src/features/devices/workspace-tabs.ts index 2a560630..5838bf55 100644 --- a/src/features/devices/workspace-tabs.ts +++ b/src/features/devices/workspace-tabs.ts @@ -4,12 +4,13 @@ import type { CardAvailability } from "./cards/availability.ts"; export function availableWorkspaceTabs( connected: boolean, has: CardAvailability, - options: { hideLighting?: boolean } = {}, + options: { hideLighting?: boolean; gameProfile?: boolean } = {}, ): readonly WorkspaceTab[] { if (!connected) return WORKSPACE_TAB_ORDER; const hasButtons = has.eggButtons || has.eggHanded || has.razerButtons || has.mxMasterButtons || has.atkButtons || has.buttonMapping || has.debounce || has.lightforce || has.eggSpdt || has.superstrike || has.magnetic - || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard || has.tmrDs; + || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard + || (!options.gameProfile && has.lunafuryBottomButton) || has.tmrDs; const hasProfiles = has.profiles || has.keychronNapeLayers || has.atkProfile || has.onboardProfiles || has.pulsarPro; return WORKSPACE_TAB_ORDER.filter((tab) => { diff --git a/src/features/games/GameProfilePanel.tsx b/src/features/games/GameProfilePanel.tsx index 2db3573e..323f0382 100644 --- a/src/features/games/GameProfilePanel.tsx +++ b/src/features/games/GameProfilePanel.tsx @@ -262,7 +262,7 @@ export function GameProfilePanel({ const artwork = catalogEntry ? gameArtwork(catalogEntry) : null; const tabs = editing - ? availableWorkspaceTabs(true, cardAvailability(snapshot)).filter((entry) => GAME_PROFILE_TABS.includes(entry)) + ? availableWorkspaceTabs(true, cardAvailability(snapshot), { gameProfile: true }).filter((entry) => GAME_PROFILE_TABS.includes(entry)) : []; const activeTab = tabs.includes(tab) ? tab : tabs[0] ?? "performance"; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 4cde08fc..75a6649d 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,17 @@ const en = { "luna.motionSync": "Motion Sync", "luna.rippleControl": "Ripple Control", "luna.buttons": "Button response", + "luna.bottomButton": "Bottom button", + "luna.bottomPower": "Short press: power on/off", + "luna.bottomPowerDpi": "Long press: power on/off / short press: DPI", + "luna.bottomButtonNote": "Saved in the current onboard profile. Apply to save.", + "luna.receiverLight": "Receiver lighting", + "luna.lightMixed": "Mixed mode", + "luna.lightBattery": "Battery mode", + "luna.lightDpi": "DPI mode", + "luna.receiverLightNote": "Applies to the 8K receiver, independently of onboard and game profiles.", + "luna.buttonPollingRate": "Button polling rate", + "luna.buttonPollingNote": "Independent of the USB report rate.", "luna.lightning": "Lightning Trigger", "luna.leftPriority": "Left priority", "luna.rightPriority": "Right priority", diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index c760d046..fa380646 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -8,6 +8,17 @@ export const ar: Record = { "luna.motionSync": "مزامنة الحركة", "luna.rippleControl": "التحكم في التموج", "luna.buttons": "استجابة الأزرار", + "luna.bottomButton": "الزر السفلي", + "luna.bottomPower": "ضغطة قصيرة: تشغيل/إيقاف", + "luna.bottomPowerDpi": "ضغطة طويلة: تشغيل/إيقاف / قصيرة: DPI", + "luna.bottomButtonNote": "يُحفظ في ملف التعريف الداخلي الحالي. اضغط تطبيق للحفظ.", + "luna.receiverLight": "إضاءة المستقبل", + "luna.lightMixed": "الوضع المختلط", + "luna.lightBattery": "وضع البطارية", + "luna.lightDpi": "وضع DPI", + "luna.receiverLightNote": "ينطبق على مستقبل 8K، بشكل مستقل عن ملفات التعريف الداخلية وملفات الألعاب.", + "luna.buttonPollingRate": "معدل استطلاع الأزرار", + "luna.buttonPollingNote": "مستقل عن معدل تقارير USB.", "luna.lightning": "الزناد البرقي", "luna.leftPriority": "أولوية اليسار", "luna.rightPriority": "أولوية اليمين", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index eb5b34dc..7061cb5e 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -8,6 +8,17 @@ export const de: Record = { "luna.motionSync": "Bewegungssynchronisierung", "luna.rippleControl": "Welligkeitskontrolle", "luna.buttons": "Tastenreaktion", + "luna.bottomButton": "Unterseitentaste", + "luna.bottomPower": "Kurz drücken: ein-/ausschalten", + "luna.bottomPowerDpi": "Lang drücken: ein-/ausschalten / kurz drücken: DPI", + "luna.bottomButtonNote": "Im aktuellen Onboard-Profil gespeichert. Zum Speichern anwenden.", + "luna.receiverLight": "Empfängerbeleuchtung", + "luna.lightMixed": "Gemischter Modus", + "luna.lightBattery": "Akkumodus", + "luna.lightDpi": "DPI-Modus", + "luna.receiverLightNote": "Gilt für den 8K-Empfänger, unabhängig von Onboard- und Spielprofilen.", + "luna.buttonPollingRate": "Tasten-Abfragerate", + "luna.buttonPollingNote": "Unabhängig von der USB-Abfragerate.", "luna.lightning": "Blitz-Auslöser", "luna.leftPriority": "Links bevorzugen", "luna.rightPriority": "Rechts bevorzugen", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d4e9e8b1..d9e1748c 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -8,6 +8,17 @@ export const es: Record = { "luna.motionSync": "Sincronización de movimiento", "luna.rippleControl": "Control de ondulación", "luna.buttons": "Respuesta de botones", + "luna.bottomButton": "Botón inferior", + "luna.bottomPower": "Pulsación corta: encender/apagar", + "luna.bottomPowerDpi": "Pulsación larga: encender/apagar / corta: DPI", + "luna.bottomButtonNote": "Se guarda en el perfil integrado actual. Aplica para guardar.", + "luna.receiverLight": "Iluminación del receptor", + "luna.lightMixed": "Modo mixto", + "luna.lightBattery": "Modo batería", + "luna.lightDpi": "Modo DPI", + "luna.receiverLightNote": "Se aplica al receptor 8K, independientemente de los perfiles integrados y de juego.", + "luna.buttonPollingRate": "Frecuencia de los botones", + "luna.buttonPollingNote": "Independiente de la frecuencia de informes USB.", "luna.lightning": "Gatillo relámpago", "luna.leftPriority": "Prioridad izquierda", "luna.rightPriority": "Prioridad derecha", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index b27d3941..30b10bd3 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -8,6 +8,17 @@ export const fr: Record = { "luna.motionSync": "Synchronisation du mouvement", "luna.rippleControl": "Contrôle des ondulations", "luna.buttons": "Réponse des boutons", + "luna.bottomButton": "Bouton inférieur", + "luna.bottomPower": "Appui court : marche/arrêt", + "luna.bottomPowerDpi": "Appui long : marche/arrêt / appui court : DPI", + "luna.bottomButtonNote": "Enregistré dans le profil embarqué actuel. Appliquez pour enregistrer.", + "luna.receiverLight": "Éclairage du récepteur", + "luna.lightMixed": "Mode mixte", + "luna.lightBattery": "Mode batterie", + "luna.lightDpi": "Mode DPI", + "luna.receiverLightNote": "Concerne le récepteur 8K, indépendamment des profils embarqués et de jeu.", + "luna.buttonPollingRate": "Fréquence des boutons", + "luna.buttonPollingNote": "Indépendante de la fréquence des rapports USB.", "luna.lightning": "Gâchette éclair", "luna.leftPriority": "Priorité gauche", "luna.rightPriority": "Priorité droite", diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 7dfc9922..e2cfafd5 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -8,6 +8,17 @@ export const ja: Record = { "luna.motionSync": "Motion Sync", "luna.rippleControl": "Ripple Control", "luna.buttons": "ボタン応答", + "luna.bottomButton": "底面ボタン", + "luna.bottomPower": "短押しで電源オン/オフ", + "luna.bottomPowerDpi": "長押しで電源オン/オフ・短押しで DPI 切り替え", + "luna.bottomButtonNote": "現在のオンボードプロファイルに保存されます。「適用」で保存してください。", + "luna.receiverLight": "レシーバーの照明", + "luna.lightMixed": "ミックスモード", + "luna.lightBattery": "バッテリーモード", + "luna.lightDpi": "DPI モード", + "luna.receiverLightNote": "8K レシーバーの設定です。オンボード・ゲームプロファイルとは独立しています。", + "luna.buttonPollingRate": "ボタンポーリングレート", + "luna.buttonPollingNote": "USB レポートレートとは独立した設定です。", "luna.lightning": "ライトニングトリガー", "luna.leftPriority": "左ボタン優先", "luna.rightPriority": "右ボタン優先", diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 73534909..4391a1eb 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -8,6 +8,17 @@ export const ko: Record = { "luna.motionSync": "모션 싱크", "luna.rippleControl": "리플 제어", "luna.buttons": "버튼 응답", + "luna.bottomButton": "하단 버튼", + "luna.bottomPower": "짧게 눌러 전원 켜기/끄기", + "luna.bottomPowerDpi": "길게 눌러 전원 켜기/끄기 · 짧게 눌러 DPI 전환", + "luna.bottomButtonNote": "현재 온보드 프로필에 저장됩니다. 적용을 눌러 저장하세요.", + "luna.receiverLight": "수신기 조명", + "luna.lightMixed": "혼합 모드", + "luna.lightBattery": "배터리 모드", + "luna.lightDpi": "DPI 모드", + "luna.receiverLightNote": "8K 수신기에 적용되며 온보드 및 게임 프로필과 독립적입니다.", + "luna.buttonPollingRate": "버튼 폴링 레이트", + "luna.buttonPollingNote": "USB 보고 주기와 별개의 설정입니다.", "luna.lightning": "라이트닝 트리거", "luna.leftPriority": "왼쪽 우선", "luna.rightPriority": "오른쪽 우선", diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 6e67ec20..a93e3a56 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -8,6 +8,17 @@ export const pt: Record = { "luna.motionSync": "Sincronização de movimento", "luna.rippleControl": "Controle de ondulação", "luna.buttons": "Resposta dos botões", + "luna.bottomButton": "Botão inferior", + "luna.bottomPower": "Pressão curta: ligar/desligar", + "luna.bottomPowerDpi": "Pressão longa: ligar/desligar / curta: DPI", + "luna.bottomButtonNote": "Guardado no perfil interno atual. Aplique para guardar.", + "luna.receiverLight": "Iluminação do receptor", + "luna.lightMixed": "Modo misto", + "luna.lightBattery": "Modo bateria", + "luna.lightDpi": "Modo DPI", + "luna.receiverLightNote": "Aplica-se ao receptor 8K, independentemente dos perfis internos e de jogos.", + "luna.buttonPollingRate": "Taxa de consulta dos botões", + "luna.buttonPollingNote": "Independente da taxa de relatórios USB.", "luna.lightning": "Gatilho relâmpago", "luna.leftPriority": "Prioridade esquerda", "luna.rightPriority": "Prioridade direita", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e3e6c766..d4c3e570 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -8,6 +8,17 @@ export const ru: Record = { "luna.motionSync": "Синхронизация движения", "luna.rippleControl": "Подавление дрожания", "luna.buttons": "Отклик кнопок", + "luna.bottomButton": "Нижняя кнопка", + "luna.bottomPower": "Короткое нажатие: включение/выключение", + "luna.bottomPowerDpi": "Долгое нажатие: питание / короткое: DPI", + "luna.bottomButtonNote": "Сохраняется в текущем встроенном профиле. Нажмите «Применить».", + "luna.receiverLight": "Подсветка приёмника", + "luna.lightMixed": "Смешанный режим", + "luna.lightBattery": "Режим батареи", + "luna.lightDpi": "Режим DPI", + "luna.receiverLightNote": "Настройка приёмника 8K, независимая от встроенных и игровых профилей.", + "luna.buttonPollingRate": "Частота опроса кнопок", + "luna.buttonPollingNote": "Не зависит от частоты отчётов USB.", "luna.lightning": "Молниеносный спуск", "luna.leftPriority": "Приоритет левой", "luna.rightPriority": "Приоритет правой", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index ab6504dc..a5582109 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -7,6 +7,17 @@ export const tr: Record = { "luna.motionSync": "Hareket senkronizasyonu", "luna.rippleControl": "Dalgalanma kontrolü", "luna.buttons": "Düğme tepkisi", + "luna.bottomButton": "Alt düğme", + "luna.bottomPower": "Kısa basış: aç/kapat", + "luna.bottomPowerDpi": "Uzun basış: aç/kapat / kısa basış: DPI", + "luna.bottomButtonNote": "Cihaz belleğindeki mevcut profile kaydedilir. Kaydetmek için Uygula'ya basın.", + "luna.receiverLight": "Alıcı aydınlatması", + "luna.lightMixed": "Karma mod", + "luna.lightBattery": "Pil modu", + "luna.lightDpi": "DPI modu", + "luna.receiverLightNote": "Cihaz belleğindeki profillerden ve oyun profillerinden bağımsız olarak 8K alıcıya uygulanır.", + "luna.buttonPollingRate": "Düğme sorgulama hızı", + "luna.buttonPollingNote": "USB raporlama hızından bağımsızdır.", "luna.lightning": "Lightning Trigger", "luna.leftPriority": "Sol öncelikli", "luna.rightPriority": "Sağ öncelikli", diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 2e0d7f36..fa2e8ead 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -8,6 +8,17 @@ export const vi: Record = { "luna.motionSync": "Đồng bộ chuyển động", "luna.rippleControl": "Kiểm soát gợn", "luna.buttons": "Phản hồi nút", + "luna.bottomButton": "Nút dưới đáy", + "luna.bottomPower": "Nhấn ngắn: bật/tắt nguồn", + "luna.bottomPowerDpi": "Nhấn lâu: bật/tắt nguồn / nhấn ngắn: DPI", + "luna.bottomButtonNote": "Lưu trong hồ sơ trên chuột hiện tại. Nhấn Áp dụng để lưu.", + "luna.receiverLight": "Đèn bộ thu", + "luna.lightMixed": "Chế độ kết hợp", + "luna.lightBattery": "Chế độ pin", + "luna.lightDpi": "Chế độ DPI", + "luna.receiverLightNote": "Áp dụng cho bộ thu 8K, độc lập với hồ sơ trên chuột và hồ sơ trò chơi.", + "luna.buttonPollingRate": "Tần số quét nút", + "luna.buttonPollingNote": "Độc lập với tần số báo cáo USB.", "luna.lightning": "Cò chớp", "luna.leftPriority": "Ưu tiên trái", "luna.rightPriority": "Ưu tiên phải", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 61e4dc26..73ccd66e 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -8,6 +8,17 @@ export const zh: Record = { "luna.motionSync": "运动同步", "luna.rippleControl": "波纹控制", "luna.buttons": "按键响应", + "luna.bottomButton": "底部按键", + "luna.bottomPower": "短按开关机", + "luna.bottomPowerDpi": "长按开关机/短按 DPI", + "luna.bottomButtonNote": "保存在当前板载配置中,点击应用后生效。", + "luna.receiverLight": "接收器灯光", + "luna.lightMixed": "混合模式", + "luna.lightBattery": "电量模式", + "luna.lightDpi": "DPI 模式", + "luna.receiverLightNote": "仅控制 8K 接收器灯光,不随板载配置或游戏配置切换。", + "luna.buttonPollingRate": "按键轮询率", + "luna.buttonPollingNote": "此设置独立于 USB 回报率。", "luna.lightning": "闪电扳机", "luna.leftPriority": "左键优先", "luna.rightPriority": "右键优先", diff --git a/src/shared/CardSlot.tsx b/src/shared/CardSlot.tsx index 2a379a88..b26298a5 100644 --- a/src/shared/CardSlot.tsx +++ b/src/shared/CardSlot.tsx @@ -44,6 +44,8 @@ const GLYPHS: Readonly> = { incott: Cog, "lunafury-buttons": Mouse, "lunafury-wheel": ShieldCheck, + "lunafury-bottom-button": ToggleRight, + "lunafury-receiver-light": Usb, "dongle-led": Usb, "atk-profile": Layers, "atk-receiver": Usb, diff --git a/src/styles/areas/advanced.css b/src/styles/areas/advanced.css index bec69e35..4b37cadc 100644 --- a/src/styles/areas/advanced.css +++ b/src/styles/areas/advanced.css @@ -17,6 +17,11 @@ align-items: center; } #sleep-settings > .setting-heading { min-width: 0; } +#pulsar-advanced:has(#lunafury-receiver-light) #sleep-settings { + display: flex; + align-items: stretch; + gap: 12px; +} #sleep-settings:has(#sleep-toggle) > .setting-heading { grid-column: 1 / -1; } #sleep-settings > .option-menu, #sleep-settings > .sleep-time-picker { grid-column: 2; } #sleep-settings:has(#sleep-toggle) > .option-menu,