Use the One Key
Bluetooth Pairing
You can pair your One Key with your computer or mobile device in the usual ways. Here's an example of pairing it on a Windows laptop.
Turn on the One Key -- it will start up and the NeoPixels will display red, indicating it is unpaired.
The ESP32 BLE KB3 device will show up in the list of available Bluetooth devices. Click it.
You'll see the One Key in the Input device list, and it'll auto-connect until you decide to remove it.
You will probably want to change features such as which key to press, NeoPixel color, and sleep timout at some point. Instead of editing the Arduino sketch, recompiling it, and uploading it to the One Key QT Py ESP32, you can use serial commands to do these things.
Configuration settings are stored in NVS (non-volatile storage in the ESP32's built-in flash) and can be changed via serial.
For example, with the One Key plugged in to your computer, open the Arduino Serial Monitor and type in x then press enter. The One Key will now send an 'x' key when pressed. This storage survives restarts and power cycling.
The One Key keyboard listens on the USB serial port at 115200 baud. You can send commands from Arduino IDE's Serial Monitor, or any terminal app. Make sure line ending is set to Newline (not "No line ending" or "Both NL & CR").
Key commands Just type the key or combo and press Enter:
-
q— single printable character -
tab,return,esc,backspace,delete,space: special keys -
up,down,left,right,home,end,pageup,pagedown,insert: navigation -
f1throughf24: function keys -
ctrl+z,alt+tab,shift+f5,ctrl+shift+z: modifier combos (up to 2 modifiers)
String commands
-
"hello world"— types a string -
"git status"+return— types a string then presses a key
Media key commands
-
volumeup,volumedown,mute,playpause,nexttrack,prevtrack,stop
LED commands
-
color:connected:0,255,0: set connected color as R,G,B (0–255 each) -
color:pressed:255,20,100: set pressed color as R,G,B -
bright:128: set brightness 0–255
Sleep command
-
sleep:5: sleep after 5 minutes of inactivity -
sleep:0: disable sleep entirely
All settings are saved to NVS flash immediately and persist across power cycles. The device echoes a confirmation message after every successful command.
WebSerial
After adding all of these commands, I figured I'd need a cheat sheet to remember them all, and that bummed me out. What if, I thought, what if there were a GUI for configuring it? WebSerial to the rescue!
I created this WebSerial web page that you can access here, or run locally with the included index.html file that acts as a front end for all of the above serial commands.
Connect
Plug in the One Key over USB, then click the Connect button.
In the pop-up window, select your serial port, then press the pop-up window's Connect button.
You'll see the "Connected" light turn green, and the current configuration should be filled in.
Single Key
To change a single key, click in the "Click to capture a keypress" box and type a key. Then click the Send button.
You can alternatively pick special keys it from the drop-down menu.
The One Key will now type your new single key.
Key Combo
You can pick a key combo, such as Ctrl-z / Command-z for undo, or Ctrl+Alt+Delete by clicking the Key Combo button, and then picking modifiers (Ctrl, Shift, Alt, GUI) and a key or special key.
Then click Send to save it to the One Key.
String
Have the One Key type out an entire message by using the String button and typing your characters (including alphabet, numerals, punctuation, symbols, and capitalization).
Click Send to save to the One Key.
String + Key
To have the One Key enter a full command, use String + Key. Then, type in your text and then a Followed by key, such as Return / Enter.
Media Keys
The Media Key section allows you to pick consumer controls:
- Volume Up
- Volume Down
- Mute
- Play / Pause
- Next Track
- Previous Track
- Stop
NeoPixel LED Settings
Adjust the connected color and pressed color as well as brightness for the NeoKey BFF NeoPixel and the QT PY ESP32 on-board NeoPixel in the Led Settings section. Press Apply when done.
Sleep Timeout
Change the number of minutes of inactivity before the One Key goes to sleep here. Using a setting of 0 will disable sleep entirely. Click Set to send to the One Key.
<!doctype html>
<!--
SPDX-FileCopyrightText: 2026 John Park for Adafruit Industries
SPDX-License-Identifier: MIT
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>One Key Keyboard — Configurator</title>
<style>
:root {
--bg: #000000;
--surface: #12232e;
--card: #4c6271;
--accent: #a5b1b8;
--accent2: #a5b1b8;
--text: #eaeaea;
--muted: #8892a4;
--green: #00bf00;
--red: #e94560;
--radius: 2px;
--font: "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
}
h1 {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: 4px;
}
h1 span {
color: var(--accent);
}
.subtitle {
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 28px;
}
.card {
background: var(--surface);
border: 1px solid #1e3a5f;
border-radius: var(--radius);
padding: 18px;
width: 100%;
max-width: 560px;
margin-bottom: 2px;
}
.card h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--accent2);
margin-bottom: 16px;
}
.connect-row {
display: flex;
align-items: center;
gap: 12px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
transition: background 0.3s;
}
.dot.connected {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
.dot.error {
background: var(--red);
}
#status-text {
color: var(--muted);
font-size: 0.9rem;
flex: 1;
}
button {
background: var(--accent);
color: #fff;
border: none;
border-radius: 2px;
padding: 10px 20px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
white-space: nowrap;
}
button:hover {
opacity: 0.85;
}
button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
button.secondary {
background: transparent;
border: 1px solid var(--accent2);
color: var(--accent2);
}
.current-combo {
background: var(--card);
border-radius: 2px;
padding: 14px 18px;
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 1px;
color: var(--accent2);
font-family: monospace;
min-height: 46px;
}
.current-label {
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 1px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.tab {
background: transparent;
border: 1px solid #2a4a6e;
color: var(--muted);
padding: 7px 14px;
font-size: 0.82rem;
border-radius: 0;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.tab:hover {
border-color: var(--accent2);
color: var(--accent2);
}
.tab.active {
background: var(--accent2);
border-color: var(--accent2);
color: #000;
font-weight: 700;
}
.mode-panel {
display: none;
flex-direction: column;
gap: 14px;
}
.mode-panel.active {
display: flex;
}
label {
font-size: 0.82rem;
color: var(--muted);
display: block;
margin-bottom: 5px;
}
input[type="text"],
select {
width: 100%;
background: var(--card);
border: 1px solid #2a4a6e;
border-radius: 0;
color: var(--text);
padding: 10px 12px;
font-size: 0.95rem;
font-family: monospace;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus,
select:focus {
border-color: var(--accent2);
}
select option {
background: var(--card);
}
.modifiers {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.mod-check {
display: flex;
align-items: center;
gap: 6px;
background: var(--card);
border: 1px solid #2a4a6e;
border-radius: 0;
padding: 8px 14px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
user-select: none;
}
.mod-check input {
display: none;
}
.mod-check.checked {
border-color: var(--accent2);
color: var(--accent2);
background: #0a2040;
}
.key-capture {
background: var(--card);
border: 1px solid #2a4a6e;
border-radius: 0;
padding: 10px 12px;
font-size: 0.95rem;
font-family: monospace;
cursor: pointer;
min-height: 42px;
display: flex;
align-items: center;
transition: border-color 0.2s;
color: var(--muted);
}
.key-capture.listening {
border-color: var(--accent);
color: var(--accent);
animation: pulse 1s infinite;
}
.key-capture.has-key {
color: var(--text);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.send-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 4px;
}
.preview {
flex: 1;
font-family: monospace;
font-size: 0.85rem;
color: var(--muted);
background: var(--card);
border-radius: 0;
padding: 10px 12px;
border: 1px solid #2a4a6e;
min-height: 42px;
display: flex;
align-items: center;
word-break: break-all;
}
.preview span {
color: var(--accent2);
}
#log {
background: #0a0f1e;
border-radius: 0;
padding: 12px;
font-family: monospace;
font-size: 0.8rem;
height: 140px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.log-line {
line-height: 1.5;
}
.log-line.tx {
color: var(--accent2);
}
.log-line.rx {
color: var(--green);
}
.log-line.info {
color: var(--muted);
}
.log-line.err {
color: var(--red);
}
.not-supported {
background: #3a1010;
border: 1px solid var(--red);
border-radius: var(--radius);
padding: 16px 20px;
color: var(--red);
font-size: 0.9rem;
max-width: 560px;
width: 100%;
margin-bottom: 20px;
display: none;
}
/* LED settings */
.led-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.led-row:last-child {
margin-bottom: 0;
}
.led-row label {
margin-bottom: 0;
flex: 1;
}
.color-swatch {
width: 36px;
height: 36px;
border-radius: 2px;
border: 1px solid #2a4a6e;
flex-shrink: 0;
cursor: pointer;
transition: border-color 0.2s;
}
.color-swatch:hover {
border-color: var(--accent2);
}
input[type="color"] {
width: 0;
height: 0;
opacity: 0;
position: absolute;
pointer-events: none;
}
input[type="range"] {
flex: 1;
accent-color: var(--accent2);
height: 6px;
cursor: pointer;
}
.bright-val {
font-family: monospace;
font-size: 0.9rem;
color: var(--accent2);
width: 32px;
text-align: right;
flex-shrink: 0;
}
.led-send-row {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
</head>
<body>
<h1>One Key Keyboard</h1>
<p class="subtitle">WebSerial Configurator — open in Chrome or Edge</p>
<div class="not-supported" id="not-supported">
⚠️ WebSerial is not supported in this browser. Please open this file
in Chrome or Edge.
</div>
<!-- Connection -->
<div class="card">
<h2>Connection</h2>
<div class="connect-row">
<div class="dot" id="dot"></div>
<span id="status-text">Not connected</span>
<button id="connect-btn">Connect</button>
<button class="secondary" id="disconnect-btn" disabled>
Disconnect
</button>
</div>
</div>
<!-- Current config -->
<div class="card">
<h2>Current Configuration</h2>
<div class="current-label">Active combo on device</div>
<div class="current-combo" id="current-combo">—</div>
</div>
<!-- Config editor -->
<div class="card">
<h2>Set New Combo</h2>
<div class="tabs">
<div class="tab active" data-mode="single">Single Key</div>
<div class="tab" data-mode="combo">Key Combo</div>
<div class="tab" data-mode="string">String</div>
<div class="tab" data-mode="string-key">String + Key</div>
<div class="tab" data-mode="media">Media Key</div>
</div>
<!-- Single key -->
<div class="mode-panel active" id="panel-single">
<div>
<label>Click to capture a keypress</label>
<div class="key-capture" id="capture-single" tabindex="0">
Click to capture key...
</div>
</div>
<div>
<label>Or select a special key</label>
<select id="special-single">
<option value="">— select —</option>
<option value="tab">Tab</option>
<option value="return">Return / Enter</option>
<option value="esc">Esc</option>
<option value="backspace">Backspace</option>
<option value="delete">Delete</option>
<option value="space">Space</option>
<option value="up">Up Arrow</option>
<option value="down">Down Arrow</option>
<option value="left">Left Arrow</option>
<option value="right">Right Arrow</option>
<option value="home">Home</option>
<option value="end">End</option>
<option value="pageup">Page Up</option>
<option value="pagedown">Page Down</option>
<option value="insert">Insert</option>
<option value="f1">F1</option>
<option value="f2">F2</option>
<option value="f3">F3</option>
<option value="f4">F4</option>
<option value="f5">F5</option>
<option value="f6">F6</option>
<option value="f7">F7</option>
<option value="f8">F8</option>
<option value="f9">F9</option>
<option value="f10">F10</option>
<option value="f11">F11</option>
<option value="f12">F12</option>
</select>
</div>
</div>
<!-- Combo -->
<div class="mode-panel" id="panel-combo">
<div>
<label>Modifiers</label>
<div class="modifiers">
<label class="mod-check" id="mod-ctrl"
><input type="checkbox" value="ctrl" />Ctrl</label
>
<label class="mod-check" id="mod-shift"
><input type="checkbox" value="shift" />Shift</label
>
<label class="mod-check" id="mod-alt"
><input type="checkbox" value="alt" />Alt</label
>
<label class="mod-check" id="mod-gui"
><input type="checkbox" value="gui" />GUI
⌘/Win</label
>
</div>
</div>
<div>
<label>Key (click to capture)</label>
<div class="key-capture" id="capture-combo" tabindex="0">
Click to capture key...
</div>
</div>
<div>
<label>Or select a special key</label>
<select id="special-combo">
<option value="">— select —</option>
<option value="tab">Tab</option>
<option value="return">Return / Enter</option>
<option value="esc">Esc</option>
<option value="backspace">Backspace</option>
<option value="delete">Delete</option>
<option value="space">Space</option>
<option value="up">Up Arrow</option>
<option value="down">Down Arrow</option>
<option value="left">Left Arrow</option>
<option value="right">Right Arrow</option>
<option value="home">Home</option>
<option value="end">End</option>
<option value="pageup">Page Up</option>
<option value="pagedown">Page Down</option>
<option value="insert">Insert</option>
<option value="f1">F1</option>
<option value="f2">F2</option>
<option value="f3">F3</option>
<option value="f4">F4</option>
<option value="f5">F5</option>
<option value="f6">F6</option>
<option value="f7">F7</option>
<option value="f8">F8</option>
<option value="f9">F9</option>
<option value="f10">F10</option>
<option value="f11">F11</option>
<option value="f12">F12</option>
</select>
</div>
</div>
<!-- String -->
<div class="mode-panel" id="panel-string">
<div>
<label>Text to type</label>
<input
type="text"
id="string-input"
placeholder="e.g. hello world"
/>
</div>
</div>
<!-- String + key -->
<div class="mode-panel" id="panel-string-key">
<div>
<label>Text to type</label>
<input
type="text"
id="string-key-input"
placeholder="e.g. git status"
/>
</div>
<div>
<label>Followed by key</label>
<select id="tail-key">
<option value="return">Return / Enter</option>
<option value="tab">Tab</option>
<option value="esc">Esc</option>
<option value="space">Space</option>
<option value="f1">F1</option>
<option value="f2">F2</option>
<option value="f3">F3</option>
<option value="f4">F4</option>
<option value="f5">F5</option>
</select>
</div>
</div>
<!-- Media key -->
<div class="mode-panel" id="panel-media">
<div>
<label>Media key</label>
<select id="media-key">
<option value="volumeup">Volume Up</option>
<option value="volumedown">Volume Down</option>
<option value="mute">Mute</option>
<option value="playpause">Play / Pause</option>
<option value="nexttrack">Next Track</option>
<option value="prevtrack">Previous Track</option>
<option value="stop">Stop</option>
</select>
</div>
</div>
<!-- Preview + Send -->
<div class="send-row" style="margin-top: 18px">
<div class="preview" id="preview"><span>—</span></div>
<button class="secondary" id="reset-btn">Reset</button>
<button id="send-btn" disabled>Send</button>
</div>
</div>
<!-- LED Settings -->
<div class="card">
<h2>LED Settings</h2>
<!-- Connected color -->
<div class="led-row">
<label>Connected color</label>
<div
class="color-swatch"
id="swatch-connected"
style="background: #00ff00"
title="Click to change"
></div>
<input type="color" id="picker-connected" value="#00ff00" />
</div>
<!-- Pressed color -->
<div class="led-row">
<label>Pressed color</label>
<div
class="color-swatch"
id="swatch-pressed"
style="background: #ff1464"
title="Click to change"
></div>
<input type="color" id="picker-pressed" value="#ff1464" />
</div>
<!-- Brightness -->
<div class="led-row">
<label>Brightness</label>
<input
type="range"
id="bright-slider"
min="0"
max="255"
value="255"
/>
<span class="bright-val" id="bright-val">255</span>
</div>
<div class="led-send-row">
<button id="led-btn" disabled>Apply</button>
</div>
</div>
<!-- Sleep timeout -->
<div class="card">
<h2>Sleep Timeout</h2>
<div style="display: flex; align-items: center; gap: 12px">
<div style="flex: 1">
<label
>Minutes of inactivity before sleep (0 =
disabled)</label
>
<input
type="number"
id="sleep-input"
min="0"
max="255"
value="5"
style="
width: 100%;
background: var(--card);
border: 1px solid #2a4a6e;
border-radius: 0;
color: var(--text);
padding: 10px 12px;
font-size: 0.95rem;
font-family: monospace;
outline: none;
"
/>
</div>
<button id="sleep-btn" disabled style="margin-top: 20px">
Set
</button>
</div>
</div>
<!-- Log -->
<div class="card">
<h2>Serial Log</h2>
<div id="log"></div>
</div>
<p class="subtitle">John Park for Adafruit 2026</p>
<script>
let port = null,
reader = null,
writer = null;
let currentMode = "single";
let capturedKey = { single: "", combo: "" };
let readBuffer = "";
if (!("serial" in navigator)) {
document.getElementById("not-supported").style.display =
"block";
}
function log(msg, type = "info") {
const el = document.getElementById("log");
const line = document.createElement("div");
line.className = `log-line ${type}`;
const ts = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
line.textContent = `[${ts}] ${msg}`;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
}
document
.getElementById("connect-btn")
.addEventListener("click", async () => {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
writer = port.writable.getWriter();
setConnected(true);
log("Connected to serial port", "info");
startReading();
setTimeout(() => sendRaw("\n"), 800);
} catch (e) {
log("Connection failed: " + e.message, "err");
}
});
document
.getElementById("disconnect-btn")
.addEventListener("click", async () => {
await doDisconnect();
});
async function doDisconnect() {
try {
if (reader) {
await reader.cancel();
reader = null;
}
if (writer) {
writer.releaseLock();
writer = null;
}
if (port) {
await port.close();
port = null;
}
} catch (e) {}
setConnected(false);
log("Disconnected", "info");
}
function setConnected(c) {
document.getElementById("dot").className =
"dot" + (c ? " connected" : "");
document.getElementById("status-text").textContent = c
? "Connected"
: "Not connected";
document.getElementById("connect-btn").disabled = c;
document.getElementById("disconnect-btn").disabled = !c;
document.getElementById("send-btn").disabled = !c;
document.getElementById("sleep-btn").disabled = !c;
document.getElementById("led-btn").disabled = !c;
if (!c)
document.getElementById("current-combo").textContent = "—";
}
async function startReading() {
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
reader = decoder.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
readBuffer += value;
let lines = readBuffer.split("\n");
readBuffer = lines.pop();
for (const line of lines) {
const t = line.trim();
if (!t) continue;
log(t, "rx");
parseIncoming(t);
}
}
} catch (e) {
if (port) log("Read error: " + e.message, "err");
}
}
function parseIncoming(line) {
const m = line.match(
/\[CFG\].*(?:combo|Loaded)[^\:]*[:\—–]\s*(.+)/,
);
if (m)
document.getElementById("current-combo").textContent =
m[1].trim();
const s = line.match(/\[CFG\] Sleep timeout:\s*(\d+)/);
if (s) document.getElementById("sleep-input").value = s[1];
const sd = line.match(/\[CFG\] Sleep disabled/);
if (sd) document.getElementById("sleep-input").value = "0";
// Sync brightness from boot: "[CFG] Brightness: 255"
const br = line.match(/\[CFG\] Brightness:\s*(\d+)/);
if (br) {
const v = parseInt(br[1]);
document.getElementById("bright-slider").value = v;
document.getElementById("bright-val").textContent = v;
}
// Sync connected color from boot: "[CFG] Connected color: 0,255,0"
const cc = line.match(
/\[CFG\] Connected color:\s*(\d+),(\d+),(\d+)/,
);
if (cc) {
const hex = rgbToHex(
parseInt(cc[1]),
parseInt(cc[2]),
parseInt(cc[3]),
);
document.getElementById("picker-connected").value = hex;
document.getElementById(
"swatch-connected",
).style.background = hex;
}
// Sync pressed color from boot: "[CFG] Pressed color: 255,20,100"
const pc = line.match(
/\[CFG\] Pressed color:\s*(\d+),(\d+),(\d+)/,
);
if (pc) {
const hex = rgbToHex(
parseInt(pc[1]),
parseInt(pc[2]),
parseInt(pc[3]),
);
document.getElementById("picker-pressed").value = hex;
document.getElementById("swatch-pressed").style.background =
hex;
}
}
// ---- LED helpers -----------------------------------
function rgbToHex(r, g, b) {
return (
"#" +
[r, g, b]
.map((v) => v.toString(16).padStart(2, "0"))
.join("")
);
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
// Color swatch clicks open the hidden color picker
document
.getElementById("swatch-connected")
.addEventListener("click", () => {
document.getElementById("picker-connected").click();
});
document
.getElementById("swatch-pressed")
.addEventListener("click", () => {
document.getElementById("picker-pressed").click();
});
// Keep swatch in sync with picker
document
.getElementById("picker-connected")
.addEventListener("input", (e) => {
document.getElementById(
"swatch-connected",
).style.background = e.target.value;
});
document
.getElementById("picker-pressed")
.addEventListener("input", (e) => {
document.getElementById("swatch-pressed").style.background =
e.target.value;
});
// Brightness slider live value display
document
.getElementById("bright-slider")
.addEventListener("input", (e) => {
document.getElementById("bright-val").textContent =
e.target.value;
});
// Apply button — sends all three LED commands
document.getElementById("led-btn").addEventListener("click", () => {
const connHex =
document.getElementById("picker-connected").value;
const pressHex =
document.getElementById("picker-pressed").value;
const bright = document.getElementById("bright-slider").value;
const conn = hexToRgb(connHex);
const press = hexToRgb(pressHex);
sendCommand(`color:connected:${conn.r},${conn.g},${conn.b}`);
setTimeout(
() =>
sendCommand(
`color:pressed:${press.r},${press.g},${press.b}`,
),
150,
);
setTimeout(() => sendCommand(`bright:${bright}`), 300);
});
// ---- Serial ----------------------------------------
async function sendRaw(str) {
if (!writer) return;
await writer.write(new TextEncoder().encode(str));
}
async function sendCommand(cmd) {
if (!writer) return;
log("> " + cmd, "tx");
await sendRaw(cmd + "\n");
}
document
.getElementById("send-btn")
.addEventListener("click", () => {
const cmd = buildCommand();
if (cmd) sendCommand(cmd);
});
function buildCommand() {
switch (currentMode) {
case "single": {
const k =
capturedKey.single ||
document.getElementById("special-single").value;
if (!k) {
log("No key selected", "err");
return null;
}
return k;
}
case "combo": {
const mods = [
...document.querySelectorAll(
".mod-check input:checked",
),
].map((i) => i.value);
const k =
capturedKey.combo ||
document.getElementById("special-combo").value;
if (!k) {
log("No key selected", "err");
return null;
}
return [...mods, k].join("+");
}
case "string": {
const s = document.getElementById("string-input").value;
if (!s) {
log("No string entered", "err");
return null;
}
return `"${s}"`;
}
case "string-key": {
const s =
document.getElementById("string-key-input").value;
const t = document.getElementById("tail-key").value;
if (!s) {
log("No string entered", "err");
return null;
}
return `"${s}"+${t}`;
}
case "media": {
return document.getElementById("media-key").value;
}
}
return null;
}
function updatePreview() {
const cmd = buildCommand();
const el = document.getElementById("preview");
el.innerHTML = cmd
? `<span>${cmd}</span>`
: '<span style="color:var(--muted)">—</span>';
}
document
.getElementById("sleep-btn")
.addEventListener("click", () => {
const val = parseInt(
document.getElementById("sleep-input").value,
);
if (isNaN(val) || val < 0 || val > 255) {
log("Invalid sleep value — enter 0–255", "err");
return;
}
sendCommand(`sleep:${val}`);
});
document
.getElementById("reset-btn")
.addEventListener("click", () => {
capturedKey = { single: "", combo: "" };
document.getElementById("capture-single").textContent =
"Click to capture key...";
document.getElementById("capture-single").className =
"key-capture";
document.getElementById("capture-combo").textContent =
"Click to capture key...";
document.getElementById("capture-combo").className =
"key-capture";
document.getElementById("special-single").value = "";
document.getElementById("special-combo").value = "";
document.getElementById("string-input").value = "";
document.getElementById("string-key-input").value = "";
document.getElementById("tail-key").selectedIndex = 0;
document.getElementById("media-key").selectedIndex = 0;
document
.querySelectorAll(".mod-check input")
.forEach((cb) => {
cb.checked = false;
cb.closest(".mod-check").classList.remove(
"checked",
);
});
updatePreview();
});
document
.getElementById("special-single")
.addEventListener("change", () => {
capturedKey.single = "";
document.getElementById("capture-single").textContent =
"Click to capture key...";
document.getElementById("capture-single").className =
"key-capture";
updatePreview();
});
document
.getElementById("special-combo")
.addEventListener("change", () => {
capturedKey.combo = "";
document.getElementById("capture-combo").textContent =
"Click to capture key...";
document.getElementById("capture-combo").className =
"key-capture";
updatePreview();
});
document
.getElementById("media-key")
.addEventListener("change", updatePreview);
document
.getElementById("string-input")
.addEventListener("input", updatePreview);
document
.getElementById("string-key-input")
.addEventListener("input", updatePreview);
document
.getElementById("tail-key")
.addEventListener("change", updatePreview);
document.querySelectorAll(".mod-check input").forEach((cb) => {
cb.addEventListener("change", () => {
cb.closest(".mod-check").classList.toggle(
"checked",
cb.checked,
);
updatePreview();
});
});
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
currentMode = tab.dataset.mode;
document
.querySelectorAll(".tab")
.forEach((t) => t.classList.remove("active"));
document
.querySelectorAll(".mode-panel")
.forEach((p) => p.classList.remove("active"));
tab.classList.add("active");
document
.getElementById("panel-" + currentMode)
.classList.add("active");
updatePreview();
});
});
function setupCapture(id, stateKey) {
const el = document.getElementById(id);
let listening = false;
el.addEventListener("click", () => {
listening = true;
el.textContent = "Press a key...";
el.className = "key-capture listening";
el.focus();
});
el.addEventListener("keydown", (e) => {
if (!listening) return;
e.preventDefault();
if (["Control", "Shift", "Alt", "Meta"].includes(e.key))
return;
const k = keyEventToToken(e);
if (!k) return;
capturedKey[stateKey] = k;
el.textContent = k;
el.className = "key-capture has-key";
listening = false;
const sel =
id === "capture-single"
? "special-single"
: "special-combo";
document.getElementById(sel).value = "";
updatePreview();
});
el.addEventListener("blur", () => {
if (listening) {
listening = false;
el.textContent =
capturedKey[stateKey] || "Click to capture key...";
el.className = capturedKey[stateKey]
? "key-capture has-key"
: "key-capture";
}
});
}
function keyEventToToken(e) {
const map = {
Tab: "tab",
Enter: "return",
Escape: "esc",
Backspace: "backspace",
Delete: "delete",
" ": "space",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
Home: "home",
End: "end",
PageUp: "pageup",
PageDown: "pagedown",
Insert: "insert",
};
if (map[e.key]) return map[e.key];
if (e.key.match(/^F\d{1,2}$/)) return e.key.toLowerCase();
if (e.key.length === 1) return e.key.toLowerCase();
return null;
}
setupCapture("capture-single", "single");
setupCapture("capture-combo", "combo");
</script>
</body>
</html>
Page last edited May 22, 2026
Text editor powered by tinymce.