first commit

This commit is contained in:
2026-05-27 18:50:35 +09:00
commit d80de116c6
17 changed files with 1381 additions and 0 deletions

237
cmd/hhkb-web/main.go Normal file
View File

@@ -0,0 +1,237 @@
// Command hhkb-web serves a browser-based HHKB keymap editor, styled after the
// QMK Configurator. The Go backend reuses internal/hhkb for all device I/O; the
// browser is just the renderer.
package main
import (
"embed"
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
"hkkb/internal/hhkb"
)
//go:embed web
var webFS embed.FS
// devMu serializes access: only one request may touch the keyboard at a time.
var devMu sync.Mutex
func withDevice(fn func(*hhkb.Device) error) error {
devMu.Lock()
defer devMu.Unlock()
d, err := hhkb.Open()
if err != nil {
return err
}
defer d.Close()
return fn(d)
}
type keyDTO struct {
Num int `json:"num"`
Row int `json:"row"`
X float64 `json:"x"`
W float64 `json:"w"`
Def int `json:"def"`
DefName string `json:"defname"`
}
type modelDTO struct {
Name string `json:"name"`
Variant string `json:"variant"`
Hybrid bool `json:"hybrid"`
Keys []keyDTO `json:"keys"`
}
type codeDTO struct {
Code int `json:"code"`
Name string `json:"name"`
}
type groupDTO struct {
Name string `json:"name"`
Keys []codeDTO `json:"keys"`
}
type deviceDTO struct {
Connected bool `json:"connected"`
Model string `json:"model"`
Type string `json:"type"`
Serial string `json:"serial"`
Mode string `json:"mode"`
Error string `json:"error"`
}
func modelsDTO() []modelDTO {
out := make([]modelDTO, 0, len(hhkb.Models))
for _, m := range hhkb.Models {
md := modelDTO{Name: m.Name, Variant: m.Variant.String(), Hybrid: m.Hybrid}
for _, k := range m.Keys {
w := k.W
if w == 0 {
w = 1
}
md.Keys = append(md.Keys, keyDTO{
Num: k.Num, Row: k.Row, X: k.X, W: w,
Def: int(k.Def), DefName: hhkb.KeyName(k.Def),
})
}
out = append(out, md)
}
return out
}
func paletteDTO() []groupDTO {
out := make([]groupDTO, 0, len(hhkb.Palette))
for _, g := range hhkb.Palette {
gd := groupDTO{Name: g.Name}
for _, c := range g.Codes {
gd.Keys = append(gd.Keys, codeDTO{Code: int(c), Name: hhkb.KeyName(c)})
}
out = append(out, gd)
}
return out
}
func deviceStatus() deviceDTO {
var out deviceDTO
err := withDevice(func(d *hhkb.Device) error {
out.Connected = true
if m, ok, e := d.DetectModel(); e == nil && ok {
out.Model = m.Name
}
if i, e := d.Info(); e == nil {
out.Type, out.Serial = i.TypeNumber, i.Serial
}
if md, e := d.Mode(); e == nil {
out.Mode = md.String()
}
return nil
})
if err != nil {
out.Connected = false
out.Error = friendlyErr(err)
}
return out
}
func friendlyErr(err error) string {
if errors.Is(err, os.ErrPermission) {
return "No access to /dev/hidraw. Run once as root:\n" +
`echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="04fe", MODE="0660", GROUP="input"' | sudo tee /etc/udev/rules.d/70-hhkb.rules` +
"\nsudo udevadm control --reload-rules && sudo udevadm trigger"
}
return err.Error()
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(v)
}
func handleInit(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{
"models": modelsDTO(),
"palette": paletteDTO(),
"device": deviceStatus(),
})
}
// handleDevice reports just the current device status, for lightweight polling.
func handleDevice(w http.ResponseWriter, r *http.Request) {
writeJSON(w, deviceStatus())
}
func handleKeymap(w http.ResponseWriter, r *http.Request) {
fn := r.URL.Query().Get("layer") == "fn"
keys := []map[string]any{}
err := withDevice(func(d *hhkb.Device) error {
lay, err := d.ReadLayer(fn)
if err != nil {
return err
}
for num := 1; num < hhkb.LayerLen; num++ {
code := lay[num]
keys = append(keys, map[string]any{"num": num, "code": int(code), "name": hhkb.KeyName(code)})
}
return nil
})
if err != nil {
writeJSON(w, map[string]any{"error": friendlyErr(err)})
return
}
writeJSON(w, map[string]any{"keys": keys})
}
func handleRemap(w http.ResponseWriter, r *http.Request) {
var req struct {
Num int `json:"num"`
Code int `json:"code"`
Fn bool `json:"fn"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
err := withDevice(func(d *hhkb.Device) error {
return d.Remap(req.Num, byte(req.Code), req.Fn)
})
if err != nil {
writeJSON(w, map[string]any{"error": friendlyErr(err)})
return
}
writeJSON(w, map[string]any{"ok": true})
}
func handleReset(w http.ResponseWriter, r *http.Request) {
err := withDevice(func(d *hhkb.Device) error { return d.Reset() })
if err != nil {
writeJSON(w, map[string]any{"error": friendlyErr(err)})
return
}
writeJSON(w, map[string]any{"ok": true})
}
func main() {
port := flag.Int("port", 8080, "port to listen on (localhost)")
open := flag.Bool("open", true, "open the editor in a browser")
flag.Parse()
static, err := fs.Sub(webFS, "web")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(static)))
mux.HandleFunc("/api/init", handleInit)
mux.HandleFunc("/api/device", handleDevice)
mux.HandleFunc("/api/keymap", handleKeymap)
mux.HandleFunc("/api/remap", handleRemap)
mux.HandleFunc("/api/reset", handleReset)
addr := fmt.Sprintf("127.0.0.1:%d", *port)
url := "http://" + addr
log.Printf("hkkb editor on %s", url)
if *open {
go openBrowser(url)
}
log.Fatal(http.ListenAndServe(addr, mux))
}
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
if err := exec.Command("xdg-open", url).Start(); err != nil {
fmt.Println("open", url, "in your browser")
}
}

361
cmd/hhkb-web/web/app.js Normal file
View File

@@ -0,0 +1,361 @@
"use strict";
const $ = (sel, el = document) => el.querySelector(sel);
const api = async (path, opts) => (await fetch(path, opts)).json();
const U = 54; // pixels per key unit
const GAP = 4; // gap between keycaps
let DATA; // /api/init payload
const modelsByName = {};
const names = {}; // scancode -> label
let model; // selected modelDTO
let layer = "base"; // "base" | "fn"
let selected = null; // selected key number
const live = { base: {}, fn: {} }; // num -> code, from device/defaults
const edits = { base: {}, fn: {} };// num -> pending code
let testMode = false; // key-test: highlight presses (focus-gated, no device needed)
const pressed = new Set(); // key numbers currently held
const keyEls = {}; // num -> DOM element
let codeToNum = {}; // KeyboardEvent.code -> key number
let defByNum = {}; // key number -> default (base) scancode
let deviceReady = false; // auto-read already done since the device appeared
// HID usage -> KeyboardEvent.code (physical position, OS-layout independent).
const USAGE_TO_CODE = (() => {
const m = {};
"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach((c, i) => (m[0x04 + i] = "Key" + c));
for (let i = 0; i < 9; i++) m[0x1e + i] = "Digit" + (i + 1);
m[0x27] = "Digit0";
for (let i = 0; i < 12; i++) m[0x3a + i] = "F" + (i + 1);
Object.assign(m, {
0x28: "Enter", 0x29: "Escape", 0x2a: "Backspace", 0x2b: "Tab", 0x2c: "Space",
0x2d: "Minus", 0x2e: "Equal", 0x2f: "BracketLeft", 0x30: "BracketRight", 0x31: "Backslash",
0x33: "Semicolon", 0x34: "Quote", 0x35: "Backquote", 0x36: "Comma", 0x37: "Period",
0x38: "Slash", 0x39: "CapsLock",
0x46: "PrintScreen", 0x47: "ScrollLock", 0x48: "Pause",
0x49: "Insert", 0x4a: "Home", 0x4b: "PageUp", 0x4c: "Delete", 0x4d: "End", 0x4e: "PageDown",
0x4f: "ArrowRight", 0x50: "ArrowLeft", 0x51: "ArrowDown", 0x52: "ArrowUp",
0xe0: "ControlLeft", 0xe1: "ShiftLeft", 0xe2: "AltLeft", 0xe3: "MetaLeft",
0xe4: "ControlRight", 0xe5: "ShiftRight", 0xe6: "AltRight", 0xe7: "MetaRight",
});
return m;
})();
// inverse: KeyboardEvent.code -> HID usage (names the key the keyboard sent).
const CODE_TO_USAGE = Object.fromEntries(
Object.entries(USAGE_TO_CODE).map(([usage, code]) => [code, Number(usage)])
);
function codeName(code) {
return names[code] ?? ("0x" + code.toString(16).padStart(2, "0"));
}
function shown(num) {
if (num in edits[layer]) return edits[layer][num];
if (num in live[layer]) return live[layer][num];
return layer === "base" ? (defByNum[num] ?? 0) : 0;
}
async function boot() {
DATA = await api("/api/init");
DATA.models.forEach(m => (modelsByName[m.name] = m));
DATA.palette.forEach(g => g.keys.forEach(k => (names[k.code] = k.name)));
DATA.models.forEach(m => m.keys.forEach(k => (names[k.def] = k.defname)));
buildModelSelect();
buildLayers();
buildPalette();
const want = DATA.device.model && modelsByName[DATA.device.model]
? DATA.device.model : DATA.models[0].name;
$("#model").value = want;
selectModel(want);
// Auto-read on connect: read now if the keyboard is ready, otherwise keep
// checking so it reads by itself the moment you plug in / grant access.
if (!(await refreshDevice())) {
const timer = setInterval(async () => {
if (await refreshDevice()) clearInterval(timer);
}, 2500);
}
}
// refreshDevice updates the banner and, the first time the keyboard becomes
// available, selects the detected model and reads its keymap. Returns whether
// the device is currently connected.
async function refreshDevice() {
let dev;
try { dev = await api("/api/device"); } catch { return false; }
showBanner(dev);
if (dev.connected && !deviceReady) {
deviceReady = true;
if (dev.model && modelsByName[dev.model] && $("#model").value !== dev.model) {
$("#model").value = dev.model;
selectModel(dev.model);
}
await readAll();
}
return dev.connected;
}
function buildModelSelect() {
const sel = $("#model");
sel.innerHTML = "";
DATA.models.forEach(m => {
const o = document.createElement("option");
o.value = m.name;
o.textContent = m.name;
sel.appendChild(o);
});
sel.onchange = () => selectModel(sel.value);
}
function selectModel(name) {
model = modelsByName[name];
defByNum = {};
model.keys.forEach(k => (defByNum[k.num] = k.def));
codeToNum = buildCodeMap();
selected = null;
renderBoard();
}
function buildLayers() {
const box = $("#layers");
box.innerHTML = "";
[["base", "Base"], ["fn", "Fn"]].forEach(([id, label]) => {
const b = document.createElement("button");
b.textContent = label;
b.className = "layer" + (id === layer ? " active" : "");
b.onclick = () => { layer = id; selected = null; buildLayers(); renderBoard(); };
box.appendChild(b);
});
}
function renderBoard() {
const board = $("#board");
board.innerHTML = "";
Object.keys(keyEls).forEach(k => delete keyEls[k]);
let maxX = 0, maxRow = 0;
model.keys.forEach(k => {
const d = document.createElement("div");
d.className = "key";
keyEls[k.num] = d;
if (k.num === selected) d.classList.add("selected");
if (k.num in edits[layer]) d.classList.add("changed");
if (pressed.has(k.num)) d.classList.add("pressed");
d.style.left = (k.x * U) + "px";
d.style.top = (k.row * U) + "px";
d.style.width = (k.w * U - GAP) + "px";
d.style.height = (U - GAP) + "px";
d.textContent = codeName(shown(k.num));
d.title = "key " + k.num;
d.onclick = () => { selected = selected === k.num ? null : k.num; renderBoard(); };
board.appendChild(d);
maxX = Math.max(maxX, k.x + k.w);
maxRow = Math.max(maxRow, k.row);
});
board.style.width = (maxX * U) + "px";
board.style.height = ((maxRow + 1) * U) + "px";
updateHint();
}
function buildPalette() {
const pal = $("#palette");
pal.innerHTML = "";
DATA.palette.forEach(g => {
const sec = document.createElement("div");
sec.className = "group";
const h = document.createElement("div");
h.className = "ghead";
h.textContent = g.name;
sec.appendChild(h);
const row = document.createElement("div");
row.className = "gkeys";
g.keys.forEach(c => {
const b = document.createElement("button");
b.className = "cap";
b.textContent = c.name;
b.title = "0x" + c.code.toString(16).padStart(2, "0");
b.onclick = () => assign(c.code);
row.appendChild(b);
});
sec.appendChild(row);
pal.appendChild(sec);
});
}
function assign(code) {
if (selected == null) { toast("Select a key first"); return; }
if ((live[layer][selected] ?? 0) === code) delete edits[layer][selected];
else edits[layer][selected] = code;
renderBoard();
}
function buildCodeMap() {
const m = {};
model.keys.forEach(k => {
const code = USAGE_TO_CODE[live.base[k.num] ?? k.def];
if (code) m[code] = k.num;
});
return m;
}
// Focus-gated: we listen only while this page has focus, never in the background.
function setTest(on) {
testMode = on;
$("#test").classList.toggle("active", on);
const recv = $("#recv");
if (on) {
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
recv.hidden = false;
recv.textContent = "Press a key. (The Fn key is handled inside the keyboard and never reaches the computer.)";
toast("Key test ON — only while this page is focused. Click again to stop.");
} else {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
pressed.forEach(n => keyEls[n] && keyEls[n].classList.remove("pressed"));
pressed.clear();
recv.hidden = true;
}
}
function onKeyDown(e) {
const usage = CODE_TO_USAGE[e.code];
if (usage != null && !(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault();
$("#recv").textContent = "Keyboard sent: " + (usage != null ? codeName(usage) : e.code);
const num = codeToNum[e.code];
if (num == null) return; // e.g. an Fn-layer result like Up has no base position
pressed.add(num);
if (keyEls[num]) keyEls[num].classList.add("pressed");
}
function onKeyUp(e) {
const num = codeToNum[e.code];
if (num == null) return;
pressed.delete(num);
if (keyEls[num]) keyEls[num].classList.remove("pressed");
}
// baseLabel is what's printed on the physical key (its base-layer assignment).
function baseLabel(num) {
return codeName(live.base[num] ?? defByNum[num] ?? 0);
}
function updateHint() {
const h = $("#hint");
const n = changedNums().length;
const pend = n ? `${n} unsaved (press Write)` : "";
if (selected == null) {
h.textContent = (layer === "fn"
? "Fn layer — click a key to set what “Fn + that key” sends."
: "Click a key, then pick a keycode below.") + pend;
return;
}
const lhs = layer === "fn" ? `Fn + ${baseLabel(selected)}` : baseLabel(selected);
h.textContent = `${lhs}${codeName(shown(selected))}${pend}`;
}
function changedNums() { return Object.keys(edits[layer]).map(Number); }
async function write() {
const nums = changedNums();
if (nums.length === 0) { toast("No changes to write"); return; }
for (const num of nums) {
const res = await api("/api/remap", {
method: "POST",
body: JSON.stringify({ num, code: edits[layer][num], fn: layer === "fn" }),
});
if (res.error) { toast(res.error); return; }
live[layer][num] = edits[layer][num];
delete edits[layer][num];
renderBoard();
}
toast("Written to keyboard");
}
async function readAll() {
for (const lyr of ["base", "fn"]) {
const res = await api("/api/keymap?layer=" + lyr);
if (res.error) { toast(res.error); return; }
live[lyr] = {};
res.keys.forEach(k => { live[lyr][k.num] = k.code; names[k.code] = k.name; });
}
edits.base = {};
edits.fn = {};
codeToNum = buildCodeMap();
renderBoard();
toast("Read from keyboard");
}
async function reset() {
if (!confirm("Restore the factory default keymap on the keyboard?")) return;
const res = await api("/api/reset", { method: "POST" });
if (res.error) { toast(res.error); return; }
await readAll();
toast("Reset to factory defaults");
}
function save() {
const data = { model: model.name, base: {}, fn: {} };
["base", "fn"].forEach(l =>
model.keys.forEach(k => {
const e = edits[l], li = live[l];
data[l][k.num] = (k.num in e) ? e[k.num] : (li[k.num] ?? 0);
}));
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "hkkb-keymap.json";
a.click();
}
function load(file) {
const r = new FileReader();
r.onload = () => {
try {
const data = JSON.parse(r.result);
["base", "fn"].forEach(l => {
for (const num in (data[l] || {})) {
if (!(num in defByNum)) continue; // ignore keys not on this model
const code = data[l][num];
const cur = live[l][num] ?? (l === "base" ? defByNum[num] : 0);
if (cur !== code) edits[l][num] = code;
}
});
renderBoard();
toast("Loaded — review the changes, then Write");
} catch (e) {
toast("Bad file: " + e);
}
};
r.readAsText(file);
}
let toastTimer;
function toast(msg) {
const t = $("#toast");
t.textContent = msg;
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (t.hidden = true), 3500);
}
function showBanner(dev) {
const b = $("#banner");
if (dev.connected) { b.hidden = true; return; }
b.hidden = false;
b.textContent = dev.error || "No keyboard connected — showing factory defaults.";
}
window.addEventListener("DOMContentLoaded", () => {
$("#read").onclick = readAll;
$("#write").onclick = write;
$("#reset").onclick = reset;
$("#save").onclick = save;
$("#load").onclick = () => $("#file").click();
$("#file").onchange = e => e.target.files[0] && load(e.target.files[0]);
$("#test").onclick = () => setTest(!testMode);
boot().catch(e => toast(String(e)));
});

View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hkkb — HHKB Keymap</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="toolbar">
<span class="brand">hkkb</span>
<label>Model <select id="model"></select></label>
<div class="layers" id="layers"></div>
<span class="spacer"></span>
<button id="test">Key test</button>
<button id="read">Read</button>
<button id="write" class="primary">Write</button>
<button id="reset">Reset…</button>
<button id="save">Save</button>
<button id="load">Load</button>
<input type="file" id="file" accept=".json" hidden>
</header>
<div id="banner" class="banner" hidden></div>
<div id="hint" class="hint"></div>
<div id="recv" class="recv" hidden></div>
<main>
<section id="board" class="board"></section>
<section id="palette" class="palette"></section>
</main>
<div id="toast" class="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,88 @@
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.4 -apple-system, "Segoe UI", Roboto, sans-serif;
background: #eceff1;
color: #263238;
}
/* toolbar */
.toolbar {
display: flex; align-items: center; gap: 14px;
background: #263238; color: #eceff1;
padding: 0 16px; height: 52px;
}
.brand { font-weight: 700; color: #26c6da; letter-spacing: .5px; font-size: 16px; }
.toolbar label { font-size: 13px; color: #b0bec5; display: flex; gap: 6px; align-items: center; }
.toolbar select {
background: #37474f; color: #fff; border: 1px solid #455a64;
border-radius: 4px; padding: 5px 8px; font-size: 13px;
}
.layers { display: flex; gap: 4px; }
.layer {
background: #37474f; color: #cfd8dc; border: 1px solid #455a64;
border-radius: 4px; padding: 5px 14px; cursor: pointer; font-size: 13px;
}
.layer.active { background: #26c6da; color: #06343b; border-color: #26c6da; font-weight: 600; }
.spacer { flex: 1; }
.toolbar button {
background: #37474f; color: #eceff1; border: 1px solid #455a64;
border-radius: 4px; padding: 6px 12px; cursor: pointer; font-size: 13px;
}
.toolbar button:hover { background: #455a64; }
.toolbar button.primary { background: #26c6da; color: #06343b; border-color: #26c6da; font-weight: 600; }
.toolbar button.primary:hover { background: #4dd0e1; }
/* banners */
.banner {
background: #fff3cd; border: 1px solid #ffe69c; color: #664d03;
padding: 10px 16px; margin: 12px 16px; border-radius: 6px;
white-space: pre-wrap; font-family: ui-monospace, monospace; font-size: 12px;
}
.hint { margin: 14px 16px 6px; color: #546e7a; font-size: 13px; min-height: 18px; }
.recv { margin: 0 16px 8px; color: #00695c; font-size: 13px; font-weight: 600; }
/* keyboard */
.board {
position: relative; margin: 4px 16px 24px;
background: #cfd8dc; border-radius: 10px; padding: 10px;
box-shadow: inset 0 0 0 1px #b0bec5;
}
.key {
position: absolute;
background: linear-gradient(#ffffff, #eceff1);
border: 1px solid #b0bec5; border-bottom-width: 2px;
border-radius: 5px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #37474f; text-align: center;
overflow: hidden; user-select: none; padding: 0 2px;
}
.key:hover { border-color: #26c6da; }
.key.selected { outline: 2px solid #26c6da; outline-offset: -1px; z-index: 2; box-shadow: 0 0 0 3px rgba(38,198,218,.25); }
.key.changed { background: linear-gradient(#fff6e6, #ffe2b3); border-color: #e0a040; }
/* palette */
.palette { margin: 0 16px 48px; display: flex; flex-wrap: wrap; gap: 18px; align-items: flex-start; }
.group { background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; padding: 10px 12px; }
.ghead { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: #78909c; margin-bottom: 8px; }
.gkeys { display: flex; flex-wrap: wrap; gap: 5px; max-width: 330px; }
.cap {
min-width: 38px; height: 34px; padding: 0 7px;
background: linear-gradient(#ffffff, #eceff1);
border: 1px solid #b0bec5; border-bottom-width: 2px;
border-radius: 5px; cursor: pointer; font-size: 11px; color: #37474f;
}
.cap:hover { background: #e0f7fa; border-color: #26c6da; }
/* key-test highlight + toggled buttons */
.key.pressed { background: linear-gradient(#b9f6ca, #69f0ae); border-color: #00c853; color: #064d2e; box-shadow: 0 0 0 3px rgba(0,200,83,.25); z-index: 1; }
.toolbar button.active { background: #26c6da; color: #06343b; border-color: #26c6da; font-weight: 600; }
/* toast */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: #263238; color: #fff; padding: 10px 18px; border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,.25); white-space: pre-wrap; max-width: 80vw;
}
[hidden] { display: none !important; }