diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..4a0bedb7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(npm start)", + "Bash(node_modules/.bin/react-scripts build:*)", + "Bash(powershell -command:*)", + "Bash(node_modules/.bin/cross-env ENABLE_DEV_SERVER=true node index.js)", + "Bash(python -c:*)", + "Bash(powershell.exe -NoProfile -Command \"Get-NetTCPConnection -LocalPort 8080 -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force }\")", + "Bash(powershell.exe -NoProfile -Command \"Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force }\")" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..fce4cb46 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,24 @@ +{ + "permissions": { + "allow": [ + "Bash(git restore:*)", + "WebSearch", + "Bash(npm run dev:*)", + "Bash(curl:*)", + "Bash(node -e:*)", + "Bash(dir:*)", + "Bash(netstat:*)", + "Bash(findstr:*)", + "Bash(taskkill:*)", + "Bash(test:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(gh run list:*)", + "Bash(python3:*)", + "Bash(cmd /c \"dir \"\"D:\\\\Programs\\\\keymap-editor\\\\app\\\\src\\\\Keyboard\\\\Keys\"\" /b\")", + "Bash(node index.js:*)", + "Bash(ls:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index 705332f6..d24c106b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,5 @@ qmk_firmware zmk-config private-key.pem -.env \ No newline at end of file +.env +working/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..50caf012 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# keymap-editor — CLAUDE.md + +## Project Purpose +Local keymap editor for a Totem split keyboard (38 keys, ZMK firmware). +Edits `zmk-config/config/keymap.json` + `config/totem.keymap`, then git pushes to trigger GitHub Actions builds. + +## Architecture +- **Frontend**: React (CRA) in `app/src/`, served on port 3000 in dev +- **Backend API**: Express in `api/`, listens on port 8080 +- **zmk-config**: cloned at `D:/Programs/keymap-editor/zmk-config/` (GitHub: Meserlion/zmk-config-totem) + +## Running the Dev Server (Windows) +- `npm run dev` — kills stale processes on 8080/3000, then starts API + React dev server +- Open at `http://127.0.0.1:3000` (not `localhost` — IPv6 conflicts on this machine) +- `ENABLE_DEV_SERVER=true` is in `.env` — do not use `cross-env` in scripts, it silently fails in PowerShell on this machine +- API and CRA both bind to `127.0.0.1` explicitly +- To manually kill port 8080: `powershell -command "Get-NetTCPConnection -LocalPort 8080 -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object { Stop-Process -Id $_ -Force }"` + +## Important Files +| File | Purpose | +|------|---------| +| `index.js` | Entry point — starts Express on port 8080 | +| `api/index.js` | Express app setup, mounts routes, starts dev server if `ENABLE_DEV_SERVER` | +| `api/config.js` | Loads `.env` via dotenv, exports all config values | +| `api/routes/application.js` | Spawns the CRA dev server process | +| `api/routes/keyboards.js` | API routes: `GET/POST /keyboards/:id/keymap`, `GET /keyboards/:id/layout` | +| `api/services/zmk/local-source.js` | Reads/writes zmk-config files; falls back to totem.json if info.json missing | +| `api/services/zmk/data/zmk-behaviors.json` | **Single source of truth** for ZMK behaviors — do not duplicate | +| `app/src/App.js` | Main React component | +| `app/src/keymap.js` | `parseKeymap`, `parseKeyBinding`, `getBehaviourParams`, re-exports `loadKeymap` | +| `app/src/data/totem.json` | Totem 38-key layout (from keymap-editor-contrib) | +| `app/src/data/totem.keymap.json` | Default keymap (38 `&none` bindings) | + +## Behaviors — Single Source of Truth +`api/services/zmk/data/zmk-behaviors.json` is the **only** behaviors file. +The frontend fetches behaviors at runtime via `GET /behaviors` — there is no frontend copy. +`app/src/data/zmk-behaviors.json` was intentionally deleted to prevent drift. + +## Key Architectural Notes +- Layout array is at `layoutData.layouts.LAYOUT.layout` — don't pass the full JSON object +- Key bindings must be `{value, params}` objects (not raw strings) before passing to `` +- `editingKeymap` (not `keymap`) is the in-progress state; it is always set on init via `parseKeymap()` +- Frontend fetches keymap, behaviors, macros, and combos at runtime from the API — no bundled copies + +## ZMK Version — IMPORTANT +`west.yml` is pinned to commit `fee2404d5d886c455e3820f9ca624cf9275e9cb5` (Dec 30, 2025). +**Do NOT change `revision` back to `main`** — ZMK main (post-Dec 2025) has a regression that breaks BT advertising on the Totem/XIAO BLE. The keyboard boots and works over USB but never appears in the BT device list. +Working firmware is also stored in `D:/Programs/keymap-editor/working/` as a backup. + +## Totem Layout Source +`https://raw.githubusercontent.com/nickcoutsos/keymap-editor-contrib/main/keyboard-data/totem.json` diff --git a/api/routes/application.js b/api/routes/application.js index 82d852a6..ddbbe9f3 100644 --- a/api/routes/application.js +++ b/api/routes/application.js @@ -4,13 +4,15 @@ const path = require('path') const config = require('../config') const appDir = path.join(__dirname, '..', '..', 'app') -const API_BASE_URL = 'http://localhost:8080' -const APP_BASE_URL = 'http://localhost:3000' +const API_BASE_URL = 'http://127.0.0.1:8080' +const APP_BASE_URL = 'http://127.0.0.1:3000' function init (app) { const opts = { cwd: appDir, env: Object.assign({}, process.env, { + HOST: '127.0.0.1', + BROWSER: 'none', REACT_APP_ENABLE_LOCAL: true, REACT_APP_ENABLE_GITHUB: config.ENABLE_GITHUB, REACT_APP_GITHUB_APP_NAME: config.GITHUB_APP_NAME, @@ -19,10 +21,12 @@ function init (app) { }) } - childProcess.execFile('npm', ['start'], opts, err => { - console.error(err) - console.error('Application serving failed') - process.exit(1) + const child = childProcess.spawn('npm', ['start'], { ...opts, shell: true, stdio: 'inherit' }) + child.on('error', err => console.error('Failed to start app:', err.message)) + child.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.error(`App process exited with code ${code}`) + } }) app.get('/', (req, res) => res.redirect(APP_BASE_URL)) diff --git a/api/routes/keyboards.js b/api/routes/keyboards.js index eaaa55d0..22bdce7e 100644 --- a/api/routes/keyboards.js +++ b/api/routes/keyboards.js @@ -5,10 +5,21 @@ const router = Router() router.get('/behaviors', (req, res) => res.json(zmk.loadBehaviors())) router.get('/keycodes', (req, res) => res.json(zmk.loadKeycodes())) +router.get('/aliases', (req, res) => res.json(zmk.loadAliases())) router.get('/layout', (req, res) => res.json(zmk.loadLayout())) router.get('/keymap', (req, res) => res.json(zmk.loadKeymap())) router.post('/keymap', (req, res) => { const keymap = req.body + + try { + zmk.validateKeymapJson(keymap) + } catch (err) { + if (err.name === 'KeymapValidationError') { + return res.status(400).json({ errors: err.errors }) + } + return res.status(500).send(String(err)) + } + const layout = zmk.loadLayout() const generatedKeymap = zmk.generateKeymap(layout, keymap) const exportStdout = zmk.exportKeymap(generatedKeymap, 'flash' in req.query, err => { @@ -27,4 +38,30 @@ router.post('/keymap', (req, res) => { // }) }) +router.get('/macros', (req, res) => res.json(zmk.loadMacros())) +router.post('/macros', (req, res) => { + zmk.exportMacros(req.body, err => { + if (err) { res.status(500).send(err); return } + res.send() + }) +}) + +router.get('/combos', (req, res) => res.json(zmk.loadCombos())) +router.post('/combos', (req, res) => { + zmk.exportCombos(req.body, err => { + if (err) { res.status(500).send(err); return } + res.send() + }) +}) + +router.post('/git/push', (req, res) => { + zmk.gitCommitPush((err, stdout, stderr) => { + if (err) { + return res.status(500).json({ error: err.message, stderr }) + } + const actionsUrl = zmk.getActionsUrl() + res.json({ stdout, stderr, actionsUrl }) + }) +}) + module.exports = router diff --git a/api/services/zmk/data/zmk-behaviors.json b/api/services/zmk/data/zmk-behaviors.json index 17879fcf..babe584d 100644 --- a/api/services/zmk/data/zmk-behaviors.json +++ b/api/services/zmk/data/zmk-behaviors.json @@ -54,6 +54,13 @@ "type": "integer", "enum": [0, 1, 2, 3, 4] }] + }, { + "code": "BT_CLR_ALL", + "description": "Clear bond information for all Bluetooth profiles." + }, { + "code": "BT_DISC", + "description": "Disconnect from the host for a given profile index.", + "additionalParams": [{"name": "index", "type": "integer", "enum": [0, 1, 2, 3, 4]}] }] }, { @@ -189,6 +196,16 @@ "description": "Toggle the external power" }] }, + { + "code": "&kt", + "name": "Key Toggle", + "params": ["code"] + }, + { + "code": "&sys_reset", + "name": "System Reset", + "params": [] + }, { "code": "&trans", "name": "Transparent", diff --git a/api/services/zmk/index.js b/api/services/zmk/index.js index 775b4207..50153856 100644 --- a/api/services/zmk/index.js +++ b/api/services/zmk/index.js @@ -1,6 +1,8 @@ const { parseKeyBinding, - generateKeymap + generateKeymap, + validateKeymapJson, + KeymapValidationError } = require('./keymap') const { @@ -8,15 +10,31 @@ const { loadKeycodes, loadLayout, loadKeymap, - exportKeymap + exportKeymap, + loadMacros, + exportMacros, + loadCombos, + exportCombos, + loadAliases, + getActionsUrl, + gitCommitPush } = require('./local-source') module.exports = { parseKeyBinding, generateKeymap, + validateKeymapJson, + KeymapValidationError, loadBehaviors, loadKeycodes, loadLayout, loadKeymap, - exportKeymap + exportKeymap, + loadMacros, + exportMacros, + loadCombos, + exportCombos, + loadAliases, + getActionsUrl, + gitCommitPush } diff --git a/api/services/zmk/keymap.js b/api/services/zmk/keymap.js index 21c044a6..f74a5714 100644 --- a/api/services/zmk/keymap.js +++ b/api/services/zmk/keymap.js @@ -49,10 +49,17 @@ function getBehavioursUsed(keymap) { * @param {String} binding * @returns {Object} */ +const COMPOUND_KEYCODES = new Set([ + 'LA(LC(N7))', 'LA(LC(N8))', 'LA(LC(N9))', 'LA(LC(N0))', + 'RS(NUMBER_8)', 'RS(N9)', + 'LS(FSLH)' +]) + function parseKeyBinding(binding) { const paramsPattern = /\((.+)\)/ function parse(code) { + if (COMPOUND_KEYCODES.has(code)) return { value: code, params: [] } const value = code.replace(paramsPattern, '') const params = get(code.match(paramsPattern), '[1]', '').split(',') .map(s => s.trim()) @@ -157,16 +164,61 @@ function validateKeymapJson(keymap) { const key = layer[j] const keyPath = `layers[${i}][${j}]` - if (typeof key !== 'string') { - errors.push(`Value at "${keyPath}" must be a string`) + // Accept both string bindings ("&kp A") and parsed objects ({value, params}) + let bindCode, params + if (typeof key === 'string') { + const m = key.match(/^(&\S+)/) + bindCode = m && m[1] + params = bindCode ? parseKeyBinding(key).params : [] + } else if (typeof key === 'object' && key !== null && key.value) { + bindCode = key.value + params = key.params || [] } else { - const bind = key.match(/^&.+?\b/) - if (!(bind && bind[0] in behavioursByBind)) { - errors.push(`Key bind at "${keyPath}" has invalid behaviour`) - } + errors.push(`Value at "${keyPath}" has invalid format`) + continue + } + + if (!bindCode) { + errors.push(`Value at "${keyPath}" has invalid format`) + continue } - // TODO: validate remaining bind parameters + const behaviour = behavioursByBind[bindCode] + if (!behaviour) { + // Unknown behavior — likely a user-defined macro, skip param validation + continue + } + + const expectedParams = behaviour.params || [] + const commandsByCode = keyBy(behaviour.commands || [], 'code') + + // Calculate total expected params including additionalParams for commands + let totalExpected = expectedParams.length + if (expectedParams[0] === 'command' && params[0]) { + const cmdCode = typeof params[0] === 'string' ? params[0] : params[0].value + const cmd = commandsByCode[cmdCode] + totalExpected += (cmd && cmd.additionalParams ? cmd.additionalParams.length : 0) + } + + if (params.length !== totalExpected) { + errors.push( + `Key bind at "${keyPath}" (${bindCode}) expects ${totalExpected} param(s), got ${params.length}` + ) + continue + } + + // Validate command params + for (let k = 0; k < expectedParams.length; k++) { + if (expectedParams[k] === 'command' && params[k]) { + const cmdCode = typeof params[k] === 'string' ? params[k] : params[k].value + const validCmds = (behaviour.commands || []).map(c => c.code) + if (cmdCode && !validCmds.includes(cmdCode)) { + errors.push( + `Key bind at "${keyPath}" has unknown command "${cmdCode}" for ${bindCode}` + ) + } + } + } } } } diff --git a/api/services/zmk/local-source.js b/api/services/zmk/local-source.js index a0db0797..df9b88bf 100644 --- a/api/services/zmk/local-source.js +++ b/api/services/zmk/local-source.js @@ -4,7 +4,7 @@ const path = require('path') const { parseKeymap } = require('./keymap') const ZMK_PATH = path.join(__dirname, '..', '..', '..', 'zmk-config') -const KEYBOARD = 'dactyl' +const KEYBOARD = 'totem' const EMPTY_KEYMAP = { keyboard: 'unknown', @@ -24,7 +24,9 @@ function loadKeycodes() { function loadLayout (layout = 'LAYOUT') { const layoutPath = path.join(ZMK_PATH, 'config', 'info.json') - return JSON.parse(fs.readFileSync(layoutPath)).layouts[layout].layout + const fallbackPath = path.join(__dirname, '..', '..', '..', 'app', 'src', 'data', 'totem.json') + const source = fs.existsSync(layoutPath) ? layoutPath : fallbackPath + return JSON.parse(fs.readFileSync(source)).layouts[layout].layout } function loadKeymap () { @@ -41,13 +43,176 @@ function findKeymapFile () { return files.find(file => file.endsWith('.keymap')) } +function extractKeymapBlock (source) { + const start = source.search(/\bkeymap\s*\{/) + if (start === -1) return null + let depth = 0 + for (let i = start; i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}') { + if (--depth === 0) { + const end = source.indexOf(';', i) + 1 + return { start, end, block: source.slice(start, end) } + } + } + } + return null +} + +function mergeKeymapCode (existingContent, generatedCode) { + const existing = extractKeymapBlock(existingContent) + const generated = extractKeymapBlock(generatedCode) + if (!existing || !generated) return generatedCode + return existingContent.slice(0, existing.start) + + generated.block + + existingContent.slice(existing.end) +} + +function loadCombos () { + const combosPath = path.join(ZMK_PATH, 'config', 'combos.json') + if (!fs.existsSync(combosPath)) return [] + return JSON.parse(fs.readFileSync(combosPath)) +} + +function generateCombosDTS (combos) { + if (!combos || combos.length === 0) return null + const blocks = combos.map(combo => { + const lines = [` ${combo.name} {`] + if (combo.timeout) lines.push(` timeout-ms = <${combo.timeout}>;`) + lines.push(` key-positions = <${combo.positions.join(' ')}>;`) + lines.push(` bindings = <${combo.binding}>;`) + if (combo.layers && combo.layers.length) lines.push(` layers = <${combo.layers.join(' ')}>;`) + lines.push(` };`) + return lines.join('\n') + }).join('\n\n') + return ` combos {\n compatible = "zmk,combos";\n\n${blocks}\n };` +} + +function mergeCombosInKeymap (content, combosDTS) { + const existing = extractBlock(content, /\bcombos\s*\{/) + if (existing) { + return content.slice(0, existing.start) + combosDTS + content.slice(existing.end) + } + // Insert before macros block if present, otherwise before keymap block + const macrosPos = content.search(/\n[\t ]*macros[\t ]*\{/) + if (macrosPos !== -1) { + return content.slice(0, macrosPos) + '\n\n' + combosDTS + content.slice(macrosPos) + } + const keymapPos = content.search(/\n[\t ]*keymap[\t ]*\{/) + if (keymapPos === -1) return content + '\n\n' + combosDTS + return content.slice(0, keymapPos) + '\n\n' + combosDTS + content.slice(keymapPos) +} + +function exportCombos (combos, callback) { + const combosPath = path.join(ZMK_PATH, 'config', 'combos.json') + fs.writeFileSync(combosPath, JSON.stringify(combos, null, 2)) + + const keymapFile = findKeymapFile() + if (keymapFile) { + const keymapFilePath = path.join(ZMK_PATH, 'config', keymapFile) + if (fs.existsSync(keymapFilePath)) { + const existing = fs.readFileSync(keymapFilePath, 'utf8') + const combosDTS = generateCombosDTS(combos) + const updated = combosDTS + ? mergeCombosInKeymap(existing, combosDTS) + : existing + fs.writeFileSync(keymapFilePath, updated) + } + } + + return childProcess.execFile('git', ['status'], { cwd: ZMK_PATH }, callback) +} + +function loadMacros () { + const macrosPath = path.join(ZMK_PATH, 'config', 'macros.json') + if (!fs.existsSync(macrosPath)) return [] + return JSON.parse(fs.readFileSync(macrosPath)) +} + +function generateMacrosDTS (macros) { + if (!macros || macros.length === 0) return null + const blocks = macros.map(macro => { + const name = macro.code.replace(/^&/, '') + return [ + ` ${name}: ${name} {`, + ` compatible = "zmk,behavior-macro";`, + ` #binding-cells = <0>;`, + ` bindings =`, + ` <¯o_press>,`, + ` <&kp ${macro.modifier}>,`, + ` <¯o_tap>,`, + ` <&kp ${macro.key}>,`, + ` <¯o_release>,`, + ` <&kp ${macro.modifier}>;`, + ` label = "${name.toUpperCase()}";`, + ` };` + ].join('\n') + }).join('\n\n') + return ` macros {\n${blocks}\n };` +} + +function extractBlock (source, pattern) { + const start = source.search(pattern) + if (start === -1) return null + let depth = 0 + for (let i = start; i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}') { + if (--depth === 0) { + const end = source.indexOf(';', i) + 1 + return { start, end } + } + } + } + return null +} + +function mergeMacrosInKeymap (content, macrosDTS) { + const existing = extractBlock(content, /\bmacros\s*\{/) + if (existing) { + return content.slice(0, existing.start) + macrosDTS + content.slice(existing.end) + } + // Insert before keymap block + const keymapPos = content.search(/\n[\t ]*keymap[\t ]*\{/) + if (keymapPos === -1) return content + '\n\n' + macrosDTS + return content.slice(0, keymapPos) + '\n\n' + macrosDTS + content.slice(keymapPos) +} + +function exportMacros (macros, callback) { + const macrosPath = path.join(ZMK_PATH, 'config', 'macros.json') + fs.writeFileSync(macrosPath, JSON.stringify(macros, null, 2)) + + const keymapFile = findKeymapFile() + if (keymapFile) { + const keymapFilePath = path.join(ZMK_PATH, 'config', keymapFile) + if (fs.existsSync(keymapFilePath)) { + const existing = fs.readFileSync(keymapFilePath, 'utf8') + const macrosDTS = generateMacrosDTS(macros) + const updated = macrosDTS + ? mergeMacrosInKeymap(existing, macrosDTS) + : existing + fs.writeFileSync(keymapFilePath, updated) + } + } + + return childProcess.execFile('git', ['status'], { cwd: ZMK_PATH }, callback) +} + function exportKeymap (generatedKeymap, flash, callback) { const keymapPath = path.join(ZMK_PATH, 'config') const keymapFile = findKeymapFile() + const keymapFilePath = path.join(keymapPath, keymapFile) fs.existsSync(keymapPath) || fs.mkdirSync(keymapPath) fs.writeFileSync(path.join(keymapPath, 'keymap.json'), generatedKeymap.json) - fs.writeFileSync(path.join(keymapPath, keymapFile), generatedKeymap.code) + + const existingContent = fs.existsSync(keymapFilePath) + ? fs.readFileSync(keymapFilePath, 'utf8') + : null + const outputCode = existingContent + ? mergeKeymapCode(existingContent, generatedKeymap.code) + : generatedKeymap.code + fs.writeFileSync(keymapFilePath, outputCode) // Note: This isn't really helpful. In the QMK version I had this actually // calling `make` and piping the output in realtime but setting up a ZMK dev @@ -57,10 +222,84 @@ function exportKeymap (generatedKeymap, flash, callback) { return childProcess.execFile('git', ['status'], { cwd: ZMK_PATH }, callback) } +const ALIAS_SUFFIX_TO_SYMBOL = { + plus: '+', minus: '-', astrk: '*', qmark: '?', caret: '^', equal: '=', + lpar: '(', rpar: ')', flsh: '/', sqt: "'", dqt: '"', dllr: '$', + excl: '!', hash: '#', amp: '&', pipe: '|', lt: '<', gt: '>', dot: '.', comma: ',', + at: '@', pound: '£', dol: '$' +} + +function aliasSymbol (name) { + const suffix = name.split('_').slice(1).join('_').toLowerCase() + return ALIAS_SUFFIX_TO_SYMBOL[suffix] || name +} + +function loadAliases () { + const aliasPath = path.join(ZMK_PATH, 'aliases.h') + if (!fs.existsSync(aliasPath)) return [] + return fs.readFileSync(aliasPath, 'utf8') + .split('\n') + .map(line => line.match(/^#define\s+(\w+)\s+(.+)/)) + .filter(Boolean) + .flatMap(m => { + const name = m[1] + const value = m[2].trim() + const sym = aliasSymbol(name) + const base = { + code: name, + aliases: [name], + description: `${name} (→ ${value})`, + context: 'Alias', + symbol: sym, + params: [], + isModifier: false + } + // For compound values (e.g. LA(LC(NUMBER_2))), also add an entry keyed + // by the compound expression itself so the keymap can look it up directly. + if (/[()]/.test(value)) { + return [base, { ...base, code: value, aliases: [value] }] + } + return [base] + }) +} + +function getActionsUrl () { + try { + const raw = childProcess + .execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: ZMK_PATH }) + .toString().trim() + const url = raw + .replace(/^git@github\.com:/, 'https://github.com/') + .replace(/\.git$/, '') + return url.includes('github.com') ? `${url}/actions` : null + } catch { + return null + } +} + +function gitCommitPush (callback) { + const date = new Date().toISOString().slice(0, 16).replace('T', ' ') + const message = `Update keymap ${date}` + childProcess.execFile('git', ['add', '-A'], { cwd: ZMK_PATH }, (err, stdout, stderr) => { + if (err) return callback(err, stdout, stderr) + childProcess.execFile('git', ['commit', '-m', message], { cwd: ZMK_PATH }, (err, stdout, stderr) => { + if (err) return callback(err, stdout, stderr) + childProcess.execFile('git', ['push'], { cwd: ZMK_PATH }, callback) + }) + }) +} + module.exports = { loadBehaviors, loadKeycodes, loadLayout, loadKeymap, - exportKeymap + exportKeymap, + loadMacros, + exportMacros, + loadCombos, + exportCombos, + loadAliases, + getActionsUrl, + gitCommitPush } diff --git a/app/src/App.css b/app/src/App.css index 2f86c582..233e084a 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -16,9 +16,44 @@ html, body { } #actions { - position: absolute; + position: fixed; bottom: 5px; right: 20px; + display: flex; + align-items: center; + gap: 8px; +} + +.unsaved-indicator { + font-size: 12px; + color: #e0a040; + font-family: monospace; +} + +.toast { + position: fixed; + bottom: 50px; + right: 20px; + padding: 10px 16px; + border-radius: 5px; + font-family: monospace; + font-size: 13px; + z-index: 1000; + max-width: 420px; + white-space: pre-wrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); +} + +.toast-success { + background: #1a3a28; + color: #90e0b0; + border: 1px solid #52b788; +} + +.toast-error { + background: #3a1a1a; + color: #e09090; + border: 1px solid #b75252; } #actions button { @@ -33,11 +68,81 @@ html, body { margin: 2px; } +#actions button.action-btn-sm { + font-size: 13px; + padding: 3px 7px; + background-color: #444; + opacity: 0.9; +} + +#actions button.action-btn-sm:hover:not([disabled]) { + background-color: #555; +} + #actions button[disabled] { background-color: #ccc; cursor: not-allowed; } +.git-output { + position: fixed; + bottom: 50px; + right: 20px; + width: 420px; + background: #141414; + border: 1px solid #444; + border-radius: 5px; + font-family: monospace; + font-size: 11px; + z-index: 999; + box-shadow: 0 2px 10px rgba(0,0,0,0.6); +} + +.git-output-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 10px; + border-bottom: 1px solid #333; + color: #aaa; + font-size: 12px; +} + +.actions-link { + color: #6ab4ff; + font-size: 11px; + text-decoration: none; + white-space: nowrap; +} + +.actions-link:hover { + text-decoration: underline; +} + +.git-output-close { + background: none; + border: none; + color: #888; + cursor: pointer; + font-size: 14px; + padding: 0 2px; + line-height: 1; +} + +.git-output-close:hover { + color: #ccc; +} + +.git-output-body { + margin: 0; + padding: 8px 10px; + max-height: 220px; + overflow-y: auto; + color: #bbb; + white-space: pre-wrap; + word-break: break-all; +} + .github-link { display: inline-block; position: absolute; @@ -52,4 +157,26 @@ html, body { text-decoration: none; color: royalblue; -} \ No newline at end of file +} +.scratchpad { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 12px auto; + max-width: 600px; + padding: 0 16px; +} + +.scratchpad textarea { + flex: 1; + font-size: 18px; + padding: 8px; + border-radius: 4px; + border: 1px solid #ccc; + resize: vertical; + font-family: sans-serif; +} + +.scratchpad button { + white-space: nowrap; +} diff --git a/app/src/App.js b/app/src/App.js index 15ccda88..99fe5421 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -1,124 +1,362 @@ import '@fortawesome/fontawesome-free/css/all.css' import keyBy from 'lodash/keyBy' -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' -import * as config from './config' import './App.css'; import { DefinitionsContext } from './providers' import { loadKeycodes } from './keycodes' -import { loadBehaviours } from './api' -import KeyboardPicker from './Pickers/KeyboardPicker'; -import Spinner from './Common/Spinner'; +import { loadBehaviours, loadKeymap, loadMacros, saveMacros, loadCombos, saveCombos, saveKeymap, gitPush, loadAliases } from './api' import Keyboard from './Keyboard/Keyboard' -import GitHubLink from './GitHubLink' import Loader from './Common/Loader' -import github from './Pickers/Github/api' +import MacroEditor from './Macros/MacroEditor' +import ComboEditor from './Macros/ComboEditor' +import layoutData from './data/totem.json' +import defaultKeymap from './data/totem.keymap.json' +import { parseKeymap, encodeKeymap } from './keymap' + +const layout = layoutData.layouts.LAYOUT.layout + +const MAX_HISTORY = 50 + +function keymapHistoryReducer(state, action) { + switch (action.type) { + case 'SET_INITIAL': + return { past: [], present: action.keymap, future: [] } + case 'UPDATE': { + if (state.present === action.keymap) return state + return { + past: [...state.past.slice(-(MAX_HISTORY - 1)), state.present], + present: action.keymap, + future: [] + } + } + case 'UNDO': { + if (state.past.length === 0) return state + return { + past: state.past.slice(0, -1), + present: state.past[state.past.length - 1], + future: [state.present, ...state.future] + } + } + case 'REDO': { + if (state.future.length === 0) return state + return { + past: [...state.past, state.present], + present: state.future[0], + future: state.future.slice(1) + } + } + default: + return state + } +} function App() { - const [definitions, setDefinitions] = useState(null) - const [source, setSource] = useState(null) - const [sourceOther, setSourceOther] = useState(null) - const [layout, setLayout] = useState(null) - const [keymap, setKeymap] = useState(null) - const [editingKeymap, setEditingKeymap] = useState(null) + const [baseDefinitions, setBaseDefinitions] = useState(null) + const [macros, setMacros] = useState([]) + const [combos, setCombos] = useState([]) + const [keymapHistory, dispatch] = useReducer(keymapHistoryReducer, { + past: [], present: null, future: [] + }) + const editingKeymap = keymapHistory.present + const canUndo = keymapHistory.past.length > 0 + const canRedo = keymapHistory.future.length > 0 + + const canUndoRef = useRef(false) + const canRedoRef = useRef(false) + const handleCompileRef = useRef(null) + canUndoRef.current = canUndo + canRedoRef.current = canRedo + + const importRef = useRef(null) + const toastTimer = useRef(null) + + const [isDirty, setIsDirty] = useState(false) const [saving, setSaving] = useState(false) + const [pushing, setPushing] = useState(false) + const [toast, setToast] = useState(null) + const [pushOutput, setPushOutput] = useState(null) + const [scratchpad, setScratchpad] = useState('') - function handleCompile() { - fetch(`${config.apiBaseUrl}/keymap`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(editingKeymap || keymap) - }) - } + const showToast = useCallback((message, type = 'error') => { + clearTimeout(toastTimer.current) + setToast({ message, type }) + toastTimer.current = setTimeout(() => setToast(null), 4000) + }, []) + + useEffect(() => { + const handler = e => { + e.preventDefault() + e.returnValue = '' + } + if (isDirty) { + window.addEventListener('beforeunload', handler) + } + return () => window.removeEventListener('beforeunload', handler) + }, [isDirty]) + + useEffect(() => { + function handleKeydown(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return + const mod = e.ctrlKey || e.metaKey + if (mod && e.key === 's') { + e.preventDefault() + handleCompileRef.current?.() + } else if (mod && e.key === 'z' && !e.shiftKey) { + if (!canUndoRef.current) return + e.preventDefault() + dispatch({ type: 'UNDO' }) + setIsDirty(true) + } else if (mod && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + if (!canRedoRef.current) return + e.preventDefault() + dispatch({ type: 'REDO' }) + setIsDirty(true) + } + } + window.addEventListener('keydown', handleKeydown) + return () => window.removeEventListener('keydown', handleKeydown) + }, []) - const handleCommitChanges = useMemo(() => function() { - const { repository, branch } = sourceOther.github + const definitions = useMemo(() => { + if (!baseDefinitions) return null + const { keycodes, behaviours } = baseDefinitions + const macroBehaviours = macros.map(m => ({ + code: m.code, + name: m.name, + symbol: m.symbol, + params: [] + })) + const macroCodes = new Set(macroBehaviours.map(m => m.code)) + const allBehaviours = [ + ...behaviours.filter(b => !macroCodes.has(b.code)), + ...macroBehaviours + ] + allBehaviours.indexed = keyBy(allBehaviours, 'code') + return { keycodes, behaviours: allBehaviours } + }, [baseDefinitions, macros]) - ;(async function () { - setSaving(true) - await github.commitChanges(repository, branch, layout, editingKeymap) + async function handleCompile() { + setSaving(true) + try { + await saveCombos(combos) + await saveMacros(macros) + await saveKeymap(editingKeymap) + setIsDirty(false) + showToast('Saved successfully!', 'success') + } catch (err) { + showToast(err.message || 'Save failed', 'error') + } finally { setSaving(false) + } + } + handleCompileRef.current = handleCompile - setKeymap(editingKeymap) - setEditingKeymap(null) - })() - }, [ - layout, - editingKeymap, - sourceOther, - setSaving, - setKeymap, - setEditingKeymap - ]) - - const handleKeyboardSelected = useMemo(() => function(event) { - const { source, layout, keymap, ...other } = event - - setSource(source) - setSourceOther(other) - setLayout(layout) - setKeymap(keymap) - setEditingKeymap(null) - }, [ - setSource, - setSourceOther, - setLayout, - setKeymap, - setEditingKeymap - ]) - - const initialize = useMemo(() => { - return async function () { - const [keycodes, behaviours] = await Promise.all([ - loadKeycodes(), - loadBehaviours() - ]) - - keycodes.indexed = keyBy(keycodes, 'code') - behaviours.indexed = keyBy(behaviours, 'code') - - setDefinitions({ keycodes, behaviours }) + async function handlePush() { + setPushing(true) + try { + const result = await gitPush() + const text = [result.stdout, result.stderr].filter(Boolean).join('\n').trim() + setPushOutput({ text: text || '(no output)', actionsUrl: result.actionsUrl || null }) + } catch (err) { + showToast(err.message || 'Push failed', 'error') + } finally { + setPushing(false) } - }, [setDefinitions]) + } - const handleUpdateKeymap = useMemo(() => function(keymap) { - setEditingKeymap(keymap) - }, [setEditingKeymap]) + function handleExportKeymap() { + const encoded = encodeKeymap(editingKeymap) + const blob = new Blob([JSON.stringify(encoded, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'keymap.json' + a.click() + URL.revokeObjectURL(url) + } + + function handleImportKeymap(e) { + const file = e.target.files[0] + if (!file) return + const reader = new FileReader() + reader.onload = evt => { + try { + const raw = JSON.parse(evt.target.result) + // Accept both string format (from disk) and already-parsed format + const keymap = (raw.layers && raw.layers[0] && typeof raw.layers[0][0] === 'string') + ? parseKeymap(raw) + : raw + dispatch({ type: 'UPDATE', keymap }) + setIsDirty(true) + showToast('Keymap imported', 'success') + } catch { + showToast('Invalid keymap file', 'error') + } + } + reader.readAsText(file) + e.target.value = '' + } + + const initialize = useCallback(async () => { + const results = await Promise.allSettled([ + loadKeycodes(), + loadBehaviours(), + loadMacros(), + loadCombos(), + loadAliases() + ]) + const val = (r, fallback = []) => r.status === 'fulfilled' ? r.value : fallback + const [keycodes, behaviours, loadedMacros, loadedCombos, aliases] = results.map(r => val(r)) + + const allKeycodes = [...keycodes, ...aliases] + allKeycodes.indexed = keyBy(allKeycodes, 'code') + behaviours.indexed = keyBy(behaviours, 'code') + + setBaseDefinitions({ keycodes: allKeycodes, behaviours }) + setMacros(loadedMacros) + setCombos(loadedCombos) + + try { + const savedKeymap = await loadKeymap() + if (savedKeymap && savedKeymap.layers && savedKeymap.layers[0] && savedKeymap.layers[0].length > 0) { + dispatch({ type: 'SET_INITIAL', keymap: savedKeymap }) + } else { + dispatch({ type: 'SET_INITIAL', keymap: parseKeymap(defaultKeymap) }) + } + } catch (e) { + dispatch({ type: 'SET_INITIAL', keymap: parseKeymap(defaultKeymap) }) + } + }, []) + + const handleUpdateKeymap = useCallback((keymap) => { + dispatch({ type: 'UPDATE', keymap }) + setIsDirty(true) + }, []) + + const handleUndo = useCallback(() => { + dispatch({ type: 'UNDO' }) + setIsDirty(true) + }, []) + + const handleRedo = useCallback(() => { + dispatch({ type: 'REDO' }) + setIsDirty(true) + }, []) + + const handleUpdateMacros = useCallback((newMacros) => { + setMacros(newMacros) + setIsDirty(true) + }, []) + + const handleUpdateCombos = useCallback((newCombos) => { + setCombos(newCombos) + setIsDirty(true) + }, []) return ( <> -
- {source === 'local' && ( - - )} - {source === 'github' && ( - - )} + {isDirty && ● Unsaved} + + + + + +
+ + + + {toast && ( +
+ {toast.message} +
+ )} + + {pushOutput && ( +
+
+ git push +
+ {pushOutput.actionsUrl && ( + + View Actions → + + )} + +
+
+
{pushOutput.text}
+
+ )} + - {layout && keymap && ( + {editingKeymap && ( )} +
+