mock: IDX Editorを作り直す(T-31)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg
This commit is contained in:
2026-10-25 10:59:44 +09:00
co-authored by Claude Opus 5.5
parent 2321c068f9
commit c180642fa8
16 changed files with 1985 additions and 281 deletions
+39
View File
@@ -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<string>();
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 };
}
+110
View File
@@ -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<string>;
groups: ReadonlyMap<string, readonly string[]>;
};
/** Lists the VariantGroup ancestors of every key, outermost first. Common keys get an empty list. */
export function keyVariantGroups(phy: PhyFile): Map<string, string[]> {
const out = new Map<string, string[]>();
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()];
}
+158
View File
@@ -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<number, number>;
};
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<string>; right: Set<string>; 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<number, number>,
): { 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 = <T>(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<string, { row: number; col: number }> | 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<string, { row: number; col: number }>();
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<string, { row: number; col: number }>,
) {
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];
}