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..a9c99f0 100644
--- a/src/control-devices.css
+++ b/src/control-devices.css
@@ -6,6 +6,10 @@
#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-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..136ee77 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,8 @@ async function showFixturePreview(name: PreviewMode): Promise {
return;
}
dpiOptions = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 32000];
+ // Populate brand capabilities 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,