From 622a4bb86fac5f718782f983aa0a3bedee8d299d Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 15:59:45 +1000 Subject: [PATCH 1/2] Port shared DPI stage selector to the Preact control UI. Rebuild the Compx/Keychron stage editor on DpiCard after the Preact rewrite, move Terra Pro DPI lighting to the Lighting tab, and refresh preview fixtures now that protocol #7 is on main. --- src/app/App.tsx | 17 ++++--- src/app/cards/DpiCard.tsx | 96 ++++++++++++++++++++++++++++++++++++++- src/control-devices.css | 19 ++++++++ src/control-profiles.css | 8 ++++ src/device/controller.ts | 89 ++++++++++++++++++++++++++++++++++++ src/preview-fixtures.ts | 24 +++++++++- 6 files changed, 244 insertions(+), 9 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index a23de57..864deef 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -132,8 +132,6 @@ function Workspace({ ? : null, show(has.lowPower, ["advanced"]) ? : null, show(has.processing, ["performance"]) ? : null, - show(has.teevolutionDpiLighting, ["advanced"]) - ? : null, show(has.finalmouse, ["advanced"]) ? : null, show(has.eggFilter, ["performance"]) ? : null, show(has.eggSpdt, ["buttons"]) ? : null, @@ -144,9 +142,12 @@ function Workspace({ ].filter((node) => node !== null); const lightingZones = status.lightingZones?.length ? status.lightingZones : status.lighting ? [status.lighting] : []; - const lighting = show(has.lighting, ["lighting"]) - ? [] - : []; + const lighting = [ + show(has.lighting, ["lighting"]) + ? : null, + show(has.teevolutionDpiLighting, ["lighting"]) + ? : null, + ].filter((node) => node !== null); const showProfiles = show(has.profiles, ["profiles"]); const showSuperstrike = show(has.superstrike, ["buttons"]); @@ -160,6 +161,10 @@ function Workspace({ || showProfiles || showSuperstrike || showLogitechDetails || showMxMaster || showDiagnostics || showOverview; const slotsAvailable = snapshot.profile.slotsAvailable; + const stagesAvailable = Boolean(status.ui?.dpiStageEditor) + && Array.isArray(status.dpiStages) + && status.dpiStages.length > 0 + && !slotsAvailable; const showSeparateDpiAxes = snapshot.traits.logitech && status.supportsSeparateDpiAxes === true && !slotsAvailable; @@ -185,7 +190,7 @@ function Workspace({ className={[ "settings-grid device-data", showSeparateDpiAxes ? "has-logitech-axis-controls" : "", - slotsAvailable ? "has-dpi-slots" : "", + slotsAvailable || stagesAvailable ? "has-dpi-slots" : "", ].filter(Boolean).join(" ")} data-workspace-host role="tabpanel" diff --git a/src/app/cards/DpiCard.tsx b/src/app/cards/DpiCard.tsx index a264f2f..b62d7f9 100644 --- a/src/app/cards/DpiCard.tsx +++ b/src/app/cards/DpiCard.tsx @@ -157,6 +157,87 @@ function DpiSlots({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { ); } +/** Shared Compx/Keychron-style stages: one DPI value per stage + active highlight. */ +function DpiStages({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const status = snapshot.status; + const editor = status?.ui?.dpiStageEditor; + const stages = status?.dpiStages; + if (!status || !editor || !stages || stages.length === 0) return null; + + const countEditable = editor.countEditable === true; + const active = Math.min(status.activeDpiStage ?? 0, stages.length - 1); + const disabled = snapshot.settingsPending; + + return ( +
+ {countEditable ? ( + <> +
+ Stages in use +
+ {Array.from({ length: editor.maxStages }, (_, step) => { + const value = step + 1; + const on = value === stages.length; + return ( + + ); + })} +
+
+
+ + ) : null} +
+
+ DPI +
+ {stages.map((dpi, index) => { + const isActive = index === active; + return ( +
+ + control.applyDpiStageValue(index, Number(event.currentTarget.value))} + /> +
+ ); + })} +
+ + {countEditable + ? `Presets and Custom edit the highlighted stage. ${editor.minDpi.toLocaleString()}–${editor.maxDpi.toLocaleString()} DPI. The DPI button on the mouse cycles through these stages.` + : `Presets and Custom edit the highlighted stage. ${editor.minDpi.toLocaleString()}–${editor.maxDpi.toLocaleString()} DPI in steps of ${editor.stepDpi}. The DPI button on the mouse cycles through all ${editor.maxStages} stages.`} + +
+ ); +} + function AxisControls({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status!; const [x, setX] = useState(String(status.dpi)); @@ -211,8 +292,15 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode const status = snapshot.status; const deviceStatus = snapshot.deviceStatus; if (!status || !deviceStatus) return null; - const staged = snapshot.pending.keys.includes("dpi"); + const staged = snapshot.pending.keys.includes("dpi") + || snapshot.pending.keys.includes("dpi-stage-count") + || snapshot.pending.keys.includes("dpi-active-stage") + || snapshot.pending.keys.some((key) => key.startsWith("dpi-stage-")); const slotsAvailable = snapshot.profile.slotsAvailable; + const stagesAvailable = Boolean(status.ui?.dpiStageEditor) + && Array.isArray(status.dpiStages) + && status.dpiStages.length > 0 + && !slotsAvailable; const showSeparateDpiAxes = snapshot.traits.logitech && status.supportsSeparateDpiAxes === true && !slotsAvailable; @@ -225,7 +313,10 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode : `${source.dpi.toLocaleString()} DPI`; return ( -
+

DPI

@@ -285,6 +376,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {showSeparateDpiAxes ? : null} {slotsAvailable ? : null} + {stagesAvailable ? : null}
diff --git a/src/control-devices.css b/src/control-devices.css index e6c621e..5124f76 100644 --- a/src/control-devices.css +++ b/src/control-devices.css @@ -6,6 +6,25 @@ #pulsar-advanced input:disabled, #pulsar-advanced select:disabled { cursor: not-allowed; opacity: .45; } #pulsar-advanced input[type="checkbox"] { width: .85rem; height: .85rem; margin: 0; accent-color: var(--ui-accent); } +/* Teevolution DPI indicator lives on the Lighting tab; keep select chrome + readable outside #pulsar-advanced. */ +#teevolution-dpi-lighting select { + width: 100%; + box-sizing: border-box; + margin-top: .2rem; + padding: .48rem .55rem; + border: 1px solid var(--line-strong); + border-radius: 6px; + outline: none; + background: var(--surface-input); + color: var(--text-input); + font: inherit; + color-scheme: dark; +} +#teevolution-dpi-lighting select:focus { border-color: var(--faint); box-shadow: 0 0 0 2px rgb(255 255 255 / 4%); } +#teevolution-dpi-lighting select:disabled, +#teevolution-dpi-lighting input:disabled { cursor: not-allowed; opacity: .45; } + #pulsar-advanced.egg-advanced-layout { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; gap: .55rem !important; } #pulsar-advanced.egg-advanced-layout > .setting-card { min-height: 0 !important; padding: .72rem !important; } #egg-polling-settings, #egg-cpi-settings, #egg-button-settings { grid-column: 1 / -1; } diff --git a/src/control-profiles.css b/src/control-profiles.css index a2a8f3f..f6f4a67 100644 --- a/src/control-profiles.css +++ b/src/control-profiles.css @@ -262,6 +262,14 @@ .control-shell .settings-grid.has-dpi-slots { grid-template-rows:minmax(176px,auto) } .control-shell .settings-grid.has-dpi-slots .dpi-card { grid-row:auto } +/* Compx/Keychron simple stages reuse the slot row chrome with a 2-column grid. */ +#dpi-stages { margin-top: .65rem; } +.dpi-stage-list .dpi-slot-row { grid-template-columns:1.7rem minmax(0,1fr) } +.dpi-stage-list .dpi-slot-head { grid-template-columns:1.7rem minmax(0,1fr) } +@media (max-width:640px) { + .dpi-stage-list .dpi-slot-row { grid-template-columns:1.7rem minmax(0,1fr) } +} + /* `.segmented` and friends set display, which outranks the user-agent `[hidden] { display: none }` rule — so hiding those rows by setting the hidden property silently did nothing. Make the attribute win everywhere. */ diff --git a/src/device/controller.ts b/src/device/controller.ts index 08ada64..f2ca89c 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1496,6 +1496,12 @@ export function applyDpiValue(dpi: number): boolean { preview: (status) => { status.dpi = dpi; if (status.dpiY !== undefined) status.dpiY = dpi; + // Stage-aware mice: presets write the active stage; keep the table in sync. + if (status.dpiStages && status.activeDpiStage != null && status.activeDpiStage < status.dpiStages.length) { + status.dpiStages = status.dpiStages.map((value, index) => ( + index === status.activeDpiStage ? dpi : value + )); + } }, apply: async () => { await requireClientMethod("setDpi", "DPI").setDpi(dpi); @@ -1504,6 +1510,86 @@ export function applyDpiValue(dpi: number): boolean { return true; } +export function applyDpiStageCount(count: number): void { + if (!hasActiveClient()) return; + const editor = latestDeviceStatus?.ui?.dpiStageEditor; + if (!editor || editor.countEditable !== true) return; + if (!Number.isInteger(count) || count < 1 || count > editor.maxStages) return; + if (!("setDpiStageCount" in requireSettingsClient())) return; + stageChange({ + key: "dpi-stage-count", + label: `${count} DPI stage${count === 1 ? "" : "s"}`, + command: `Set DPI stage count to ${count}`, + progress: `Setting ${count} DPI stages…`, + preview: (status) => { + const current = status.dpiStages?.slice() ?? []; + if (count <= current.length) status.dpiStages = current.slice(0, count); + else { + const padded = current.slice(); + while (padded.length < count) padded.push(padded.at(-1) ?? status.dpi); + status.dpiStages = padded; + } + if ((status.activeDpiStage ?? 0) >= count) { + status.activeDpiStage = count - 1; + status.dpi = status.dpiStages[count - 1] ?? status.dpi; + } + }, + apply: async () => { + await requireClientMethod("setDpiStageCount", "DPI stage count").setDpiStageCount(count); + }, + }); +} + +export function applyActiveDpiStage(stage: number): void { + if (!hasActiveClient()) return; + const stages = latestDeviceStatus ? withPendingChanges(latestDeviceStatus).dpiStages : undefined; + if (!stages || !Number.isInteger(stage) || stage < 0 || stage >= stages.length) return; + stageChange({ + key: "dpi-active-stage", + label: `DPI stage ${stage + 1}`, + command: `Set active DPI stage to ${stage + 1}`, + progress: `Selecting DPI stage ${stage + 1}…`, + preview: (status) => { + status.activeDpiStage = stage; + status.dpi = status.dpiStages?.[stage] ?? status.dpi; + }, + apply: async () => { + await requireClientMethod("setActiveDpiStage", "DPI stage").setActiveDpiStage(stage); + }, + }); +} + +export function applyDpiStageValue(stage: number, rawDpi: number): void { + if (!hasActiveClient()) return; + const stagedStatus = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; + const stages = stagedStatus?.dpiStages ?? latestDeviceStatus?.dpiStages; + if (!stages || !Number.isInteger(stage) || stage < 0 || stage >= stages.length) return; + const dpi = closestDpiOption(dpiOptions, rawDpi) ?? rawDpi; + if (!dpiOptions.includes(dpi)) { + setReadStatus(`${rawDpi.toLocaleString()} DPI is not supported by this mouse.`); + emit(); + return; + } + stageChange({ + key: `dpi-stage-${stage}`, + label: `Stage ${stage + 1} · ${dpi.toLocaleString()} DPI`, + command: `Set DPI stage ${stage + 1} to ${dpi.toLocaleString()}`, + progress: `Setting stage ${stage + 1} to ${dpi.toLocaleString()} DPI…`, + preview: (status) => { + // Pad first so a newly added stage (only present via a staged count + // increase) still diffs against the device snapshot in matchesDeviceStatus. + const next = status.dpiStages?.slice() ?? []; + while (next.length <= stage) next.push(next.at(-1) ?? status.dpi); + next[stage] = dpi; + status.dpiStages = next; + if ((status.activeDpiStage ?? 0) === stage) status.dpi = dpi; + }, + apply: async () => { + await requireClientMethod("setDpiStageValue", "DPI stage value").setDpiStageValue(stage, dpi); + }, + }); +} + export function applyLogitechAxisDpi(dpiX: number, dpiY: number): void { if (!logitechClient()) return; if (!dpiOptions.includes(dpiX) || !dpiOptions.includes(dpiY)) { @@ -2858,6 +2944,9 @@ async function showFixturePreview(name: PreviewMode): Promise { return; } dpiOptions = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 32000]; + // Populate brand capabilities (e.g. Teevolution DPI lighting profile) so + // preview cards that gate on capabilities still render. + capabilities = readCapabilities(); applyStatus(fixture.status); setConnectionButtons(true, "Preview mode"); setReadStatus(`Preview: ${fixture.label}. Nothing is written.`); diff --git a/src/preview-fixtures.ts b/src/preview-fixtures.ts index fe5881e..211e194 100644 --- a/src/preview-fixtures.ts +++ b/src/preview-fixtures.ts @@ -387,10 +387,23 @@ const RAZER_COBRA: MouseStatus = { const TEEVOLUTION: MouseStatus = { brand: "Teevolution", name: "Terra Pro", - ui: { defaultDisplayName: "Terra Pro", hideUnsupportedPollingRates: true, hideSignalCard: true }, + ui: { + defaultDisplayName: "Terra Pro", + hideUnsupportedPollingRates: true, + hideSignalCard: true, + dpiStageEditor: { + maxStages: 4, + countEditable: true, + minDpi: 50, + maxDpi: 42000, + stepDpi: 50, + }, + }, batteryPercent: 52, batteryState: "Discharging", dpi: 1600, + dpiStages: [400, 800, 1600, 3200], + activeDpiStage: 2, pollingRateHz: 1000, supportedPollingRates: [125, 250, 500, 1000], activeProfile: 1, @@ -516,10 +529,19 @@ const KEYCHRON: MouseStatus = { hideProcessingCard: true, forceShowBattery: true, pollingNote: "Nape Pro exposes polling through Keychron's misc HID commands when the firmware allows it.", + dpiStageEditor: { + maxStages: 5, + countEditable: false, + minDpi: 50, + maxDpi: 4000, + stepDpi: 50, + }, }, batteryPercent: 76, batteryState: "Discharging", dpi: 800, + dpiStages: [400, 800, 1600, 2400, 4000], + activeDpiStage: 1, pollingRateHz: 1000, supportedPollingRates: [500, 1000, 2000, 4000, 8000], activeProfile: null, From c8de226b4eb44b617ef3dd6bd763662e4c3ddd49 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 12 Aug 2026 16:04:21 +1000 Subject: [PATCH 2/2] Match Teevolution DPI lighting CSS to single-line device styles. --- src/control-devices.css | 19 ++----------------- src/device/controller.ts | 3 +-- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/control-devices.css b/src/control-devices.css index 5124f76..a9c99f0 100644 --- a/src/control-devices.css +++ b/src/control-devices.css @@ -6,24 +6,9 @@ #pulsar-advanced input:disabled, #pulsar-advanced select:disabled { cursor: not-allowed; opacity: .45; } #pulsar-advanced input[type="checkbox"] { width: .85rem; height: .85rem; margin: 0; accent-color: var(--ui-accent); } -/* Teevolution DPI indicator lives on the Lighting tab; keep select chrome - readable outside #pulsar-advanced. */ -#teevolution-dpi-lighting select { - width: 100%; - box-sizing: border-box; - margin-top: .2rem; - padding: .48rem .55rem; - border: 1px solid var(--line-strong); - border-radius: 6px; - outline: none; - background: var(--surface-input); - color: var(--text-input); - font: inherit; - color-scheme: dark; -} +#teevolution-dpi-lighting select { width: 100%; box-sizing: border-box; margin-top: .2rem; padding: .48rem .55rem; border: 1px solid var(--line-strong); border-radius: 6px; outline: none; background: var(--surface-input); color: var(--text-input); font: inherit; color-scheme: dark; } #teevolution-dpi-lighting select:focus { border-color: var(--faint); box-shadow: 0 0 0 2px rgb(255 255 255 / 4%); } -#teevolution-dpi-lighting select:disabled, -#teevolution-dpi-lighting input:disabled { cursor: not-allowed; opacity: .45; } +#teevolution-dpi-lighting select:disabled, #teevolution-dpi-lighting input:disabled { cursor: not-allowed; opacity: .45; } #pulsar-advanced.egg-advanced-layout { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; gap: .55rem !important; } #pulsar-advanced.egg-advanced-layout > .setting-card { min-height: 0 !important; padding: .72rem !important; } diff --git a/src/device/controller.ts b/src/device/controller.ts index f2ca89c..136ee77 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -2944,8 +2944,7 @@ async function showFixturePreview(name: PreviewMode): Promise { return; } dpiOptions = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 32000]; - // Populate brand capabilities (e.g. Teevolution DPI lighting profile) so - // preview cards that gate on capabilities still render. + // Populate brand capabilities so preview cards that gate on capabilities still render. capabilities = readCapabilities(); applyStatus(fixture.status); setConnectionButtons(true, "Preview mode");