Skip to content
Open
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
68 changes: 46 additions & 22 deletions src/app/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import * as control from "../device/controller";
import { isNoirE1Status, isNoirKsnakeStatus, noirBrandLabel } from "../device/noir.ts";
import type { ControlSnapshot, WorkspaceTab } from "../device/types";
import { t, tp, connectionText, type I18nKey } from "../i18n";
import { Diagnostics, LogitechDetails } from "./Diagnostics";
Expand Down Expand Up @@ -94,10 +95,22 @@ const M2_NEX_BUTTON_MARKER_POSITIONS: Readonly<Record<string, ButtonMarkerPositi
"scroll down": { x: 0.62, y: 0.29 },
};

const NOIR_E1_BUTTON_MARKER_POSITIONS: Readonly<Record<string, ButtonMarkerPosition>> = {
left: { x: 0.28, y: 0.2 },
right: { x: 0.72, y: 0.2 },
middle: { x: 0.5, y: 0.18 },
forward: { x: 0.08, y: 0.32 },
backward: { x: 0.08, y: 0.5 },
};

function m2NexButtonMarkerPosition(button: string): ButtonMarkerPosition | null {
return M2_NEX_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null;
}

function noirE1ButtonMarkerPosition(button: string): ButtonMarkerPosition | null {
return NOIR_E1_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null;
}

// Device artwork requests go straight to the documented GitHub issue form
// (public/devices/README.md, .github/ISSUE_TEMPLATE/device-artwork.yml).
const ARTWORK_ISSUE_URL = "https://github.com/OpenMouse-Project/openmouse/issues/new?template=device-artwork.yml";
Expand Down Expand Up @@ -219,24 +232,24 @@ function DeviceShowcase({ snapshot }: {
function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const locale = snapshot.preferences.locale;
if (status?.brand !== "Noir Gear" || status.name !== "M2-NEX" || snapshot.m2nexProfiles === null) return null;
if (!status || !isNoirKsnakeStatus(status) || snapshot.m2nexProfiles === null) return null;
const profile = snapshot.m2nexProfiles[snapshot.activeM2NexProfile];
if (!profile) return null;
const applyDisabled = snapshot.settingInProgress || snapshot.pending.count > 0;
return (
<section
id="m2nex-profile-overview"
className="m2nex-profile-overview"
aria-label={t(locale, "m2nex.controls")}
aria-label={`${status.name} ${t(locale, "m2nex.controls")}`}
>
<div className="m2nex-profile-overview-title">
<span>{t(locale, "m2nex.overline")}</span>
<strong>{t(locale, "m2nex.label")}</strong>
<strong>{status.name}</strong>
</div>
<label className="m2nex-profile-overview-picker" htmlFor="m2nex-profile-overview-select">
<select
id="m2nex-profile-overview-select"
aria-label={t(locale, "m2nex.select")}
aria-label={`${status.name} profile`}
value={snapshot.activeM2NexProfile}
disabled={snapshot.settingInProgress || snapshot.pending.busy}
onChange={(event) => control.selectM2NexProfile(Number(event.currentTarget.value))}
Expand Down Expand Up @@ -275,9 +288,9 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
if (!status) return null;
const locale = snapshot.preferences.locale;
const image = snapshot.deviceArtwork;
const isNoirE1 = isNoirE1Status(status);
const showButtonMarkers = snapshot.workspaceTab === "buttons"
&& status.brand === "Noir Gear"
&& status.name === "M2-NEX"
&& isNoirKsnakeStatus(status)
&& status.buttonMappings != null;

return (
Expand All @@ -295,20 +308,30 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
}}
/>
<div className="button-map-markers" aria-hidden="true">
{Object.keys(status.buttonMappings ?? {}).map((button, index) => {
const position = m2NexButtonMarkerPosition(button);
if (!position) return null;
return (
<span
key={button}
className="button-map-marker"
style={{ left: `${position.x * 100}%`, top: `${position.y * 100}%` }}
>
{String(index + 1).padStart(2, "0")}
</span>
);
})}
{Object.keys(status.buttonMappings ?? {})
.filter((button) => !isNoirE1 || button.trim().toLowerCase() !== "dpi")
.map((button, index) => {
const position = isNoirE1 ? noirE1ButtonMarkerPosition(button) : m2NexButtonMarkerPosition(button);
if (!position) return null;
return (
<span
key={button}
className="button-map-marker"
style={{ left: `${position.x * 100}%`, top: `${position.y * 100}%` }}
>
{String(index + 1).padStart(2, "0")}
</span>
);
})}
</div>
{isNoirE1 ? (
<div className="button-map-underside-callout" aria-hidden="true">
<span className="button-map-underside-badge">
<span className="button-map-underside-number">06</span>
<span>DPI <small>UNDERSIDE</small></span>
</span>
</div>
) : null}
</div>
) : (
<img
Expand All @@ -324,7 +347,7 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
</div>
<div className="showcase-sidebar-info">
<h2 className="showcase-sidebar-name">{status.name}</h2>
<p className="showcase-sidebar-brand">{status.brand}</p>
<p className="showcase-sidebar-brand">{noirBrandLabel(status)}</p>
</div>
<div className="showcase-sidebar-status">
<span className="device-showcase-dot" aria-hidden="true" />
Expand Down Expand Up @@ -674,7 +697,8 @@ function DeviceListView({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
const pollText = liveStatus?.pollingRateHz ? `${liveStatus.pollingRateHz} Hz` : "–";
const batteryText = liveStatus?.batteryPercent != null ? `${liveStatus.batteryPercent}%` : "–%";
const imageSrc = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name);
const imageClass = deviceImageFilename(device.name) === "noir-m2-nex.png"
const imageFilename = deviceImageFilename(device.name);
const imageClass = imageFilename === "noir-m2-nex.png" || imageFilename === "noir-e1.png"
? " device-tile-image-is-portrait"
: "";
return (
Expand Down Expand Up @@ -767,7 +791,7 @@ export function OverviewPage({
const locale = preferences.locale;

const tabs = availableWorkspaceTabs(status !== null, cardAvailability(snapshot), {
hideLighting: status?.brand === "Noir Gear" && status.name === "M2-NEX",
hideLighting: isNoirKsnakeStatus(status),
});
const workspaceTab = availableWorkspaceTab(snapshot.workspaceTab, tabs);
const workspaceSnapshot = workspaceTab === snapshot.workspaceTab
Expand Down
11 changes: 5 additions & 6 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
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 * 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 @@ -1541,8 +1542,8 @@ function cloneKsnakeMacroProfile(profile: KsnakeMacroProfile | undefined): Ksnak
export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const locale = snapshot.preferences.locale;
const isM2Nex = status?.brand === "Noir Gear" && status.name === "M2-NEX";
const macroCapable = status?.ui?.family === "ksnake" || isM2Nex;
const isNoirKsnake = isNoirKsnakeStatus(status);
const macroCapable = status?.ui?.family === "ksnake" || isNoirKsnake;
// Keep the card usable while an older hot-reloaded controller snapshot is
// still missing the newly added field. Treat that state as "not loaded" so
// the local editor is prepared instead of silently hiding the card.
Expand Down Expand Up @@ -1756,7 +1757,7 @@ export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): Re
</div>
</div>
<p className="ksnake-macro-notice">
{isM2Nex ? t(locale, "macro.noticeM2nex") : t(locale, "macro.notice")}
{isNoirKsnake ? t(locale, "macro.noticeM2nex") : t(locale, "macro.notice")}
</p>
<label className="ksnake-macro-slot-picker">
<span>{t(locale, "macro.slotLabel")}</span>
Expand Down Expand Up @@ -1940,9 +1941,7 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }):
if (!status?.buttonMappings || !status.buttonOptions?.length) return null;
const locale = snapshot.preferences.locale;
const options = status.buttonOptions;
const isM2Nex = status.brand === "Noir Gear"
&& status.name === "M2-NEX"
&& snapshot.m2nexProfiles !== null;
const isM2Nex = isNoirKsnakeStatus(status) && snapshot.m2nexProfiles !== null;
const selectedM2NexProfile = isM2Nex
? snapshot.m2nexProfiles?.[snapshot.activeM2NexProfile]
: null;
Expand Down
14 changes: 11 additions & 3 deletions src/app/cards/DpiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@openmouse/protocol/drivers/logitech/onboard-profiles";
import * as control from "../../device/controller";
import type { ControlSnapshot, LiftOffLevel } from "../../device/types";
import { isNoirE1Status, isNoirKsnakeStatus, NOIR_DPI_STAGE_COLORS } from "../../device/noir.ts";
import { closestDpiOption, dpiPresetValues } from "../../dpi-presets";
import { t, tp } from "../../i18n";
import { LiftOffDistance, hasLiftOff } from "./PerformanceCards";
Expand Down Expand Up @@ -48,7 +49,11 @@ function DpiStageEditor({

const naturalMode: "logitech" | "stage" | "generic" = isLogitech ? "logitech" : isStage ? "stage" : "generic";
const mode: "logitech" | "stage" | "generic" = editorView === "single" ? "generic" : naturalMode;
const showM2NexStageColors = mode === "stage" && status?.brand === "Noir Gear" && status.name === "M2-NEX";
const stageColors = mode === "stage"
? status?.dpiStageColors ?? (isNoirKsnakeStatus(status) ? NOIR_DPI_STAGE_COLORS : null)
: null;
const showM2NexStageColors = mode === "stage"
&& (stageColors?.length ?? 0) >= (status?.dpiStages?.length ?? 0);
const limits = snapshot.profile.slotLimits;
// The single-DPI (generic) view writes the live DPI, not the profile, so it
// must not be gated behind the profile's slot-write lock.
Expand Down Expand Up @@ -286,10 +291,12 @@ function DpiStageEditor({
: false;
const highlighted = mode === "generic" ? row.enabled : mode === "stage" ? isActive === true : isStarting;
const stageColorClass = showM2NexStageColors ? ` m2nex-stage-${index + 1}` : "";
const stageColor = showM2NexStageColors ? stageColors?.[index] : undefined;
return (
<div
key={index}
className={`dpi-editor-row${row.enabled ? "" : " is-off"}${highlighted ? " is-active" : ""}${stageColorClass}`}
style={stageColor ? { "--dpi-stage-color": stageColor } as CSSProperties : undefined}
>
<label className="dpi-editor-check">
<input
Expand Down Expand Up @@ -453,6 +460,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
|| (Boolean(status?.ui?.dpiStageEditor)
&& Array.isArray(status?.dpiStages)
&& (status?.dpiStages?.length ?? 0) > 0);
const singleDpiAvailable = !isNoirE1Status(status);

const label = (source: typeof status): string => `${source.dpi.toLocaleString()} DPI`;

Expand All @@ -473,7 +481,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
) : null}
</h2>
</div>
{stageCapable ? (
{stageCapable && singleDpiAvailable ? (
<div
className="dpi-view-toggle"
role="group"
Expand Down Expand Up @@ -535,7 +543,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode

<DpiStageEditor
snapshot={snapshot}
editorView={stageCapable ? editorView : undefined}
editorView={stageCapable && singleDpiAvailable ? editorView : stageCapable ? "stage" : undefined}
/>

<div className="setting-action">
Expand Down
2 changes: 2 additions & 0 deletions src/app/cards/PerformanceCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
reportRatesFor,
} from "@openmouse/protocol/drivers/logitech/onboard-profiles";
import * as control from "../../device/controller";
import { isNoirE1Status } from "../../device/noir.ts";
import { isNativeAttackSharkX11, RATE_STEPS_HZ } from "../../device/controller";
import type { ControlSnapshot, LiftOffLevel } from "../../device/types";
import { t, tp } from "../../i18n";
Expand Down Expand Up @@ -204,6 +205,7 @@ export function SensorCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNo
export function hasLiftOff(snapshot: ControlSnapshot): boolean {
const status = snapshot.status;
if (!status || snapshot.profile.slotsAvailable) return false;
if (isNoirE1Status(status)) return false;
return Boolean(status.asymmetricLiftOff)
|| Boolean(status.liftOffScale)
|| status.liftOffDistance != null
Expand Down
28 changes: 28 additions & 0 deletions src/app/cards/availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,34 @@ test("the generic button card follows the driver\'s mappings, not the advanced s
assert.equal(has.processing, false, "the rest of the advanced section stays closed");
});

test("NOIR E1 gets the same core configurator controls even without a family hint", () => {
const has = cardAvailability(snapshot({
status: {
brand: "K-snake",
name: "NOIR E1",
ui: { showAdvancedSection: true },
buttonMappings: {
Left: "Left click",
Right: "Right click",
Middle: "Middle click",
Forward: "Forward",
Backward: "Backward",
DPI: "DPI loop",
},
buttonOptions: ["Left click", "Right click", "Scroll up", "DPI loop"],
dpiStages: [800, 1200, 1600, 3200, 5000, 12000],
scrollDirection: "Forward",
lighting: {} as never,
},
}));
assert.equal(has.dpi, true);
assert.equal(has.polling, true);
assert.equal(has.buttonMapping, true);
assert.equal(has.ksnakeScroll, true);
assert.equal(has.ksnakeMacros, true);
assert.equal(has.lightingAdvanced, false);
});

test("the G-Wolves XVI generation opens sleep, debounce and processing, but not signal", () => {
const cards = cardAvailability(snapshot({
status: {
Expand Down
10 changes: 5 additions & 5 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { selectableValues } from "../../device/options.ts";
import { isPulsarProProtocol } from "../../device/traits.ts";
import { isNoirKsnakeStatus } from "../../device/noir.ts";
import type { ControlSnapshot } from "../../device/types";

export interface CardAvailability {
Expand Down Expand Up @@ -95,7 +96,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
const { traits, capabilities } = snapshot;
const ready = !snapshot.settingsPending;
const host = traits.advancedSection;
const isM2Nex = status.brand === "Noir Gear" && status.name === "M2-NEX";
const isNoirKsnake = isNoirKsnakeStatus(status);

const sensor = !(!status.gamingSurfaceMode
&& Array.isArray(status.supportedLiftOffDistances)
Expand Down Expand Up @@ -137,14 +138,13 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
lightforce: Boolean(status.lightforceSwitchMode),
superstrike: traits.logitech && status.analogButtonTuning?.buttons.length === 2,
lighting: Boolean(status.lighting || status.lightingZones?.length),
lightingAdvanced: host && !isM2Nex && Boolean(status.lighting || status.lightingZones?.length),
lightingAdvanced: host && !isNoirKsnake && Boolean(status.lighting || status.lightingZones?.length),
onboardProfiles: (status.profileCount ?? 0) > 1 && status.activeProfile != null,
buttonMapping: Boolean(status.buttonMappings) && Boolean(status.buttonOptions?.length),
// K-snake/M2-NEX exposes the macro write/commit path. The editor starts
// with local slots because this firmware does not expose a reliable macro
// read through the browser.
ksnakeMacros: ui?.family === "ksnake"
|| (status.brand === "Noir Gear" && status.name === "M2-NEX"),
ksnakeMacros: ui?.family === "ksnake" || isNoirKsnake,
powerMode: host && Boolean(status.powerModes?.length),
profiles: traits.logitech
&& status.deviceMode !== undefined && status.deviceMode !== "Unknown",
Expand All @@ -157,7 +157,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
&& status.debounceMs !== null && status.debounceMs !== undefined,
sleep: host && (traits.sleep || capabilities?.sleepOptions != null || razerSleep)
&& ui?.hideSleepCard !== true,
ksnakeScroll: ui?.family === "ksnake" && status.scrollDirection != null,
ksnakeScroll: (ui?.family === "ksnake" || isNoirKsnake) && status.scrollDirection != null,
lowPower: host && razerLowPower,
processing: host && processing,
ninjutsoSensor: host && traits.ninjutso
Expand Down
47 changes: 47 additions & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,53 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
line-height: 1;
transform: translate(-50%, -50%);
}
.button-map-underside-callout {
display: flex;
flex-direction: column;
align-items: center;
margin-top: .18rem;
line-height: 1;
}
.button-map-underside-callout::before {
width: 1px;
height: .72rem;
border-radius: 2px;
background: #727985;
content: "";
}
.button-map-underside-badge {
display: inline-flex;
align-items: center;
gap: .42rem;
padding: .2rem .58rem .2rem .22rem;
border: 1px solid #41464f;
border-radius: 999px;
background: #24272d;
box-shadow: 0 2px 8px rgb(0 0 0 / 32%);
color: #e0e3e8;
font: 650 .59rem var(--font-mono);
letter-spacing: .04em;
text-transform: uppercase;
}
.button-map-underside-badge small {
margin-left: .2rem;
color: #9ba2ac;
font: inherit;
font-size: .51rem;
letter-spacing: .06em;
}
.button-map-underside-number {
display: grid;
width: 1.42rem;
height: 1.42rem;
place-items: center;
border: 1px solid #fff;
border-radius: 50%;
background: #f2f3f5;
color: #202329;
font: 700 .57rem var(--font-mono);
letter-spacing: .02em;
}
.showcase-sidebar-info { width: 100%; min-width: 0; margin-top: 1rem; }
.showcase-sidebar-name {
margin: 0;
Expand Down
Loading
Loading