Skip to content
Merged
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,
KEYCHRON_NAPE_ORIENTATION_OPTIONS,
keychronLayerLabel,
type 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) => (
<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) => (
<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);
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;
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;
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
Loading