From c180642fa8e125d2f3e275162fb9d9e0c9129844 Mon Sep 17 00:00:00 2001 From: EwdErna Date: Sun, 25 Oct 2026 10:59:44 +0900 Subject: [PATCH] =?UTF-8?q?mock:=20IDX=20Editor=E3=82=92=E4=BD=9C=E3=82=8A?= =?UTF-8?q?=E7=9B=B4=E3=81=99(T-31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provisional/idx-editor-feedback.md の合意事項と T-31 の仮決定に沿って、IdxPanel を捨てて ui/idx/ に一から書き直した。 並びのリストは置かず、左にバリアントのツリー、中央のキャンバスでドラッグして並べ替え、右にプリセット・取り込み・未登録の登録を置いた。 - order の操作(隠れたキーのくっつき先、挿入、入れ替え、連番の並べ直し)を domain/idxOrder.ts に、 走査・半身の分割・行の割り当てのずらし・マトリクス順を domain/idxScan.ts に、純粋関数として置いた。行と列の判定は rows.ts をそのまま使う - QMK keyboard.json・VIA定義・KLE JSON の取り込みは、位置と回転で表示中のキーに照合する(io/layoutImport.ts、domain/idxImport.ts) - 回転したキーでも出力番号を正立させる - Codex の設定ファイルが壊れていて起動できなかったため、実装は Claude が直接行った - 検証: vue-tsc 0件、Vitest 101件(うち新規17件)、Playwright 48件(IDX 8件を書き直し)、prettier --check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg --- mock/e2e/idx.spec.ts | 128 +++++- mock/e2e/phy.spec.ts | 3 +- mock/src/domain/idxImport.ts | 39 ++ mock/src/domain/idxOrder.ts | 110 ++++++ mock/src/domain/idxScan.ts | 158 ++++++++ mock/src/io/layoutImport.ts | 54 +++ mock/src/ui/App.vue | 14 +- mock/src/ui/idx/IdxEditor.vue | 657 +++++++++++++++++++++++++++++++ mock/src/ui/idx/IdxImport.vue | 91 +++++ mock/src/ui/idx/IdxPresets.vue | 149 +++++++ mock/src/ui/idx/IdxTree.vue | 91 +++++ mock/src/ui/idx/logic.ts | 67 ++++ mock/src/ui/idx/presets.ts | 21 + mock/src/ui/idx/useIdxPointer.ts | 108 +++++ mock/src/ui/panels/IdxPanel.vue | 254 ------------ mock/tests/idxEdit.test.ts | 322 +++++++++++++++ 16 files changed, 1985 insertions(+), 281 deletions(-) create mode 100644 mock/src/domain/idxImport.ts create mode 100644 mock/src/domain/idxOrder.ts create mode 100644 mock/src/domain/idxScan.ts create mode 100644 mock/src/io/layoutImport.ts create mode 100644 mock/src/ui/idx/IdxEditor.vue create mode 100644 mock/src/ui/idx/IdxImport.vue create mode 100644 mock/src/ui/idx/IdxPresets.vue create mode 100644 mock/src/ui/idx/IdxTree.vue create mode 100644 mock/src/ui/idx/logic.ts create mode 100644 mock/src/ui/idx/presets.ts create mode 100644 mock/src/ui/idx/useIdxPointer.ts delete mode 100644 mock/src/ui/panels/IdxPanel.vue create mode 100644 mock/tests/idxEdit.test.ts diff --git a/mock/e2e/idx.spec.ts b/mock/e2e/idx.spec.ts index 23565f9..c5d596d 100644 --- a/mock/e2e/idx.spec.ts +++ b/mock/e2e/idx.spec.ts @@ -1,41 +1,129 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; + +const canvasKeys = (page: Page) => page.locator('[data-testid="idx-canvas"] .key'); +const numberOf = (page: Page, i: number) => canvasKeys(page).nth(i).getByTestId("idx-number"); + +/** Drags key `from` onto key `to`, releasing at `fraction` of the target's width. */ +async function dragKey(page: Page, from: number, to: number, fraction: number) { + const a = (await canvasKeys(page).nth(from).locator("rect").boundingBox())!; + const b = (await canvasKeys(page).nth(to).locator("rect").boundingBox())!; + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(a.x + a.width / 2 + 10, a.y + a.height / 2, { steps: 3 }); + await page.mouse.move(b.x + b.width * fraction, b.y + b.height / 2, { steps: 5 }); + await page.mouse.up(); +} test.beforeEach(async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "IDX", exact: true }).click(); + await expect(canvasKeys(page)).toHaveCount(16); }); -test("new PHY key is registered at the end, reorder its output number, and undo", async ({ page }) => { +test("drops at the edge of a key insert before or after it, and undo restores", async ({ page }) => { + await dragKey(page, 0, 3, 0.9); + await expect(numberOf(page, 0)).toHaveText("3"); + await expect(numberOf(page, 3)).toHaveText("2"); + await expect(numberOf(page, 1)).toHaveText("0"); + await dragKey(page, 0, 1, 0.1); + await expect(numberOf(page, 0)).toHaveText("0"); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(numberOf(page, 0)).toHaveText("3"); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(numberOf(page, 0)).toHaveText("0"); + await expect(numberOf(page, 3)).toHaveText("3"); +}); + +test("a drop in the middle of a key swaps the two and leaves the others", async ({ page }) => { + await dragKey(page, 0, 5, 0.5); + await expect(numberOf(page, 0)).toHaveText("5"); + await expect(numberOf(page, 5)).toHaveText("0"); + await expect(numberOf(page, 1)).toHaveText("1"); + await expect(numberOf(page, 4)).toHaveText("4"); +}); + +test("a key added in PHY is registered at the end and can be dragged to the front", async ({ page }) => { await page.getByRole("button", { name: "PHY", exact: true }).click(); await page.getByRole("button", { name: "キーを足す" }).click(); - const keyId = await page.locator('[data-node-type="key"]').last().getAttribute("data-node-id"); await page.getByRole("button", { name: "IDX", exact: true }).click(); - const row = page.getByTestId(`idx-row-${keyId}`); - await expect(row).toBeVisible(); - const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`); - await expect(previewKey).toHaveText("16"); - - await row.getByRole("button", { name: `${keyId}を上へ` }).click(); - await expect(previewKey).toHaveText("15"); - await page.getByRole("button", { name: "Undo" }).click(); - await expect(previewKey).toHaveText("16"); + await expect(numberOf(page, 16)).toHaveText("16"); + await dragKey(page, 16, 0, 0.1); + await expect(numberOf(page, 16)).toHaveText("0"); + await expect(numberOf(page, 0)).toHaveText("1"); }); -test("output numbers recalculate after switching to a variant that excludes ordered keys", async ({ +test("the variant tree switches the shown configuration without touching other keys' order", async ({ page, }) => { await page.getByRole("button", { name: "PHY", exact: true }).click(); const keys = page.locator('[data-node-type="key"]'); const firstId = await keys.nth(0).getAttribute("data-node-id"); - const secondId = await keys.nth(1).getAttribute("data-node-id"); await keys.nth(0).click(); await keys.nth(1).click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "選択からバリアントグループ" }).click(); await page.getByRole("button", { name: "IDX", exact: true }).click(); - await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0"); - await page.getByLabel("Variant Group 出力バリアント").selectOption({ label: "Option B" }); - await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外"); - await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外"); - const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" }); - await expect(remaining).toHaveCount(1); + const optionA = page.locator(".tree").getByRole("button", { name: /Option A/ }); + const optionB = page.locator(".tree").getByRole("button", { name: /Option B/ }); + await expect(optionA).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(1); + await optionB.click(); + await expect(optionB).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(0); + await expect(canvasKeys(page)).toHaveCount(14); + await expect(numberOf(page, 0)).toHaveText("0"); +}); + +test("a column-major preset previews, applies as one undo step", async ({ page }) => { + await page.getByLabel("走査").selectOption({ label: "列優先" }); + await page.getByRole("button", { name: "プレビュー" }).click(); + await expect(numberOf(page, 4)).toHaveText("1"); + await expect(page.locator(".scan-line")).toHaveCount(4); + await page.getByRole("button", { name: "適用" }).click(); + await expect(numberOf(page, 4)).toHaveText("1"); + await expect(numberOf(page, 1)).toHaveText("4"); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(numberOf(page, 1)).toHaveText("1"); +}); + +test("the preview shifts a picked column by one row and Esc discards it", async ({ page }) => { + await page.getByRole("button", { name: "プレビュー" }).click(); + await canvasKeys(page).nth(0).click(); + await page.keyboard.press("ArrowDown"); + // column 0 now belongs one row lower: row 0 is keys 1..3 + await expect(numberOf(page, 1)).toHaveText("0"); + await expect(numberOf(page, 0)).toHaveText("3"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "プレビュー" })).toBeVisible(); + await expect(numberOf(page, 0)).toHaveText("0"); +}); + +test("a range selection renumbers from its first key's position", async ({ page }) => { + const k2 = (await canvasKeys(page).nth(2).locator("rect").boundingBox())!; + const k15 = (await canvasKeys(page).nth(15).locator("rect").boundingBox())!; + await page.mouse.move(k2.x + 5, k2.y - 20); + await page.mouse.down(); + await page.mouse.move(k15.x + k15.width + 20, k15.y + k15.height + 20, { steps: 6 }); + await page.mouse.up(); + await expect(page.getByText("選択範囲(8)")).toBeVisible(); + await page.getByText("選択範囲(8)").click(); + await page.getByLabel("走査").selectOption({ label: "列優先" }); + await page.getByRole("button", { name: "プレビュー" }).click(); + await page.getByRole("button", { name: "適用" }).click(); + // 0, 1, then the right half column by column, then the rest of the left half + await expect(numberOf(page, 2)).toHaveText("2"); + await expect(numberOf(page, 6)).toHaveText("3"); + await expect(numberOf(page, 3)).toHaveText("6"); + await expect(numberOf(page, 4)).toHaveText("10"); +}); + +test("importing a QMK layout copies its key order onto matching keys", async ({ page }) => { + const layout = [0, 1, 2, 3].flatMap((x) => [0, 1, 2, 3].map((y) => ({ matrix: [y, x], x, y }))); + await page.getByTestId("idx-import-file").setInputFiles({ + name: "keyboard.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify({ layouts: { LAYOUT: { layout } } })), + }); + await expect(page.getByTestId("idx-import-result")).toContainText("16件を照合"); + await expect(numberOf(page, 4)).toHaveText("1"); + await expect(numberOf(page, 1)).toHaveText("4"); }); diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts index acba6e2..ec5c53d 100644 --- a/mock/e2e/phy.spec.ts +++ b/mock/e2e/phy.spec.ts @@ -336,7 +336,8 @@ test("削除確認は参照を列挙し、実行後はIDXからも消える", as await confirm.getByRole("button", { name: "削除", exact: true }).click(); await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0); await page.getByRole("button", { name: "IDX", exact: true }).click(); - await expect(page.getByTestId(`idx-row-${id}`)).toHaveCount(0); + await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${id}"]`)).toHaveCount(0); + await expect(page.getByTestId("idx-register-all")).toBeDisabled(); }); test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => { diff --git a/mock/src/domain/idxImport.ts b/mock/src/domain/idxImport.ts new file mode 100644 index 0000000..76a928d --- /dev/null +++ b/mock/src/domain/idxImport.ts @@ -0,0 +1,39 @@ +import type { FlatKey } from "./phy"; + +/** World position of a key origin and its rotation, as read from an existing definition. T-31. */ +export type Placement = { x: number; y: number; rotation: number }; + +const TOLERANCE = 0.01; + +const angle = (deg: number) => ((deg % 360) + 360) % 360; + +function sameRotation(a: number, b: number): boolean { + const d = Math.abs(angle(a) - angle(b)); + return Math.min(d, 360 - d) <= TOLERANCE; +} + +/** Matches placements one-to-one to the shown keys by position and rotation, in placement order. */ +export function matchPlacements( + placements: readonly Placement[], + keys: readonly FlatKey[], +): { ids: string[]; unmatched: number } { + const used = new Set(); + const ids: string[] = []; + for (const p of placements) { + let best: FlatKey | undefined, + distance = Infinity; + for (const k of keys) { + if (used.has(k.key.id) || !sameRotation(p.rotation, k.rotation)) continue; + const dx = Math.abs(p.x - k.x), + dy = Math.abs(p.y - k.y); + if (dx > TOLERANCE || dy > TOLERANCE || dx + dy >= distance) continue; + best = k; + distance = dx + dy; + } + if (best) { + used.add(best.key.id); + ids.push(best.key.id); + } + } + return { ids, unmatched: placements.length - ids.length }; +} diff --git a/mock/src/domain/idxOrder.ts b/mock/src/domain/idxOrder.ts new file mode 100644 index 0000000..43354dd --- /dev/null +++ b/mock/src/domain/idxOrder.ts @@ -0,0 +1,110 @@ +import type { Node, PhyFile } from "./phy"; + +/** Keys shown in the edited configuration and the VariantGroups each key sits under. T-31. */ +export type OrderContext = { + visible: ReadonlySet; + groups: ReadonlyMap; +}; + +/** Lists the VariantGroup ancestors of every key, outermost first. Common keys get an empty list. */ +export function keyVariantGroups(phy: PhyFile): Map { + const out = new Map(); + const visit = (nodes: Node[], groups: string[]) => + nodes.forEach((n) => { + if (n.type === "key") out.set(n.id, groups); + else if (n.type === "cluster") visit(n.children, groups); + else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children, [...groups, n.id])); + }); + visit(phy.root, []); + return out; +} + +function sharesGroup(ctx: OrderContext, a: string, b: string): boolean { + const mine = ctx.groups.get(a) ?? []; + return (ctx.groups.get(b) ?? []).some((g) => mine.includes(g)); +} + +/** + * Splits `order` into one segment per visible key, each holding the hidden keys attached to it. + * A hidden key attaches to the nearer visible key that shares a VariantGroup with it, + * preferring the one before it; once a hidden key attaches forward, the rest of that run does too. + * Concatenating the segments gives `order` back. Without visible keys there are no segments. + */ +export function orderSegments(order: readonly string[], ctx: OrderContext): string[][] { + const at = order.flatMap((id, i) => (ctx.visible.has(id) ? [i] : [])); + if (!at.length) return []; + const segments = at.map((i) => [order[i]!]); + segments[0]!.unshift(...order.slice(0, at[0])); + segments.at(-1)!.push(...order.slice(at.at(-1)! + 1)); + for (let k = 0; k + 1 < at.length; k++) { + const prev = order[at[k]!]!, + next = order[at[k + 1]!]!, + run = order.slice(at[k]! + 1, at[k + 1]); + let split = run.findIndex((h) => sharesGroup(ctx, h, next) && !sharesGroup(ctx, h, prev)); + if (split < 0) split = run.length; + segments[k]!.push(...run.slice(0, split)); + segments[k + 1]!.unshift(...run.slice(split)); + } + return segments; +} + +/** + * Moves `keyId` to gap `gap` (0..V) between the visible keys left after taking it out. + * Hidden keys that were attached to it stay where they are. Unregistered keys are registered there. + */ +export function insertAtGap( + order: readonly string[], + ctx: OrderContext, + keyId: string, + gap: number, +): string[] { + const rest = order.filter((id) => id !== keyId); + const segments = orderSegments(rest, ctx); + if (gap >= segments.length) return [...rest, keyId]; + return [...segments.slice(0, gap).flat(), keyId, ...segments.slice(gap).flat()]; +} + +/** Moves `keyId` right before (`after` false) or right after the visible key `target`. */ +export function insertNextTo( + order: readonly string[], + ctx: OrderContext, + keyId: string, + target: string, + after: boolean, +): string[] { + if (keyId === target) return [...order]; + const visibleRest = order.filter((id) => id !== keyId && ctx.visible.has(id)); + const index = visibleRest.indexOf(target); + if (index < 0) throw new Error(`Not a visible registered key: ${target}`); + return insertAtGap(order, ctx, keyId, index + (after ? 1 : 0)); +} + +/** Swaps two registered keys. Every other key keeps its position. */ +export function swapKeys(order: readonly string[], a: string, b: string): string[] { + const i = order.indexOf(a), + j = order.indexOf(b); + if (i < 0 || j < 0) throw new Error("Both keys must be registered"); + const out = [...order]; + [out[i], out[j]] = [out[j]!, out[i]!]; + return out; +} + +/** + * Places the visible keys of `sequence` consecutively, starting where its first registered key was + * among the other visible keys. Other visible keys keep their relative order, hidden keys move with + * the visible key they are attached to, and unregistered keys in `sequence` are registered. T-31. + */ +export function renumber(order: readonly string[], ctx: OrderContext, sequence: readonly string[]): string[] { + const registered = new Set(order); + const seq = [...new Set(sequence)].filter((id) => ctx.visible.has(id) || !registered.has(id)); + const inSeq = new Set(seq); + const anchor = seq.find((id) => registered.has(id)); + if (!anchor) return [...order, ...seq]; + const segments = orderSegments(order, ctx); + const byKey = new Map(segments.map((s) => [s.find((id) => ctx.visible.has(id))!, s])); + const others = segments.filter((s) => !s.some((id) => inSeq.has(id))); + const anchorAt = order.indexOf(anchor); + const gap = others.filter((s) => order.indexOf(s.find((id) => ctx.visible.has(id))!) < anchorAt).length; + const block = seq.flatMap((id) => byKey.get(id) ?? [id]); + return [...others.slice(0, gap).flat(), ...block, ...others.slice(gap).flat()]; +} diff --git a/mock/src/domain/idxScan.ts b/mock/src/domain/idxScan.ts new file mode 100644 index 0000000..e734948 --- /dev/null +++ b/mock/src/domain/idxScan.ts @@ -0,0 +1,158 @@ +import type { FlatKey } from "./phy"; +import type { ProjectFile } from "./project"; +import { detectColumns, detectRows, type KeyRect } from "./rows"; + +/** Gaps narrower than this do not separate the two halves. T-31. */ +const MIN_HALF_GAP = 0.5; + +export type ScanKind = "rows" | "columns" | "halves" | "alternating"; +export type Direction = "normal" | "reversed"; +export type ScanOptions = { + kind: ScanKind; + /** Whole-board direction for `rows` and `columns`. */ + direction: Direction; + /** Per-half directions for `halves` and `alternating`. */ + left: Direction; + right: Direction; + /** Shift of the line assignment per cross line index (columns for row scans, rows for `columns`). */ + offsets: ReadonlyMap; +}; +export type ScanResult = { + sequence: string[]; + /** Lines the scan walks, for the preview: rows, or columns for `columns`. */ + lines: string[][]; + /** Lines that can be shifted with `offsets`, in index order. */ + crossLines: string[][]; + /** x of the split between the halves, for scans that use halves. */ + boundary?: number; +}; + +/** Axis-aligned bounding box of a key in world coordinates; rotated keys are judged by it. T-31. */ +export function keyRect(k: FlatKey): KeyRect { + const r = (k.rotation * Math.PI) / 180, + c = Math.cos(r), + s = Math.sin(r); + const { w, h } = k.key.size; + const xs = [0, w * c, -h * s, w * c - h * s].map((dx) => k.x + dx); + const ys = [0, w * s, h * c, w * s + h * c].map((dy) => k.y + dy); + const x = Math.min(...xs), + y = Math.min(...ys); + return { id: k.key.id, x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; +} + +/** + * Splits keys into left and right halves at the widest gap between detected columns. + * When no gap reaches MIN_HALF_GAP, splits at the middle of the keys' x range by key center. + */ +export function splitHalves(rects: KeyRect[]): { left: Set; right: Set; boundary: number } { + const columns = detectColumns(rects); + const byId = new Map(rects.map((r) => [r.id, r])); + const span = columns.map((col) => { + const rs = col.map((id) => byId.get(id)!); + return { min: Math.min(...rs.map((r) => r.x)), max: Math.max(...rs.map((r) => r.x + r.w)) }; + }); + let best = -1, + width = -Infinity; + for (let i = 0; i + 1 < span.length; i++) { + const gap = span[i + 1]!.min - span[i]!.max; + if (gap > width) [best, width] = [i, gap]; + } + if (best >= 0 && width >= MIN_HALF_GAP) { + const left = new Set(columns.slice(0, best + 1).flat()); + return { + left, + right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))), + boundary: (span[best]!.max + span[best + 1]!.min) / 2, + }; + } + const boundary = rects.length + ? (Math.min(...rects.map((r) => r.x)) + Math.max(...rects.map((r) => r.x + r.w))) / 2 + : 0; + const left = new Set(rects.filter((r) => r.x + r.w / 2 < boundary).map((r) => r.id)); + return { left, right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))), boundary }; +} + +/** + * Detects rows (or columns), then moves every key of cross line `i` by `offsets.get(i)` lines. + * Rows are rebuilt top to bottom and sorted by x; columns left to right and sorted by y. + */ +export function assignLines( + rects: KeyRect[], + kind: "row" | "column", + offsets: ReadonlyMap, +): { lines: string[][]; crossLines: string[][] } { + const base = kind === "row" ? detectRows(rects) : detectColumns(rects); + const crossLines = kind === "row" ? detectColumns(rects) : detectRows(rects); + const line = new Map(base.flatMap((ids, i) => ids.map((id) => [id, i] as const))); + crossLines.forEach((ids, i) => ids.forEach((id) => line.set(id, line.get(id)! + (offsets.get(i) ?? 0)))); + const byId = new Map(rects.map((r) => [r.id, r])); + const along = (id: string) => (kind === "row" ? byId.get(id)!.x : byId.get(id)!.y); + const indexes = [...new Set(line.values())].sort((a, b) => a - b); + const lines = indexes.map((i) => + [...line] + .filter(([, at]) => at === i) + .map(([id]) => id) + .sort((a, b) => along(a) - along(b)), + ); + return { lines, crossLines }; +} + +const directed = (items: T[], d: Direction) => (d === "reversed" ? [...items].reverse() : items); + +/** Builds the key sequence of a preset scan over the target keys. T-31. */ +export function scanKeys(rects: KeyRect[], options: ScanOptions): ScanResult { + if (options.kind === "columns") { + const { lines, crossLines } = assignLines(rects, "column", options.offsets); + return { sequence: directed(lines, options.direction).flat(), lines, crossLines }; + } + const { lines, crossLines } = assignLines(rects, "row", options.offsets); + if (options.kind === "rows") + return { sequence: lines.flatMap((row) => directed(row, options.direction)), lines, crossLines }; + const { left, boundary } = splitHalves(rects); + const leftPart = (row: string[]) => + directed( + row.filter((id) => left.has(id)), + options.left, + ); + const rightPart = (row: string[]) => + directed( + row.filter((id) => !left.has(id)), + options.right, + ); + const sequence = + options.kind === "halves" + ? [...lines.flatMap(leftPart), ...lines.flatMap(rightPart)] + : lines.flatMap((row) => [...leftPart(row), ...rightPart(row)]); + return { sequence, lines, crossLines, boundary }; +} + +/** + * Finds each contact key's (row, col) from the matrix pins, by the same rule as the Generator. + * Returns undefined when the project has no matrix settings. T-19. + */ +export function matrixPositions(p: ProjectFile): Map | undefined { + const matrix = p.output.matrix; + if (!matrix) return undefined; + const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])), + colIndex = new Map(matrix.colPins.map((x, i) => [x, i])); + const out = new Map(); + for (const w of p.elc.keys) { + if (w.detect !== "contact") continue; + const { a, b } = w.terminals; + if (rowIndex.has(a) && colIndex.has(b)) out.set(w.key, { row: rowIndex.get(a)!, col: colIndex.get(b)! }); + else if (rowIndex.has(b) && colIndex.has(a)) + out.set(w.key, { row: rowIndex.get(b)!, col: colIndex.get(a)! }); + } + return out; +} + +/** Sorts keys by row then col. Keys without a matrix position keep their input order at the end. */ +export function matrixSequence( + ids: readonly string[], + positions: ReadonlyMap, +) { + const placed = ids.filter((id) => positions.has(id)); + const rest = ids.filter((id) => !positions.has(id)); + const key = (id: string) => positions.get(id)!; + return [...placed.sort((a, b) => key(a).row - key(b).row || key(a).col - key(b).col), ...rest]; +} diff --git a/mock/src/io/layoutImport.ts b/mock/src/io/layoutImport.ts new file mode 100644 index 0000000..cfbcf08 --- /dev/null +++ b/mock/src/io/layoutImport.ts @@ -0,0 +1,54 @@ +import { flattenKeys } from "../domain/geometry"; +import type { Placement } from "../domain/idxImport"; +import { importKle } from "./kleImport"; + +type Obj = Record; +const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); +const num = (v: unknown, fallback = 0) => (typeof v === "number" ? v : fallback); + +/** Result of reading an existing definition. `layouts` lists the choices when one must be picked. */ +export type LayoutRead = { placements: Placement[]; layouts?: string[]; layout?: string }; + +/** QMK keyboard.json/info.json layout names, or undefined when the input is not a QMK definition. */ +export function qmkLayoutNames(input: unknown): string[] | undefined { + if (!isObj(input) || !isObj(input.layouts) || Array.isArray(input.layouts.keymap)) return undefined; + const names = Object.entries(input.layouts) + .filter(([, v]) => isObj(v) && Array.isArray(v.layout)) + .map(([name]) => name); + return names.length ? names : undefined; +} + +/** + * Reads key placements, in definition order, from QMK `layouts..layout[]`, a VIA definition's + * `layouts.keymap`, or KLE JSON. KLE and VIA go through importKle so coordinates match the project. + * QMK r/rx/ry are read with the KLE meaning. Without `layout`, a QMK file with several layouts + * returns only the names. T-31. + */ +export function readLayoutPlacements(input: unknown, layout?: string): LayoutRead { + const names = qmkLayoutNames(input); + if (names) { + const name = layout ?? (names.length === 1 ? names[0] : undefined); + if (!name) return { placements: [], layouts: names }; + const entries = ((input as { layouts: Obj }).layouts[name] as { layout: unknown[] }).layout; + return { placements: entries.filter(isObj).map(qmkPlacement), layouts: names, layout: name }; + } + const { phy, keyOrder } = importKle(input); + const flat = new Map(flattenKeys(phy).map((k) => [k.key.id, k])); + const placements = keyOrder.flatMap((id) => { + const k = flat.get(id); + return k && !k.key.decal ? [{ x: k.x, y: k.y, rotation: k.rotation }] : []; + }); + return { placements }; +} + +function qmkPlacement(e: Obj): Placement { + const x = num(e.x), + y = num(e.y), + r = num(e.r), + rx = num(e.rx), + ry = num(e.ry); + const a = (r * Math.PI) / 180, + c = Math.cos(a), + s = Math.sin(a); + return { x: rx + (x - rx) * c - (y - ry) * s, y: ry + (x - rx) * s + (y - ry) * c, rotation: r }; +} diff --git a/mock/src/ui/App.vue b/mock/src/ui/App.vue index c7460dd..998488f 100644 --- a/mock/src/ui/App.vue +++ b/mock/src/ui/App.vue @@ -9,7 +9,7 @@ import { exportKle } from "../io/kleExport"; import { defaultLayout } from "./defaultLayout"; import type { Dispatch } from "./types"; import PhyEditor from "./phy/PhyEditor.vue"; -import IdxPanel from "./panels/IdxPanel.vue"; +import IdxEditor from "./idx/IdxEditor.vue"; import ElcPanel from "./panels/ElcPanel.vue"; import MapPanel from "./panels/MapPanel.vue"; import GeneratorPanel from "./panels/GeneratorPanel.vue"; @@ -186,12 +186,14 @@ function exportSelected() { :commit-history="commitHistory" @error="errorText = $event" /> +
- +// IDX Editor。キャンバス上のドラッグとプリセットで order を並べ替える。T-31。 +import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import type { ProjectFile } from "../../domain/project"; +import { defaultVariantSelection } from "../../domain/project"; +import { flattenKeys } from "../../domain/geometry"; +import type { FlatKey, Node } from "../../domain/phy"; +import { appendKeys, unregisteredKeys } from "../../domain/idx"; +import { keyVariantGroups, renumber, type OrderContext } from "../../domain/idxOrder"; +import { keyRect, matrixPositions, matrixSequence, scanKeys, type ScanResult } from "../../domain/idxScan"; +import { matchPlacements, type Placement } from "../../domain/idxImport"; +import { fitViewport, zoomAround } from "../phy/viewport"; +import type { Dispatch } from "../types"; +import IdxTree from "./IdxTree.vue"; +import IdxPresets from "./IdxPresets.vue"; +import IdxImport from "./IdxImport.vue"; +import { applyDrop, keyCenter, outputNumbers, type DropAction } from "./logic"; +import { defaultPresetSettings, lineColors, type PresetSettings } from "./presets"; +import { useIdxPointer } from "./useIdxPointer"; + +const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>(); +const emit = defineEmits<{ error: [text: string] }>(); + +const variants = ref>({}), + selected = ref([]), + zoom = ref(52), + pan = ref({ x: 50, y: 50 }), + size = ref({ w: 900, h: 600 }), + svg = ref(), + settings = ref(defaultPresetSettings()), + preview = ref<{ offsets: Map; cross: number | null } | null>(null), + importMessage = ref(""); + +/** Displayed configuration: starts from output.variantSelection, never written back. T-31. */ +function resetVariants() { + variants.value = { + ...defaultVariantSelection(props.project.phy), + ...props.project.output.variantSelection, + }; +} +resetVariants(); +watch( + () => props.project.projectId, + () => { + resetVariants(); + selected.value = []; + preview.value = null; + importMessage.value = ""; + fit(); + }, +); +watch( + () => props.project.phy, + () => { + // groups made in PHY after opening get their default; removed keys leave the selection + variants.value = { ...defaultVariantSelection(props.project.phy), ...variants.value }; + const shown = new Set(keys.value.map((k) => k.key.id)); + selected.value = selected.value.filter((id) => shown.has(id)); + }, +); + +const keys = computed(() => { + try { + return flattenKeys(props.project.phy, variants.value).filter((k) => !k.key.decal); + } catch { + return flattenKeys(props.project.phy).filter((k) => !k.key.decal); + } +}); +const keyById = computed(() => new Map(keys.value.map((k) => [k.key.id, k]))); +const visible = computed(() => new Set(keys.value.map((k) => k.key.id))); +const registered = computed(() => new Set(props.project.idx.order)); +const allUnregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx))); +const ctx = computed(() => ({ + visible: visible.value, + groups: keyVariantGroups(props.project.phy), +})); +const positions = computed(() => matrixPositions(props.project)); + +// ---- preview of a preset ---- +const targetKeys = computed(() => + settings.value.target === "selection" + ? keys.value.filter((k) => selected.value.includes(k.key.id)) + : keys.value, +); +const scan = computed(() => { + if (!preview.value) return null; + const target = targetKeys.value; + if (settings.value.kind === "matrix") { + const at = (id: string) => (registered.value.has(id) ? props.project.idx.order.indexOf(id) : Infinity); + const ids = target.map((k) => k.key.id).sort((a, b) => at(a) - at(b)); + return { sequence: matrixSequence(ids, positions.value ?? new Map()), lines: [], crossLines: [] }; + } + return scanKeys(target.map(keyRect), { + ...settings.value, + kind: settings.value.kind, + offsets: preview.value.offsets, + }); +}); +const shownOrder = computed(() => + scan.value ? renumber(props.project.idx.order, ctx.value, scan.value.sequence) : props.project.idx.order, +); +const numbers = computed(() => outputNumbers(shownOrder.value, visible.value)); +const crossAxis = computed(() => (settings.value.kind === "columns" ? "row" : "column")); +const shiftHint = computed(() => { + if (!scan.value?.crossLines.length) return ""; + if (preview.value?.cross == null) + return `キーをクリックして${crossAxis.value === "column" ? "列" : "行"}を選ぶと、${crossAxis.value === "column" ? "↑/↓で行" : "←/→で列"}の割り当てを1つずらせます。`; + return `選んだ${crossAxis.value === "column" ? "列" : "行"}: ${crossAxis.value === "column" ? "↑/↓" : "←/→"}でずらす(${preview.value.offsets.get(preview.value.cross) ?? 0})。Escで取り消し。`; +}); +const previewCross = computed(() => { + const i = preview.value?.cross; + return new Set(i == null ? [] : (scan.value?.crossLines[i] ?? [])); +}); +watch( + settings, + () => { + if (preview.value) preview.value = { offsets: new Map(), cross: null }; + }, + { deep: true }, +); +watch( + () => [settings.value.target, selected.value.length] as const, + ([target, count]) => { + if (target === "selection" && !count) settings.value = { ...settings.value, target: "all" }; + }, +); +function startPreview() { + preview.value = { offsets: new Map(), cross: null }; +} +function applyPreview() { + if (!scan.value) return; + const sequence = scan.value.sequence; + run("IDXプリセット適用", (p) => ({ + ...p, + idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, sequence) }, + })); + preview.value = null; +} +function shiftCross(delta: number) { + const p = preview.value; + if (!p || p.cross == null) return; + const offsets = new Map(p.offsets); + offsets.set(p.cross, (offsets.get(p.cross) ?? 0) + delta); + preview.value = { ...p, offsets }; +} +function pickCross(id: string) { + const i = scan.value?.crossLines.findIndex((line) => line.includes(id)) ?? -1; + if (preview.value) preview.value = { ...preview.value, cross: i < 0 ? null : i }; +} +function linePoints(ids: string[]): string { + return ids + .map((id) => keyById.value.get(id)) + .filter((k): k is FlatKey => !!k) + .map((k) => { + const c = keyCenter(k); + return `${c.x},${c.y}`; + }) + .join(" "); +} + +// ---- editing ---- +function run(label: string, update: (p: ProjectFile) => ProjectFile) { + try { + props.dispatch(label, update); + } catch (e) { + emit("error", String(e)); + } +} +function drop(source: string, action: DropAction) { + run(action.kind === "swap" ? "IDX入れ替え" : "IDX挿入", (p) => ({ + ...p, + idx: { ...p.idx, order: applyDrop(p.idx.order, ctx.value, source, action) }, + })); +} +function registerAll() { + run("IDX未登録キーを登録", (p) => ({ ...p, idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)) })); +} +function importPlacements(placements: Placement[], source: string) { + const { ids, unmatched } = matchPlacements(placements, keys.value); + if (ids.length) + run("IDX取り込み", (p) => ({ ...p, idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, ids) } })); + const missing = keys.value.length - ids.length; + importMessage.value = + `${source}: ${ids.length}件を照合して並べ直しました。` + + (unmatched ? `定義側の${unmatched}件は表示中のキーと一致しませんでした。` : "") + + (missing ? `表示中の${missing}件は定義になく、今の順序のまま残しました。` : ""); +} +function selectVariant(groupId: string, variantId: string) { + variants.value = { ...variants.value, [groupId]: variantId }; + const shown = new Set(keys.value.map((k) => k.key.id)); + selected.value = selected.value.filter((id) => shown.has(id)); +} + +const { drag, band, spaceHeld, begin, move, end, cancel } = useIdxPointer({ + svg, + zoom, + pan, + selected, + keys: () => keys.value, + registered: () => registered.value, + previewing: () => !!preview.value, + onDrop: drop, + onPreviewPick: pickCross, +}); +const dropText = computed(() => { + const action = drag.value?.action; + if (!drag.value) return ""; + if (!action) return "ここには置けません"; + const n = numbers.value.get(action.target)!; + if (action.kind === "swap") return `${n} と入れ替え`; + return action.after + ? `${n} と ${n + 1} の間に挿入` + : n === 0 + ? "先頭に挿入" + : `${n - 1} と ${n} の間に挿入`; +}); + +// ---- selection info ---- +const info = computed(() => { + const id = selected.value.at(-1); + const k = id ? keyById.value.get(id) : undefined; + if (!k || !id) return null; + const names = new Map(); + const walk = (nodes: Node[]) => + nodes.forEach((n) => { + if (n.type === "cluster") walk(n.children); + if (n.type === "variantGroup") + n.children.forEach((v) => { + names.set(v.id, `${n.name} / ${v.name}`); + walk(v.children); + }); + }); + walk(props.project.phy.root); + const path = k.path.flatMap((p) => (names.has(p) ? [names.get(p)!] : [])); + const saved = props.project.idx.order.indexOf(id); + return { + label: k.key.labels?.find(Boolean) || "(ラベルなし)", + id, + saved, + number: numbers.value.get(id), + path: path.join(" › ") || "共通キー", + }; +}); + +// ---- viewport ---- +function fit() { + const rects = keys.value.map(keyRect); + if (!rects.length) return; + const x = Math.min(...rects.map((r) => r.x)), + y = Math.min(...rects.map((r) => r.y)); + const bounds = { + x, + y, + width: Math.max(...rects.map((r) => r.x + r.w)) - x, + height: Math.max(...rects.map((r) => r.y + r.h)) - y, + }; + const v = fitViewport(bounds, size.value.w, size.value.h); + zoom.value = v.zoom; + pan.value = v.pan; +} +function wheel(event: WheelEvent) { + const el = svg.value; + if (!el) return; + const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(el.getScreenCTM()!.inverse()); + const v = zoomAround(local, { zoom: zoom.value, pan: pan.value }, Math.exp(-event.deltaY * 0.001)); + zoom.value = v.zoom; + pan.value = v.pan; +} +const bounds = computed(() => { + const rects = keys.value.map(keyRect); + return rects.length + ? { + top: Math.min(...rects.map((r) => r.y)) - 0.4, + bottom: Math.max(...rects.map((r) => r.y + r.h)) + 0.4, + } + : { top: 0, bottom: 1 }; +}); + +function keydown(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + if (target?.closest("input,textarea,select,[contenteditable=true]")) return; + if (e.code === "Space") spaceHeld.value = true; + if (e.key === "Escape") { + if (drag.value) cancel(); + else if (preview.value) preview.value = null; + else selected.value = []; + } else if (preview.value?.cross != null && e.key.startsWith("Arrow")) { + const vertical = crossAxis.value === "column"; + const delta = { + ArrowUp: vertical ? -1 : 0, + ArrowDown: vertical ? 1 : 0, + ArrowLeft: vertical ? 0 : -1, + ArrowRight: vertical ? 0 : 1, + }[e.key]; + if (delta) { + e.preventDefault(); + shiftCross(delta); + } + } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + e.preventDefault(); + selected.value = keys.value.map((k) => k.key.id); + } else if (e.key === "Enter" && preview.value) applyPreview(); +} +function keyup(e: KeyboardEvent) { + if (e.code === "Space") spaceHeld.value = false; +} +let observer: ResizeObserver | null = null; +onMounted(() => { + window.addEventListener("keydown", keydown); + window.addEventListener("keyup", keyup); + if (svg.value) { + observer = new ResizeObserver((entries) => { + const rect = entries[0]!.contentRect; + size.value = { w: rect.width, h: rect.height }; + fit(); + }); + observer.observe(svg.value); + } + fit(); +}); +onUnmounted(() => { + window.removeEventListener("keydown", keydown); + window.removeEventListener("keyup", keyup); + observer?.disconnect(); +}); + + + + + diff --git a/mock/src/ui/idx/IdxImport.vue b/mock/src/ui/idx/IdxImport.vue new file mode 100644 index 0000000..bff00a1 --- /dev/null +++ b/mock/src/ui/idx/IdxImport.vue @@ -0,0 +1,91 @@ + + + diff --git a/mock/src/ui/idx/IdxPresets.vue b/mock/src/ui/idx/IdxPresets.vue new file mode 100644 index 0000000..9b7c033 --- /dev/null +++ b/mock/src/ui/idx/IdxPresets.vue @@ -0,0 +1,149 @@ + + + diff --git a/mock/src/ui/idx/IdxTree.vue b/mock/src/ui/idx/IdxTree.vue new file mode 100644 index 0000000..4162e97 --- /dev/null +++ b/mock/src/ui/idx/IdxTree.vue @@ -0,0 +1,91 @@ + + + diff --git a/mock/src/ui/idx/logic.ts b/mock/src/ui/idx/logic.ts new file mode 100644 index 0000000..9b8223d --- /dev/null +++ b/mock/src/ui/idx/logic.ts @@ -0,0 +1,67 @@ +import type { FlatKey } from "../../domain/phy"; +import { insertNextTo, swapKeys, type OrderContext } from "../../domain/idxOrder"; + +type Point = { x: number; y: number }; +export type DropAction = + { kind: "insert"; target: string; after: boolean } | { kind: "swap"; target: string }; + +/** World point in the key's own frame (origin at its top-left, x along its width). */ +export function toKeyLocal(k: FlatKey, p: Point): Point { + const a = (-k.rotation * Math.PI) / 180, + dx = p.x - k.x, + dy = p.y - k.y; + return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) }; +} + +/** World center of a key. */ +export function keyCenter(k: FlatKey): Point { + const a = (k.rotation * Math.PI) / 180, + hw = k.key.size.w / 2, + hh = k.key.size.h / 2; + return { x: k.x + hw * Math.cos(a) - hh * Math.sin(a), y: k.y + hw * Math.sin(a) + hh * Math.cos(a) }; +} + +/** Topmost key under a world point, with the point's position across the key width (0..1). */ +export function hitKey(keys: readonly FlatKey[], p: Point): { id: string; fraction: number } | undefined { + for (let i = keys.length - 1; i >= 0; i--) { + const k = keys[i]!, + local = toKeyLocal(k, p); + if (local.x >= 0 && local.y >= 0 && local.x <= k.key.size.w && local.y <= k.key.size.h) + return { id: k.key.id, fraction: local.x / k.key.size.w }; + } + return undefined; +} + +/** + * Drop result of `source` at a hit: the outer quarters insert before or after the key, the middle + * swaps. Unregistered keys cannot swap, so their drop splits the key in halves. Unregistered targets + * and the source itself take no drop. T-31. + */ +export function dropAction( + source: string, + hit: { id: string; fraction: number } | undefined, + registered: ReadonlySet, +): DropAction | undefined { + if (!hit || hit.id === source || !registered.has(hit.id)) return undefined; + if (!registered.has(source)) return { kind: "insert", target: hit.id, after: hit.fraction >= 0.5 }; + if (hit.fraction < 0.25) return { kind: "insert", target: hit.id, after: false }; + if (hit.fraction > 0.75) return { kind: "insert", target: hit.id, after: true }; + return { kind: "swap", target: hit.id }; +} + +/** New order after dropping `source`. */ +export function applyDrop( + order: readonly string[], + ctx: OrderContext, + source: string, + action: DropAction, +): string[] { + return action.kind === "swap" + ? swapKeys(order, source, action.target) + : insertNextTo(order, ctx, source, action.target, action.after); +} + +/** Output number of each shown key: its position after dropping keys outside the configuration. */ +export function outputNumbers(order: readonly string[], visible: ReadonlySet): Map { + return new Map(order.filter((id) => visible.has(id)).map((id, i) => [id, i])); +} diff --git a/mock/src/ui/idx/presets.ts b/mock/src/ui/idx/presets.ts new file mode 100644 index 0000000..4d02d7e --- /dev/null +++ b/mock/src/ui/idx/presets.ts @@ -0,0 +1,21 @@ +import type { Direction, ScanKind } from "../../domain/idxScan"; + +/** Preset choices in the side pane. `matrix` sorts by ELC row → col instead of scanning. T-31. */ +export type PresetSettings = { + target: "all" | "selection"; + kind: ScanKind | "matrix"; + direction: Direction; + left: Direction; + right: Direction; +}; + +export const defaultPresetSettings = (): PresetSettings => ({ + target: "all", + kind: "rows", + direction: "normal", + left: "normal", + right: "normal", +}); + +/** Line colors of the preview, cycled by line index. */ +export const lineColors = ["#1769d2", "#d2691e", "#2e8b57", "#9b30d9", "#c0392b", "#00838f"]; diff --git a/mock/src/ui/idx/useIdxPointer.ts b/mock/src/ui/idx/useIdxPointer.ts new file mode 100644 index 0000000..91d0ba9 --- /dev/null +++ b/mock/src/ui/idx/useIdxPointer.ts @@ -0,0 +1,108 @@ +import { ref, type Ref } from "vue"; +import type { FlatKey } from "../../domain/phy"; +import { dropAction, hitKey, keyCenter, type DropAction } from "./logic"; + +type Point = { x: number; y: number }; +type Options = { + svg: Ref; + zoom: Ref; + pan: Ref; + selected: Ref; + keys: () => FlatKey[]; + registered: () => ReadonlySet; + previewing: () => boolean; + onDrop: (source: string, action: DropAction) => void; + onPreviewPick: (id: string) => void; +}; +/** Pointer travel (px) before a press on a key becomes a drag. */ +const DRAG_START = 4; + +/** Canvas pointer handling: click/Shift+click selection, dragging a key to reorder, range selection, pan. T-31. */ +export function useIdxPointer(o: Options) { + const drag = ref<{ id: string; point: Point; action?: DropAction } | null>(null); + const band = ref<{ start: Point; end: Point } | null>(null); + const spaceHeld = ref(false); + let press: { id?: string; client: Point; add: boolean; moved: boolean; pan?: Point } | null = null; + + function world(event: PointerEvent): Point { + const svg = o.svg.value!; + const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM()!.inverse()); + return { x: (local.x - o.pan.value.x) / o.zoom.value, y: (local.y - o.pan.value.y) / o.zoom.value }; + } + + function begin(event: PointerEvent, id?: string) { + const add = event.shiftKey || event.ctrlKey || event.metaKey; + const panning = event.button === 1 || spaceHeld.value; + if (event.button !== 0 && !panning) return; + event.preventDefault(); + if (!panning && id && o.previewing()) { + o.onPreviewPick(id); + return; + } + o.svg.value?.setPointerCapture?.(event.pointerId); + press = { + id: panning ? undefined : id, + client: { x: event.clientX, y: event.clientY }, + add, + moved: false, + }; + if (panning) press.pan = { ...o.pan.value }; + else if (!id && !o.previewing()) band.value = { start: world(event), end: world(event) }; + } + + function move(event: PointerEvent) { + if (!press) return; + const dx = event.clientX - press.client.x, + dy = event.clientY - press.client.y; + if (!press.moved && Math.hypot(dx, dy) < DRAG_START) return; + press.moved = true; + if (press.pan) { + // viewBox matches the SVG's pixel size, so client deltas are viewBox deltas + o.pan.value = { x: press.pan.x + dx, y: press.pan.y + dy }; + return; + } + const point = world(event); + if (press.id) { + const action = dropAction(press.id, hitKey(o.keys(), point), o.registered()); + drag.value = { id: press.id, point, action }; + } else if (band.value) band.value = { ...band.value, end: point }; + } + + function end() { + if (!press) return; + const { id, add, moved } = press; + if (id && !moved) { + o.selected.value = add + ? o.selected.value.includes(id) + ? o.selected.value.filter((x) => x !== id) + : [...o.selected.value, id] + : [id]; + } else if (id && drag.value?.action) o.onDrop(id, drag.value.action); + else if (!id && !press.pan) { + const b = band.value; + if (!moved || !b) { + if (!add) o.selected.value = []; + } else { + const [x0, x1] = [Math.min(b.start.x, b.end.x), Math.max(b.start.x, b.end.x)]; + const [y0, y1] = [Math.min(b.start.y, b.end.y), Math.max(b.start.y, b.end.y)]; + const inside = o + .keys() + .filter((k) => { + const c = keyCenter(k); + return c.x >= x0 && c.x <= x1 && c.y >= y0 && c.y <= y1; + }) + .map((k) => k.key.id); + o.selected.value = add ? [...new Set([...o.selected.value, ...inside])] : inside; + } + } + cancel(); + } + + function cancel() { + press = null; + drag.value = null; + band.value = null; + } + + return { drag, band, spaceHeld, begin, move, end, cancel }; +} diff --git a/mock/src/ui/panels/IdxPanel.vue b/mock/src/ui/panels/IdxPanel.vue deleted file mode 100644 index 57da517..0000000 --- a/mock/src/ui/panels/IdxPanel.vue +++ /dev/null @@ -1,254 +0,0 @@ - - - - - diff --git a/mock/tests/idxEdit.test.ts b/mock/tests/idxEdit.test.ts new file mode 100644 index 0000000..2bf5354 --- /dev/null +++ b/mock/tests/idxEdit.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { + insertAtGap, + insertNextTo, + keyVariantGroups, + orderSegments, + renumber, + swapKeys, +} from "../src/domain/idxOrder"; +import type { OrderContext } from "../src/domain/idxOrder"; +import { + assignLines, + keyRect, + matrixSequence, + scanKeys, + splitHalves, + type ScanOptions, +} from "../src/domain/idxScan"; +import { matchPlacements } from "../src/domain/idxImport"; +import { readLayoutPlacements } from "../src/io/layoutImport"; +import { importKle } from "../src/io/kleImport"; +import { flattenKeys } from "../src/domain/geometry"; +import type { KeyRect } from "../src/domain/rows"; +import type { FlatKey, PhyFile } from "../src/domain/phy"; + +const sample = (path: string) => + JSON.parse(readFileSync(new URL(`../../samples/${path}`, import.meta.url), "utf8")) as unknown; +const gen = () => { + let i = 0; + return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`; +}; + +/** Context where `hidden` keys are in group g (with every `grouped` visible key) and the rest are common. */ +const ctx = (visible: string[], grouped: string[] = []): OrderContext => ({ + visible: new Set(visible), + groups: new Map(grouped.map((id) => [id, ["g"]])), +}); + +describe("IDX order editing", () => { + it("keeps the ISO Enter next to the ANSI Enter when inserting after it", () => { + // a = ANSI Enter (shown), h = ISO Enter (hidden), b = common key + const c = ctx(["a", "b", "x"], ["a", "h"]); + expect(orderSegments(["a", "h", "b"], c)).toEqual([["a", "h"], ["b"]]); + expect(insertNextTo(["a", "h", "b", "x"], c, "x", "a", true)).toEqual(["a", "h", "x", "b"]); + expect(insertNextTo(["a", "h", "b", "x"], c, "x", "b", false)).toEqual(["a", "h", "x", "b"]); + }); + + it("attaches hidden keys forward only to a key sharing their group", () => { + const c = ctx(["p", "q"], ["h1", "h2", "q"]); + expect(orderSegments(["p", "h1", "h2", "q"], c)).toEqual([["p"], ["h1", "h2", "q"]]); + // shares with neither: stays with the key before + expect(orderSegments(["p", "z", "q"], ctx(["p", "q"]))).toEqual([["p", "z"], ["q"]]); + // shares with both: stays with the key before + expect(orderSegments(["p", "h", "q"], ctx(["p", "q"], ["p", "h", "q"]))).toEqual([["p", "h"], ["q"]]); + // once a hidden key goes forward, the rest of the run follows to keep the order + const mixed = { + visible: new Set(["p", "q"]), + groups: new Map([ + ["h1", ["g"]], + ["q", ["g"]], + ["h2", ["k"]], + ["p", ["k"]], + ]), + }; + expect(orderSegments(["p", "h1", "h2", "q"], mixed)).toEqual([["p"], ["h1", "h2", "q"]]); + // leading hidden keys attach to the first visible key, trailing ones to the last + expect(orderSegments(["z", "p", "q", "y"], ctx(["p", "q"]))).toEqual([ + ["z", "p"], + ["q", "y"], + ]); + expect(orderSegments(["z"], ctx([]))).toEqual([]); + }); + + it("moves a key between two others like a list move", () => { + const c = ctx(["k0", "k1", "k2", "k3", "k4", "k5"]); + const order = ["k0", "k1", "k2", "k3", "k4", "k5"]; + // n = 4 dropped between i = 1 and i + 1 = 2: lands on 2, 2..3 shift back + expect(insertNextTo(order, c, "k4", "k1", true)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]); + expect(insertNextTo(order, c, "k4", "k2", false)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]); + // n = 1 dropped between i = 3 and 4: lands on 3, 2..3 shift forward + expect(insertNextTo(order, c, "k1", "k3", true)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]); + expect(insertNextTo(order, c, "k1", "k4", false)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]); + expect(insertNextTo(order, c, "k1", "k1", true)).toEqual(order); + expect(insertAtGap(order, c, "k0", 99)).toEqual(["k1", "k2", "k3", "k4", "k5", "k0"]); + }); + + it("leaves hidden keys in place when their visible key moves away", () => { + const c = ctx(["a", "b", "c"], ["a", "h"]); + expect(insertNextTo(["a", "h", "b", "c"], c, "a", "c", true)).toEqual(["h", "b", "c", "a"]); + }); + + it("registers an unregistered key at the drop position", () => { + const c = ctx(["a", "b", "new"]); + expect(insertNextTo(["a", "b"], c, "new", "b", false)).toEqual(["a", "new", "b"]); + }); + + it("swaps only the two keys", () => { + expect(swapKeys(["a", "h", "b", "c"], "a", "c")).toEqual(["c", "h", "b", "a"]); + expect(() => swapKeys(["a"], "a", "zz")).toThrow(); + }); + + it("renumbers a sequence from the position of its first key, moving attached hidden keys along", () => { + const c = ctx(["a", "b", "c", "d"], ["b", "h"]); + const order = ["a", "b", "h", "c", "d"]; + expect(renumber(order, c, ["d", "b"])).toEqual(["a", "c", "d", "b", "h"]); + expect(renumber(order, c, ["b", "a"])).toEqual(["b", "h", "a", "c", "d"]); + expect(renumber(order, c, ["c", "a"])).toEqual(["b", "h", "c", "a", "d"]); + // unregistered keys are registered inside the block; an all-new sequence goes to the end + expect(renumber(["a", "b"], ctx(["a", "b", "n"]), ["n", "b"])).toEqual(["a", "n", "b"]); + expect(renumber(["a"], ctx(["a", "n"]), ["n"])).toEqual(["a", "n"]); + }); + + it("never duplicates or drops keys", () => { + const c = ctx(["a", "b", "c", "d"], ["a", "h1", "c", "h2"]); + const order = ["h1", "a", "b", "h2", "c", "d", "z"]; + const hiddenOf = (o: string[]) => o.filter((id) => !c.visible.has(id)); + const moved = renumber(order, c, ["d", "c", "b", "a"]); + expect([...moved].sort()).toEqual([...order].sort()); + // hidden keys travel with their visible key here + expect(moved).toEqual(["d", "z", "h2", "c", "b", "h1", "a"]); + for (const r of [ + insertNextTo(order, c, "a", "d", true), + insertNextTo(order, c, "d", "a", false), + swapKeys(order, "b", "d"), + ]) { + expect([...r].sort()).toEqual([...order].sort()); + expect(hiddenOf(r)).toEqual(hiddenOf(order)); + } + }); + + it("lists VariantGroup ancestors including nested groups", () => { + const { phy } = importKle(sample("kle/macropad-4x4.json"), { idGenerator: gen() }); + const [a, b] = phy.root as Extract[]; + const nested: PhyFile = { + ...phy, + root: [ + { + type: "variantGroup", + id: "outer", + name: "o", + default: "v1", + transform: { x: 0, y: 0, rotation: 0 }, + children: [ + { + type: "variant", + id: "v1", + name: "1", + children: [ + { + type: "variantGroup", + id: "inner", + name: "i", + default: "v2", + transform: { x: 0, y: 0, rotation: 0 }, + children: [{ type: "variant", id: "v2", name: "2", children: [a!] }], + }, + ], + }, + ], + }, + b!, + ], + }; + const groups = keyVariantGroups(nested); + expect(groups.get(a!.id)).toEqual(["outer", "inner"]); + expect(groups.get(b!.id)).toEqual([]); + }); +}); + +const r = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h }); +const options = (o: Partial): ScanOptions => ({ + kind: "rows", + direction: "normal", + left: "normal", + right: "normal", + offsets: new Map(), + ...o, +}); + +describe("IDX preset scans", () => { + // Two 2x2 halves with a 2U gap + const split = [r("l00", 0, 0), r("l01", 1, 0), r("l10", 0, 1), r("l11", 1, 1)].concat([ + r("r00", 4, 0), + r("r01", 5, 0), + r("r10", 4, 1), + r("r11", 5, 1), + ]); + + it("scans rows, columns, halves and alternating halves", () => { + expect(scanKeys(split, options({})).sequence).toEqual([ + "l00", + "l01", + "r00", + "r01", + "l10", + "l11", + "r10", + "r11", + ]); + expect(scanKeys(split, options({ direction: "reversed" })).sequence.slice(0, 4)).toEqual([ + "r01", + "r00", + "l01", + "l00", + ]); + expect(scanKeys(split, options({ kind: "columns" })).sequence.slice(0, 4)).toEqual([ + "l00", + "l10", + "l01", + "l11", + ]); + expect(scanKeys(split, options({ kind: "columns", direction: "reversed" })).sequence.slice(0, 2)).toEqual( + ["r01", "r11"], + ); + const halves = scanKeys(split, options({ kind: "halves", right: "reversed" })); + expect(halves.sequence).toEqual(["l00", "l01", "l10", "l11", "r01", "r00", "r11", "r10"]); + expect(halves.boundary).toBe(3); + expect(scanKeys(split, options({ kind: "alternating" })).sequence).toEqual([ + "l00", + "l01", + "r00", + "r01", + "l10", + "l11", + "r10", + "r11", + ]); + }); + + it("splits a board without a gap at the middle of its x range", () => { + const halves = splitHalves([r("a", 0, 0), r("b", 1, 0), r("c", 2, 0), r("d", 3, 0, 2)]); + expect(halves.boundary).toBe(2.5); + expect([...halves.left]).toEqual(["a", "b"]); + }); + + it("shifts the row assignment of one column", () => { + // column-staggered: the outer column sits a full row lower and is detected one row off + const keys = [r("o0", 0, 1), r("o1", 0, 2), r("a0", 1, 0), r("a1", 1, 1), r("a2", 1, 2)]; + const before = assignLines(keys, "row", new Map()); + expect(before.lines).toEqual([["a0"], ["o0", "a1"], ["o1", "a2"]]); + const outer = before.crossLines.findIndex((col) => col.includes("o0")); + const after = assignLines(keys, "row", new Map([[outer, -1]])); + expect(after.lines).toEqual([["o0", "a0"], ["o1", "a1"], ["a2"]]); + }); + + it("uses the bounding box of rotated keys", () => { + const rotated = keyRect({ + key: { + type: "key", + id: "k", + profile: "p", + transform: { x: 0, y: 0, rotation: 0 }, + size: { w: 2, h: 1 }, + }, + x: 0, + y: 0, + rotation: 90, + path: [], + }); + expect(rotated.x).toBeCloseTo(-1); + expect(rotated.w).toBeCloseTo(1); + expect(rotated.h).toBeCloseTo(2); + }); + + it("orders by matrix row then col and leaves unwired keys at the end", () => { + const positions = new Map([ + ["a", { row: 1, col: 0 }], + ["b", { row: 0, col: 2 }], + ["c", { row: 0, col: 1 }], + ]); + expect(matrixSequence(["x", "a", "b", "y", "c"], positions)).toEqual(["c", "b", "a", "x", "y"]); + }); +}); + +describe("IDX import from existing definitions", () => { + const shown = (phy: PhyFile): FlatKey[] => flattenKeys(phy).filter((k) => !k.key.decal); + + it("matches every key of the KLE the project came from, in KLE order", () => { + for (const file of ["kle/macropad-4x4.json", "kle/cr0wn60.json"]) { + const { phy, keyOrder } = importKle(sample(file), { idGenerator: gen() }); + const result = matchPlacements(readLayoutPlacements(sample(file)).placements, shown(phy)); + expect(result.unmatched, file).toBe(0); + expect(result.ids).toEqual(keyOrder.filter((id) => shown(phy).some((k) => k.key.id === id))); + } + }); + + it("matches the VIA definition of cr0wn60 against its KLE", () => { + const { phy } = importKle(sample("kle/cr0wn60.json"), { idGenerator: gen() }); + const keys = shown(phy); + const result = matchPlacements(readLayoutPlacements(sample("via/cr0wn60.json")).placements, keys); + expect(result.ids.length).toBe(keys.length); + expect(new Set(result.ids).size).toBe(result.ids.length); + }); + + it("reads QMK layouts, asks for a name when there are several, and applies rotation", () => { + const kle = [ + [{ r: 30, rx: 2, ry: 1, x: 0.5 }, "A"], + [{ x: -1 }, "B"], + ]; + const { phy } = importKle(kle, { idGenerator: gen() }); + const keys = shown(phy); + const qmk = { + layouts: { + LAYOUT_a: { + layout: [ + { matrix: [0, 1], x: 1, y: 2, r: 30, rx: 2, ry: 1 }, + { matrix: [0, 0], x: 2.5, y: 1, r: 30, rx: 2, ry: 1 }, + ], + }, + LAYOUT_b: { layout: [{ matrix: [0, 0], x: 0, y: 0 }] }, + }, + }; + expect(readLayoutPlacements(qmk)).toEqual({ placements: [], layouts: ["LAYOUT_a", "LAYOUT_b"] }); + const read = readLayoutPlacements(qmk, "LAYOUT_a"); + const result = matchPlacements(read.placements, keys); + expect(result.unmatched).toBe(0); + const label = (id: string) => keys.find((k) => k.key.id === id)!.key.labels?.find(Boolean); + expect(result.ids.map(label)).toEqual(["B", "A"]); + expect(matchPlacements(readLayoutPlacements(qmk, "LAYOUT_b").placements, keys).unmatched).toBe(1); + }); +});