/* Разбор раскладки Vial (.vil) и планировщик маршрутов по слоям. Модуль не знает про DOM: язык/символы ему передаёт приложение через charOf(lang, code, shift). */ (() => { 'use strict'; /* ---------- Коды клавиш QMK -> KeyboardEvent.code ---------- */ const NAMES = { ENTER: 'Enter', ENT: 'Enter', ESCAPE: 'Escape', ESC: 'Escape', BSPACE: 'Backspace', BSPC: 'Backspace', TAB: 'Tab', SPACE: 'Space', SPC: 'Space', MINUS: 'Minus', MINS: 'Minus', EQUAL: 'Equal', EQL: 'Equal', LBRACKET: 'BracketLeft', LBRC: 'BracketLeft', RBRACKET: 'BracketRight', RBRC: 'BracketRight', BSLASH: 'Backslash', BSLS: 'Backslash', NONUS_HASH: 'Backslash', NUHS: 'Backslash', SCOLON: 'Semicolon', SCLN: 'Semicolon', QUOTE: 'Quote', QUOT: 'Quote', GRAVE: 'Backquote', GRV: 'Backquote', COMMA: 'Comma', COMM: 'Comma', DOT: 'Period', SLASH: 'Slash', SLSH: 'Slash', CAPSLOCK: 'CapsLock', CAPS: 'CapsLock', PSCREEN: 'PrintScreen', PSCR: 'PrintScreen', SCROLLLOCK: 'ScrollLock', SLCK: 'ScrollLock', PAUSE: 'Pause', PAUS: 'Pause', BRK: 'Pause', INSERT: 'Insert', INS: 'Insert', HOME: 'Home', PGUP: 'PageUp', DELETE: 'Delete', DEL: 'Delete', END: 'End', PGDOWN: 'PageDown', PGDN: 'PageDown', RIGHT: 'ArrowRight', RGHT: 'ArrowRight', LEFT: 'ArrowLeft', DOWN: 'ArrowDown', UP: 'ArrowUp', APPLICATION: 'ContextMenu', APP: 'ContextMenu', NUMLOCK: 'NumLock', NLCK: 'NumLock', LCTRL: 'ControlLeft', LCTL: 'ControlLeft', LSHIFT: 'ShiftLeft', LSFT: 'ShiftLeft', LALT: 'AltLeft', LGUI: 'MetaLeft', LWIN: 'MetaLeft', LCMD: 'MetaLeft', RCTRL: 'ControlRight', RCTL: 'ControlRight', RSHIFT: 'ShiftRight', RSFT: 'ShiftRight', RALT: 'AltRight', RGUI: 'MetaRight', RWIN: 'MetaRight', RCMD: 'MetaRight', KP_DOT: 'NumpadDecimal', PDOT: 'NumpadDecimal', KP_PLUS: 'NumpadAdd', PPLS: 'NumpadAdd', KP_MINUS: 'NumpadSubtract', PMNS: 'NumpadSubtract', KP_ASTERISK: 'NumpadMultiply', PAST: 'NumpadMultiply', KP_SLASH: 'NumpadDivide', PSLS: 'NumpadDivide', KP_ENTER: 'NumpadEnter', PENT: 'NumpadEnter', KP_EQUAL: 'NumpadEqual', PEQL: 'NumpadEqual' }; const MEDIA = { MPRV: '⏮', MNXT: '⏭', MPLY: '⏯', MSTP: '⏹', MUTE: '🔇', VOLU: 'Vol+', VOLD: 'Vol-', BRIU: '☀+', BRID: '☀-', MEDIA_PREV_TRACK: '⏮', MEDIA_NEXT_TRACK: '⏭', MEDIA_PLAY_PAUSE: '⏯', AUDIO_MUTE: '🔇', AUDIO_VOL_UP: 'Vol+', AUDIO_VOL_DOWN: 'Vol-', BRIGHTNESS_UP: '☀+', BRIGHTNESS_DOWN: '☀-' }; function codeOfName(n) { if (/^[A-Z]$/.test(n)) return 'Key' + n; if (/^[0-9]$/.test(n)) return 'Digit' + n; if (/^F([1-9]|1\d|2[0-4])$/.test(n)) return n; if (NAMES[n]) return NAMES[n]; const kp = /^(?:KP_|P)(\d)$/.exec(n); return kp ? 'Numpad' + kp[1] : null; } const SHIFT = 1, CTRL = 2, ALT = 4, GUI = 8; const WRAP = { LSFT: SHIFT, RSFT: SHIFT, S: SHIFT, LCTL: CTRL, RCTL: CTRL, C: CTRL, LALT: ALT, RALT: ALT, A: ALT, ALGR: ALT, LGUI: GUI, RGUI: GUI, G: GUI, LCMD: GUI, LWIN: GUI }; function keyP(code, raw) { return { t: 'key', code, raw, shift: false, other: false, holdShift: false, holdLayer: null, isMod: /^(Shift|Control|Alt|Meta)/.test(code), shiftSrc: /^Shift/.test(code) }; } /* Разбирает одну клавишу в удобную структуру. null = клавиши в этой позиции нет (-1). */ function parseKC(raw) { if (raw === -1 || raw == null) return null; const s = String(raw).trim(); if (s === 'KC_TRNS' || s === 'KC_TRANSPARENT' || s === '_______') return { t: 'trns', raw: s }; if (s === 'KC_NO' || s === 'XXXXXXX' || s === '') return { t: 'no', raw: s }; let m; if ((m = /^KC_(\w+)$/.exec(s))) { const code = codeOfName(m[1]); if (code) return keyP(code, s); return { t: 'other', raw: s, label: MEDIA[m[1]] || null }; } if ((m = /^(MO|TG|TO|OSL|TT|DF|PDF)\((\d+)\)$/.exec(s))) return { t: m[1] === 'PDF' ? 'df' : m[1].toLowerCase(), n: +m[2], raw: s }; if ((m = /^LT(\d+)\((.+)\)$/.exec(s)) || (m = /^LT\((\d+),\s*(.+)\)$/.exec(s))) { const inner = parseKC(m[2]); if (inner && inner.t === 'key') { inner.holdLayer = +m[1]; inner.raw = s; return inner; } return { t: 'other', raw: s }; } if ((m = /^OSM\((.+)\)$/.exec(s))) return { t: 'osm', shift: /SFT|SHIFT/.test(m[1]), raw: s }; if ((m = /^MT\((.+?),\s*(.+)\)$/.exec(s)) || (m = /^(?:[LR]?(?:SFT|CTL|ALT|GUI|WIN|CMD|OPT)|LCAG|ALL|MEH|HYPR|SGUI|LSG|LAG|RSG|RAG)_T\((.+)\)$/.exec(s))) { const isMT = s.startsWith('MT('); const inner = parseKC(isMT ? m[2] : m[1]); if (inner && inner.t === 'key') { inner.holdShift = isMT ? /SFT|SHIFT/.test(m[1]) : /^[LR]?SFT_T|^SGUI|^ALL|^MEH|^HYPR|^LSG|^RSG/.test(s); inner.raw = s; return inner; } return { t: 'other', raw: s }; } if ((m = /^(\w+)\((.+)\)$/.exec(s)) && WRAP[m[1]] !== undefined) { const inner = parseKC(m[2]); if (inner && inner.t === 'key') { const mods = WRAP[m[1]]; if (mods & SHIFT) inner.shift = true; if (mods & (CTRL | ALT | GUI)) inner.other = true; inner.raw = s; return inner; } } return { t: 'other', raw: s }; } /* ---------- Файл .vil ---------- */ function parseFile(text) { let d; try { d = JSON.parse(text); } catch (e) { return { error: 'Файл не похож на JSON. Нужен файл .vil из Vial (меню File → Save current layout).' }; } if (!d || !Array.isArray(d.layout) || !d.layout.length || !Array.isArray(d.layout[0])) return { error: 'В файле нет раскладки (поле "layout"). Это точно файл .vil из Vial?' }; const rows = d.layout[0].length; const cols = Math.max(...d.layout[0].map(r => (Array.isArray(r) ? r.length : 0))); if (!rows || !cols || rows > 20 || cols > 20) return { error: 'Не удалось определить размер матрицы клавиатуры.' }; const layers = d.layout.map(layer => { const out = []; for (let r = 0; r < rows; r++) { const row = []; for (let c = 0; c < cols; c++) { const k = layer[r] ? layer[r][c] : -1; row.push(k === -1 || k == null ? -1 : String(k)); } out.push(row); } return out; }); return { layout: { layers, rows, cols }, meta: { vialProtocol: d.vial_protocol || null } }; } /* ---------- Модель клавиатуры ---------- */ const bit = n => 1 << n; function createModel(layout, opts = {}) { const { layers, rows: R, cols: C } = layout, L = layers.length; const parsed = layers.map(layer => { const a = []; for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) a.push(parseKC(layer[r][c])); return a; }); const present = Array.from({ length: R * C }, (_, pos) => parsed.some(l => l[pos] !== null)); const split = opts.split !== false && R % 2 === 0 && R >= 2; const cells = new Array(R * C).fill(null), grid = {}; const rowHoles = r => { let n = 0; for (let c = 0; c < C; c++) if (!present[r * C + c]) n++; return n; }; const letterOf = d => (d <= 1 ? 'i' : d === 2 ? 'm' : d === 3 ? 'r' : 'p'); if (split) { const H = R / 2; for (const hand of ['L', 'R']) grid[hand] = Array.from({ length: H }, () => Array(C).fill(-1)); // Где у половинки внутренний край (у зазора): большие пальцы лежат под внутренними колонками. // Если по ним не понять - считаем, что у обеих половинок внутренний край у старших столбцов матрицы (как у Silakka54). const innerHigh = {}; for (const hand of ['L', 'R']) { const r = (hand === 'L' ? 0 : H) + H - 1, cols = []; for (let c = 0; c < C; c++) if (present[r * C + c]) cols.push(c); innerHigh[hand] = cols.length && cols.length < C ? cols.reduce((s, c) => s + c, 0) / cols.length > (C - 1) / 2 : true; } for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { const pos = r * C + c; if (!present[pos]) continue; const hand = r < H ? 'L' : 'R', rr = r % H, ih = innerHigh[hand]; const d = ih ? C - 1 - c : c; // расстояние до внутреннего края const x = hand === 'L' ? C - 1 - d : d; // положение слева направо на экране const thumb = rr === H - 1 && rowHoles(r) > 0; grid[hand][rr][x] = pos; cells[pos] = { hand, rr, x, thumb, finger: thumb ? 'th' : hand.toLowerCase() + letterOf(d) }; } } else { grid.A = Array.from({ length: R }, () => Array(C).fill(-1)); for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) { const pos = r * C + c; if (!present[pos]) continue; const hand = c < C / 2 ? 'L' : 'R', d = hand === 'L' ? Math.floor(C / 2) - 1 - c : c - Math.floor(C / 2); const thumb = r === R - 1 && rowHoles(r) > 0; grid.A[r][c] = pos; cells[pos] = { hand, rr: r, x: c, thumb, finger: thumb ? 'th' : hand.toLowerCase() + letterOf(d) }; } } const positions = []; present.forEach((p, i) => { if (p) positions.push(i); }); const model = { layout, L, R, C, parsed, present, positions, split, cells, grid }; model.used = usedLayers(model); return model; } function usedLayers(model) { const out = []; for (let l = 0; l < model.L; l++) { if (l === 0 || model.parsed[l].some(p => p && p.t !== 'trns' && p.t !== 'no')) out.push(l); } return out; } const isDigitCode = c => /^(Digit\d|Numpad\d)$/.test(c); function guessNumLayer(model) { let best = null, bestN = 3; for (const l of model.used) { if (l === 0) continue; const n = model.parsed[l].filter(p => p && p.t === 'key' && isDigitCode(p.code)).length; if (n > bestN) { best = l; bestN = n; } } return best; } function guessName(model, l) { if (l === 0) return 'Базовый'; const ps = model.parsed[l].filter(Boolean); const digits = ps.filter(p => p.t === 'key' && isDigitCode(p.code)).length; const fn = ps.filter(p => p.t === 'key' && /^(F\d+|Arrow.*|Home|End|Page.*)$/.test(p.code)).length; if (digits >= 6) return 'Цифры'; if (fn >= 6) return 'Fn / навигация'; return 'Слой ' + l; } /* ---------- Разрешение клавиши с учётом активных слоёв ---------- */ function resolve(model, A, pos) { for (let l = model.L - 1; l >= 0; l--) { if (!(A & bit(l)) && l !== 0) continue; const p = model.parsed[l][pos]; if (p === null) return null; if (p.t !== 'trns') return { p, layer: l }; } return null; } /* ---------- Планировщик ---------- */ function actionLabel(p) { return p.t === 'key' ? 'LT' + p.holdLayer : p.t.toUpperCase(); } function ctxFor(model, o) { return { model, lang: o.lang, charOf: o.charOf, prefer: o.prefer == null ? null : o.prefer, maxDepth: o.maxDepth == null ? 2 : o.maxDepth, cache: new Map(), actCache: new Map(), shiftCache: new Map() }; } function actionsUnder(ctx, A) { if (ctx.actCache.has(A)) return ctx.actCache.get(A); const seen = new Set(), out = []; for (const pos of ctx.model.positions) { const r = resolve(ctx.model, A, pos); if (!r) continue; const p = r.p; let kind = null, n = null; if (p.t === 'mo' || p.t === 'tt') { kind = 'hold'; n = p.n; } else if (p.t === 'tg') { kind = 'toggle'; n = p.n; } else if (p.t === 'to') { kind = 'to'; n = p.n; } else if (p.t === 'osl') { kind = 'osl'; n = p.n; } else if (p.t === 'key' && p.holdLayer != null) { kind = 'hold'; n = p.holdLayer; } if (!kind || n >= ctx.model.L) continue; if (kind === 'toggle' && n === 0) continue; const k = kind + n; if (seen.has(k)) continue; seen.add(k); out.push({ kind, n, pos, label: actionLabel(p) + (p.t === 'key' ? '' : '(' + n + ')') }); } ctx.actCache.set(A, out); return out; } function shiftSources(ctx, A) { if (ctx.shiftCache.has(A)) return ctx.shiftCache.get(A); const out = []; for (const pos of ctx.model.positions) { const r = resolve(ctx.model, A, pos); if (!r) continue; const p = r.p; if ((p.t === 'key' && (p.shiftSrc || p.holdShift)) || (p.t === 'osm' && p.shift)) out.push(pos); } ctx.shiftCache.set(A, out); return out; } function typeOptions(ctx, A, ch) { const m = ctx.model, opts = []; for (const pos of m.positions) { const r = resolve(m, A, pos); if (!r || r.p.t !== 'key' || r.p.other || r.p.isMod) continue; const p = r.p; if (ctx.charOf(ctx.lang, p.code, p.shift) === ch) { opts.push({ pos, layer: r.layer, extra: 0, shiftPos: null }); continue; } if (!p.shift && ctx.charOf(ctx.lang, p.code, true) === ch) { let best = null; for (const sp of shiftSources(ctx, A)) { if (sp === pos) continue; const cost = m.cells[sp].hand === m.cells[pos].hand ? 1.5 : 1; if (!best || cost < best.cost) best = { pos: sp, cost }; } if (best) opts.push({ pos, layer: r.layer, extra: best.cost, shiftPos: best.pos }); } } return opts; } /* Все способы напечатать ch из состояния постоянных слоёв P0: по одному лучшему на каждое итоговое состояние P'. */ function search(ctx, P0, ch) { const ck = P0 + ':' + ch; if (ctx.cache.has(ck)) return ctx.cache.get(ck); let frontier = [{ P: P0, H: 0, actions: [] }]; const seen = new Set([P0 + ':0']), cands = []; for (let depth = 0; depth <= ctx.maxDepth && frontier.length; depth++) { const next = []; for (const st of frontier) { const A = 1 | st.P | st.H; for (const o of typeOptions(ctx, A, ch)) cands.push({ P: st.P, A, actions: st.actions, opt: o, cost: st.actions.length + o.extra }); if (depth === ctx.maxDepth) continue; for (const act of actionsUnder(ctx, A)) { let P = st.P, H = st.H; if (act.kind === 'hold' || act.kind === 'osl') { if (A & bit(act.n)) continue; H |= bit(act.n); } else if (act.kind === 'toggle') P ^= bit(act.n); else P = act.n ? bit(act.n) : 0; const k = P + ':' + H; if (seen.has(k)) continue; seen.add(k); next.push({ P, H, actions: st.actions.concat(act) }); } } frontier = next; } let use = cands; if (ctx.prefer != null) { const pf = cands.filter(c => c.opt.layer === ctx.prefer); if (pf.length) use = pf; } const best = new Map(); for (const c of use) { const b = best.get(c.P); if (!b || c.cost < b.cost || (c.cost === b.cost && c.actions.length < b.actions.length)) best.set(c.P, c); } const res = [...best.values()]; ctx.cache.set(ck, res); return res; } /* Планирует весь текст: динамическое программирование по состоянию постоянных слоёв (минимум действий). */ function plan(model, text, o) { const ctx = ctxFor(model, o); const chars = [...text], P0 = o.startP || 0; let layer = new Map([[P0, { cost: 0 }]]); const steps = []; for (const ch of chars) { const nxt = new Map(); for (const [P, st] of layer) { const res = search(ctx, P, ch); if (!res.length) { const total = st.cost + 0.001; if (!nxt.has(P) || total < nxt.get(P).cost) nxt.set(P, { cost: total, from: P, cand: null }); continue; } for (const c of res) { const total = st.cost + c.cost; if (!nxt.has(c.P) || total < nxt.get(c.P).cost) nxt.set(c.P, { cost: total, from: P, cand: c }); } } steps.push(nxt); layer = nxt; } let P = null, bestCost = Infinity; for (const [k, v] of layer) if (v.cost < bestCost) { bestCost = v.cost; P = k; } const entries = new Array(chars.length); for (let i = chars.length - 1; i >= 0; i--) { const e = steps[i].get(P), c = e.cand; entries[i] = c ? { ch: chars[i], pos: c.opt.pos, shiftPos: c.opt.shiftPos, layer: c.opt.layer, actions: c.actions, A: c.A, Pbefore: e.from, Pafter: P, sw: c.actions.length > 0 } : { ch: chars[i], pos: null, shiftPos: null, layer: 0, actions: [], A: 1 | e.from, Pbefore: e.from, Pafter: P, sw: false, unreachable: true }; P = e.from; } const missing = new Set(entries.filter(e => e.unreachable).map(e => e.ch)); return { entries, missing, switches: entries.filter(e => e.actions.some(a => a.kind === 'toggle' || a.kind === 'to')).length, holds: entries.filter(e => e.actions.some(a => a.kind === 'hold' || a.kind === 'osl')).length }; } /* Какие из символов вообще нельзя напечатать на этой клавиатуре (из состояния «базовый слой»). */ function unreachable(model, o, chars) { const ctx = ctxFor(model, { ...o, prefer: null, maxDepth: 2 }); return [...chars].filter(ch => !search(ctx, 0, ch).length); } window.Vial = { parseFile, parseKC, createModel, resolve, plan, unreachable, guessNumLayer, guessName, usedLayers, bit, isDigitCode }; })();