/* ============================================================================= * app_settings_keys.ts * ☰ [설정] 단축키 표(PLAN 588) — 접이 묶음 · 찾기 · 키 기록 칸 · 줄마다 [기본값]. CAD 탭 · 다른 곳 탭이 같이 씀. * * - 표 줄 = 등록부(`key_registry.ts`) — 손글 안내 없음 · 못 바꾸는 줄은 키 배지만 * - 키 기록 = 칸을 누르고 새 키를 누름(Tab = 초점 넘김 · Esc = 그만) · 기본과 같으면 바꾼 키에서 뺌 * - 고침은 창 안 사본(`keys`)에만 · [저장] 은 `app_settings.ts` * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { attachCollapsible, createCollapsibleHead } from "@ui/ui_template_collapsible"; import { el } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { createInputField } from "@ui/ui_template_input"; import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table"; import { matchesFind } from "./app_settings_find"; import { KEY_ACTIONS, KEY_SCOPES, actionKeys, chordOf, keyLabel, type KeyError, type KeyScope, } from "./key_registry"; type LocaleKey = Parameters[0]; const KEY_ERROR_TEXT: Record = { dup: "App_Settings_Key_ErrDup", reserved: "App_Settings_Key_ErrReserved", letter: "App_Settings_Key_ErrLetter", }; export interface FindRow { cells: Cell[]; /** 찾기에 쓰는 글 — 찾을 때마다 읽음(칸에 친 글도) */ texts: () => string[]; } export interface FindGroup { root: HTMLElement; /** 맞는 줄 수 */ filter: (query: string) => number; } /** 접이 묶음 하나 — 머리(이름 · 줄 수 배지) + 표 · 처음엔 접힘 · 찾는 동안 맞는 줄만 펼침 */ export function findGroup(title: string, columns: Column[], rows: FindRow[]): FindGroup { const badge = createBadge(String(rows.length), "neutral"); const head = createCollapsibleHead({ title, actions: [badge], level: 4 }); const { root: table, tbody } = tableShell(columns, { density: "data" }); table.classList.add("app-settings__table"); const trs = rows.map((row) => tableRow(columns, row.cells)); tbody.append(...trs); const root = el("section", { className: "ui-collapsible is-collapsed app-settings__group", children: [head.root, table], }); const filter = (query: string): number => { let shown = 0; rows.forEach((row, i) => { const hit = matchesFind(query, row.texts()); trs[i].hidden = !hit; if (hit) shown += 1; }); badge.textContent = String(shown); root.hidden = !shown; root.classList.toggle("is-collapsed", !query.trim()); return shown; }; return { root, filter }; } /** 찾기 칸 + [모두 기본값] + 묶음들 + 0건 안내 — `count` = 맞는 줄 수(시험 · 검증) */ export function findPanel( groups: FindGroup[], head: HTMLElement[], onResetAll: () => void, ): { root: HTMLElement; count: () => number } { const empty = createEmpty({ text: L("App_Settings_Find_Empty"), small: true }); empty.hidden = true; let count = 0; const find = createInputField({ type: "search", size: 28, placeholder: L("App_Settings_Find"), onInput: (query) => { count = groups.reduce((sum, group) => sum + group.filter(query), 0); empty.hidden = count > 0; }, }); find.input.setAttribute("aria-label", L("App_Settings_Find")); find.input.dataset.settingsFind = ""; const reset = createButton({ label: L("App_Settings_Reset_All"), variant: "ghost", size: 28, onClick: onResetAll, }); const bar = el("div", { className: "app-settings__findbar", children: [find.root, reset] }); const root = el("div", { children: [bar, ...head, ...groups.map((g) => g.root), empty] }); attachCollapsible(root); count = groups.reduce((sum, group) => sum + group.filter(""), 0); return { root, count: () => count }; } const text = (pair: readonly [string, string]): string => pair[currentLanguageIndex] ?? pair[0]; const badges = (keys: readonly string[]): HTMLElement => el("span", { className: "app-settings__keys", children: keys.map((key) => createBadge(keyLabel(key), "neutral")), }); export interface KeyRows { groups: FindGroup[]; /** 잘못 표시 */ mark: (errors: Record) => void; /** 사본이 바뀐 뒤 칸 글 · [기본값] 다시 맞춤 */ refresh: () => void; } /** 등록부 자리들의 묶음 — `keys` 사본을 고쳐 씀 · `onChange` = 고칠 때마다(겹침 다시 셈) */ export function keyRows( scopes: KeyScope[], keys: Record, onChange: () => void, titleOf: (scope: KeyScope) => string = (scope) => text(KEY_SCOPES[scope].label), ): KeyRows { const columns: Column[] = [ { label: L("App_Settings_Key_Action") }, { label: L("App_Settings_Key_Key"), width: "13rem" }, { label: "", width: "6rem" }, ]; const fields = new Map void; setError: (msg?: string) => void }>(); const groups = scopes.map((scope) => { const rows = KEY_ACTIONS.filter((a) => a.scope === scope).map((action): FindRow => { const label = text(action.label); const texts = [label, ...action.keys, ...action.keys.map(keyLabel)]; if (action.fixed) return { cells: [label, badges(action.keys), ""], texts: () => texts }; const field = createInputField({ size: 24 }); const input = field.input; input.readOnly = true; input.dataset.keyAction = action.id; input.setAttribute("aria-label", `${label} ${L("App_Settings_Key_Key")}`); const defaults = action.keys.map(keyLabel).join(" · "); const reset = createButton({ label: L("App_Settings_Reset"), variant: "ghost", size: 24, onClick: () => { delete keys[action.id]; onChange(); }, }); reset.title = `${L("App_Settings_Reset")}: ${defaults}`; reset.dataset.keyReset = action.id; input.addEventListener("keydown", (event) => { if (event.key === "Tab") return; event.preventDefault(); event.stopPropagation(); // 창 닫기 · 페이지 키로 안 샘 if (event.key === "Escape") return input.blur(); const chord = chordOf(event); if (!chord) return; if (action.keys.length === 1 && action.keys[0] === chord) delete keys[action.id]; else keys[action.id] = [chord]; onChange(); }); fields.set(action.id, { set: () => { input.value = actionKeys(action.id, keys).map(keyLabel).join(" · "); reset.disabled = !keys[action.id]; }, setError: field.setError, }); return { cells: [label, field.root, reset], texts: () => [...texts, input.value] }; }); return findGroup(titleOf(scope), columns, rows); }); const refresh = (): void => fields.forEach((field) => field.set()); const mark = (errors: Record): void => { for (const [id, field] of fields) { const error = errors[id]; field.setError(error ? L(KEY_ERROR_TEXT[error]) : undefined); } }; refresh(); return { groups, mark, refresh }; }