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
6 changes: 5 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CaptureDialog } from "./CaptureDialog";
import { Diagnostics, LogitechDetails } from "./Diagnostics";
import { InterfaceSettings } from "./InterfaceSettings";
import { PendingBar } from "./PendingBar";
import { KeychronNapeLayers } from "./KeychronNapeLayers";
import { Profiles } from "./Profiles";
import { Sidebar } from "./Sidebar";
import { Superstrike } from "./Superstrike";
Expand Down Expand Up @@ -155,6 +156,7 @@ function Workspace({
].filter((node) => node !== null);

const showProfiles = show(has.profiles, ["profiles"]);
const showNapeLayers = show(has.keychronNapeLayers, ["profiles"]);
const showSuperstrike = show(has.superstrike, ["buttons"]);
const showLogitechDetails = show(has.logitechDetails, ["advanced"]);
const showMxMaster = on(tab, ["advanced"])
Expand All @@ -163,7 +165,8 @@ function Workspace({
const showOverview = on(tab, ["overview"]);

const anyPanel = performance.length > 0 || advanced.length > 0 || lighting.length > 0
|| showProfiles || showSuperstrike || showLogitechDetails || showMxMaster || showDiagnostics || showOverview;
|| showProfiles || showNapeLayers || showSuperstrike || showLogitechDetails || showMxMaster
|| showDiagnostics || showOverview;

const slotsAvailable = snapshot.profile.slotsAvailable;
const stagesAvailable = Boolean(status.ui?.dpiStageEditor)
Expand All @@ -188,6 +191,7 @@ function Workspace({
) : null}

{showProfiles ? <Profiles snapshot={snapshot} /> : null}
{showNapeLayers ? <KeychronNapeLayers snapshot={snapshot} /> : null}

{performance.length > 0 ? (
<section
Expand Down
324 changes: 324 additions & 0 deletions src/app/KeychronNapeLayers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
import {
KEYCHRON_NAPE_BUTTON_ACTIONS,

Check failure on line 3 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/keychron"' has no exported member 'KEYCHRON_NAPE_BUTTON_ACTIONS'.
KEYCHRON_NAPE_ORIENTATION_OPTIONS,

Check failure on line 4 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/keychron"' has no exported member 'KEYCHRON_NAPE_ORIENTATION_OPTIONS'.
keychronLayerLabel,

Check failure on line 5 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/keychron"' has no exported member 'keychronLayerLabel'.
type KeychronNapeButtonAction,

Check failure on line 6 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Module '"@openmouse/protocol/keychron"' has no exported member 'KeychronNapeButtonAction'.
} from "@openmouse/protocol/keychron";
import * as control from "../device/controller";
import type { ControlSnapshot, NapeAssignmentControl, StagedNapeAssignment } from "../device/types";
import { IconActivate, IconRefresh, IconRunning } from "./icons";

const ROW_STYLE = (open: boolean): CSSProperties => ({
display: "flex",
gap: ".55rem",
alignItems: "center",
padding: ".45rem .6rem",
border: `1px solid ${open ? "#4a4a52" : "#26262a"}`,
borderRadius: "7px",
background: open ? "#1b1b1f" : "#141416",
});

const OPEN_BUTTON_STYLE: CSSProperties = {
display: "flex",
gap: ".55rem",
alignItems: "center",
flex: 1,
minWidth: 0,
textAlign: "left",
background: "none",
border: 0,
padding: 0,
cursor: "pointer",
};

const ICON_BUTTON_STYLE = (disabled: boolean, active = false): CSSProperties => ({
display: "flex",
padding: ".3rem",
border: `1px solid ${active ? "#4a4a52" : "#3a3a3f"}`,
borderRadius: "5px",
background: "#19191c",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.4 : 1,
});

const KEY_ROWS = [
{ col: 2, number: "1", name: "01" },
{ col: 3, number: "2", name: "02" },
{ col: 0, number: "3", name: "03" },
{ col: 1, number: "4", name: "04" },
{ col: 4, number: "5", name: "M1" },
{ col: 5, number: "6", name: "M2" },
] as const;

function pendingKey(layer: number, target: NapeAssignmentControl): string {
if (target.kind === "orientation") return `nape-${layer}-orientation`;
return target.kind === "key"
? `nape-${layer}-col-${target.col}`
: `nape-${layer}-wheel-${target.clockwise ? "cw" : "ccw"}`;
}

function stagedFor(
staged: readonly StagedNapeAssignment[],
layer: number,
target: NapeAssignmentControl,
): StagedNapeAssignment | undefined {
return staged.find((entry) => {
if (entry.layer !== layer) return false;
if (entry.control.kind === "orientation" && target.kind === "orientation") return true;
if (entry.control.kind === "key" && target.kind === "key") return entry.control.col === target.col;
if (entry.control.kind === "wheel" && target.kind === "wheel") {
return entry.control.clockwise === target.clockwise;
}
return false;
});
}

function AssignmentSelect({
layer,
target,
action,
keycode,
staged,
disabled,
}: {
layer: number;
target: NapeAssignmentControl;
action: string;
keycode: number;
staged: StagedNapeAssignment | undefined;
disabled: boolean;
}): ReactNode {
const value = staged?.action ?? action;
return (
<span className="assignment-select-wrap">
<select
value={value}
disabled={disabled}
title={action === "Custom" ? `Unknown mapping: 0x${keycode.toString(16).padStart(4, "0")}` : undefined}
onChange={(event) => {
const next = event.currentTarget.value;
if (next === "Custom") return;
control.applyNapeAssignment(layer, target, next as KeychronNapeButtonAction);
}}
>
{value === "Custom" ? <option value="Custom">Custom mapping (preserved)</option> : null}
{KEYCHRON_NAPE_BUTTON_ACTIONS.map((option) => (

Check failure on line 106 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Parameter 'option' implicitly has an 'any' type.
<option key={option} value={option}>{option}</option>
))}
</select>
<i aria-hidden="true" />
</span>
);
}

function NapeAssignments({ snapshot, layer }: { snapshot: ControlSnapshot; layer: number }): ReactNode {
const map = snapshot.napeKeymap;
const busy = snapshot.settingInProgress;
const ready = map != null && map.layer === layer;
const orientationTarget: NapeAssignmentControl = { kind: "orientation" };
const stagedOrientation = ready && map
? stagedFor(snapshot.stagedNapeAssignments, layer, orientationTarget)
: undefined;
return (
<div className="profile-button-editor">
<div className="profile-button-heading">
<div>
<p>BUTTONS</p>
<h2>Onboard assignments</h2>
<small>Choose this layer's sensor orientation and what each physical control does.</small>
</div>
</div>
{ready && map ? (
<div className="assignment-grid">
<label
className={`assignment-card is-wide${stagedOrientation ? " is-staged" : ""}`}
data-pending-key={pendingKey(layer, orientationTarget)}
>
<span className="assignment-button-number">0</span>
<span className="assignment-button-name">Orientation</span>
<span className="assignment-select-wrap">
<select
value={stagedOrientation?.keycode ?? map.orientationIndex}
disabled={busy}
onChange={(event) => {
control.applyNapeOrientation(layer, Number(event.currentTarget.value));
}}
>
{KEYCHRON_NAPE_ORIENTATION_OPTIONS.map((option) => (

Check failure on line 148 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Parameter 'option' implicitly has an 'any' type.
<option key={option.index} value={option.index}>{option.label}</option>
))}
</select>
<i aria-hidden="true" />
</span>
</label>
{KEY_ROWS.map((row) => {
const key = map.keys.find((entry) => entry.col === row.col);

Check failure on line 156 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Parameter 'entry' implicitly has an 'any' type.
if (!key) return null;
const target: NapeAssignmentControl = { kind: "key", col: row.col };
const staged = stagedFor(snapshot.stagedNapeAssignments, layer, target);
return (
<label
key={row.col}
className={`assignment-card${staged ? " is-staged" : ""}`}
data-pending-key={pendingKey(layer, target)}
>
<span className="assignment-button-number">{row.number}</span>
<span className="assignment-button-name">{row.name}</span>
<AssignmentSelect
layer={layer}
target={target}
action={key.action}
keycode={key.keycode}
staged={staged}
disabled={busy}
/>
</label>
);
})}
{([
{ clockwise: false, number: "7", name: "Scroll wheel Counter Clockwise", current: map.wheel.ccw },
{ clockwise: true, number: "8", name: "Scroll wheel Clockwise", current: map.wheel.cw },
] as const).map((row) => {
const target: NapeAssignmentControl = { kind: "wheel", clockwise: row.clockwise };
const staged = stagedFor(snapshot.stagedNapeAssignments, layer, target);
return (
<label
key={row.name}
className={`assignment-card${staged ? " is-staged" : ""}`}
data-pending-key={pendingKey(layer, target)}
>
<span className="assignment-button-number">{row.number}</span>
<span className="assignment-button-name">{row.name}</span>
<AssignmentSelect
layer={layer}
target={target}
action={row.current.action}
keycode={row.current.keycode}
staged={staged}
disabled={busy}
/>
</label>
);
})}
</div>
) : (
<small className="setting-note">Reading {keychronLayerLabel(layer)}…</small>
)}
<small className="setting-note">
Changes stay on this layer until you apply them. Orientation is stored per layer in 45° steps. Clockwise and counter-clockwise scroll can be set independently.
</small>
</div>
);
}

export function KeychronNapeLayers({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const count = status?.napeLayerCount;

Check failure on line 217 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Property 'napeLayerCount' does not exist on type 'MouseStatus'.
const inner = useRef<HTMLDivElement>(null);
const body = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!body.current || !inner.current) return;
body.current.style.maxHeight = snapshot.profilesExpanded ? `${inner.current.scrollHeight}px` : "0px";
});

if (status == null || count == null || count < 1) return null;

const activeLayer = status.napeLayer ?? 1;

Check failure on line 228 in src/app/KeychronNapeLayers.tsx

View workflow job for this annotation

GitHub Actions / check

Property 'napeLayer' does not exist on type 'MouseStatus'.
const editedLayer = snapshot.editedNapeLayer ?? activeLayer;
const busy = snapshot.settingInProgress;
const tags = [
editedLayer === activeLayer ? "active" : null,
"editing",
].filter(Boolean).join(" · ");

return (
<section
id="nape-layers"
className={`profile-disclosure device-data${snapshot.profilesExpanded ? " is-open" : ""}`}
role="tabpanel"
aria-labelledby="workspace-tab-profiles"
>
<div className="profile-summary">
<button
id="nape-layer-disclosure-toggle"
className="profile-summary-main"
type="button"
aria-expanded={snapshot.profilesExpanded}
aria-controls="nape-layer-disclosure-body"
onClick={control.toggleProfilesExpanded}
>
<span className="profile-summary-text">
<span className="profile-summary-label">EDITING</span>
<strong>{keychronLayerLabel(editedLayer)}{tags ? ` · ${tags}` : ""}</strong>
<small>Onboard layers stored on the Nape Pro</small>
</span>
<i className="profile-summary-chevron" aria-hidden="true" />
</button>
<button
id="nape-layer-refresh"
className="icon-button"
type="button"
aria-label="Reload layers"
title="Reload layers"
disabled={busy}
onClick={() => void control.reloadNapeLayers()}
>
<IconRefresh />
</button>
</div>

<div id="nape-layer-disclosure-body" className="profile-disclosure-body" ref={body}>
<div className="profile-disclosure-inner" ref={inner}>
<div id="nape-layer-list">
<small style={{ display: "block", margin: "0 0 .2rem", color: "#5c5c62", fontSize: ".58rem" }}>
Click a layer to inspect it. Use the circle to switch the mouse to it.
</small>
{Array.from({ length: count }, (_, index) => {
const layer = index + 1;
const opened = editedLayer === layer;
const running = activeLayer === layer;
const rowTags = [running ? "active" : null, opened ? "editing" : null]
.filter(Boolean)
.join(" · ");
return (
<div key={layer} style={ROW_STYLE(opened)}>
<button
type="button"
title="Open this layer"
style={OPEN_BUTTON_STYLE}
onClick={() => control.openNapeLayer(layer)}
>
<span className={`device-dot${running ? "" : " is-idle"}`} />
<span className="profile-row-text" style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<strong style={{ fontSize: ".72rem", color: "#e6e6ea" }}>
{keychronLayerLabel(layer)}{rowTags ? ` · ${rowTags}` : ""}
</strong>
<small style={{ color: "#77777c", fontSize: ".62rem" }}>
{running ? "Current layer on the mouse" : "Stored on the mouse"}
</small>
</span>
</button>
<button
type="button"
disabled={busy || running}
title={running ? "The mouse is already on this layer" : `Switch the mouse to ${keychronLayerLabel(layer)}`}
aria-label={`Switch to ${keychronLayerLabel(layer)}`}
aria-pressed={running}
style={ICON_BUTTON_STYLE(busy || running, running)}
onClick={() => void control.switchNapeLayer(layer)}
>
{running ? <IconRunning /> : <IconActivate />}
</button>
</div>
);
})}
</div>
<small id="onboard-status">{snapshot.onboardStatus}</small>
<NapeAssignments snapshot={snapshot} layer={editedLayer} />
</div>
</div>
</section>
);
}
19 changes: 19 additions & 0 deletions src/app/cards/availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ test("onboard profiles need a Logitech mouse reporting a known mode", () => {
})).profiles, false);
});

test("Keychron Nape Pro layers appear on the profiles tab when VIA reports a count", () => {
const withoutLayers = cardAvailability(snapshot({
status: { brand: "Keychron", ui: { family: "keychron-nape", showAdvancedSection: true } },
}));
assert.equal(withoutLayers.keychronNapeLayers, false);
assert.equal(withoutLayers.profiles, false);

const withLayers = cardAvailability(snapshot({
status: {
brand: "Keychron",
ui: { family: "keychron-nape", showAdvancedSection: true },
napeLayer: 2,
napeLayerCount: 8,
},
}));
assert.equal(withLayers.keychronNapeLayers, true);
assert.equal(withLayers.profiles, false);
});

test("HITS tuning needs exactly the two primary buttons", () => {
const tuning = { maxActuation: 10, maxRapidTrigger: 5, maxHaptics: 5 };
assert.equal(cardAvailability(snapshot({
Expand Down
Loading