import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout"; import { normalizeInputBinding, INPUT_ACTIONS, normalizeInputPreferences, type InputPreferences } from "../input/keybinds"; export type UiDevice = "mobile" | "pc"; export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"]; export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const; export const HUD_ELEMENTS = { party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", player: "Player status", timeline: "Encounter timeline", target: "Enemy target", friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar", hints: "Control hints", actions: "Controller action bar", experience: "Experience bar", reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice", objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback", "bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3", "bar-4": "Action bar 4", "bar-5": "Action bar 5", } as const; export type HudElementId = keyof typeof HUD_ELEMENTS; export interface HudElementLayout { visible: boolean; position: NormalizedHudPosition | null; width: number | null; } export interface DesktopActionSlot { abilityId: string | null; key: string | null } export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] } export interface UiLayout { elements: Record; dpsMeterDisplay: "auto" | "main" | "companion"; wowBars: boolean; barCount: number; bars: DesktopActionBar[]; } export interface UiTemplate { name: string; layout: UiLayout } export interface UiPreferences { version: 1; keybinds: InputPreferences; current: Record; slots: Record; } export function defaultUiLayout(): UiLayout { return { elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, { visible: true, position: null, width: null, }])) as UiLayout["elements"], dpsMeterDisplay: "auto", wowBars: false, barCount: 1, bars: Array.from({ length: 5 }, (_, bar) => ({ columns: 12, slots: Array.from({ length: 12 }, (_, slot) => ({ abilityId: null, key: bar === 0 ? (slot < 9 ? `Digit${slot + 1}` : ["Digit0", "Minus", "Equal"][slot - 9]) : null, })), })), }; } function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function bounded(value: unknown, fallback: number, min: number, max: number): number { return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback; } const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/; export function normalizeActionKey(value: unknown): string | null { if (typeof value !== "string") return null; const parts = value.split("+"); const code = parts.pop()!; if (!KEY_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null; return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+"); } export function actionKeyForEvent(event: Pick): string | null { return normalizeActionKey([ ...(event.ctrlKey ? ["Ctrl"] : []), ...(event.altKey ? ["Alt"] : []), ...(event.shiftKey ? ["Shift"] : []), ...(event.metaKey ? ["Meta"] : []), event.code, ].join("+")); } export function actionKeyLabel(key: string | null): string { return key?.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ") ?? ""; } export function normalizeUiLayout(value: unknown): UiLayout { const source = record(value); const defaults = defaultUiLayout(); const elements = record(source.elements); for (const id of Object.keys(HUD_ELEMENTS) as HudElementId[]) { const element = record(elements[id]); defaults.elements[id] = { visible: element.visible !== false, position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null, width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null, }; } const used = new Set(); defaults.bars = defaults.bars.map((fallback, index) => { const bar = record(Array.isArray(source.bars) ? source.bars[index] : null); return { columns: BAR_COLUMNS.includes(bar.columns as 12) ? bar.columns as number : 12, slots: fallback.slots.map((slot, slotIndex) => { const raw = Array.isArray(bar.slots) ? record(bar.slots[slotIndex]) : slot; const key = normalizeActionKey(raw.key); const uniqueKey = key && !used.has(key) ? key : null; if (uniqueKey) used.add(uniqueKey); return { key: uniqueKey, abilityId: typeof raw.abilityId === "string" && raw.abilityId.length <= 200 ? raw.abilityId : null, }; }), }; }); return { ...defaults, dpsMeterDisplay: source.dpsMeterDisplay === "main" || source.dpsMeterDisplay === "companion" ? source.dpsMeterDisplay : "auto", wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) }; } export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" { return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay; } export function normalizeUiPreferences(value: unknown): UiPreferences { const source = record(value); const current = record(source.current); const slots = record(source.slots); return { version: 1, keybinds: normalizeInputPreferences(source.keybinds), current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) }, slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => { const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null); return typeof slot.name === "string" && slot.layout ? { name: slot.name.trim().slice(0, 40) || `Template ${index + 1}`, layout: normalizeUiLayout(slot.layout) } : null; })])) as UiPreferences["slots"], }; } /** A key has one owner, including hidden bars, so enabling a bar cannot add conflicts. */ export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: number, key: string | null): UiLayout { return { ...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({ ...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key, })) })) }; } export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotIndex: number, abilityId: string | null): UiLayout { return { ...layout, bars: layout.bars.map((bar, b) => b !== barIndex ? bar : { ...bar, slots: bar.slots.map((slot, s) => s === slotIndex ? { ...slot, abilityId } : slot), }) }; } /** Restore abilities only; key chords and the arrangement belong to the UI template. */ export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout { return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : { ...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })), }) }; } export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null { if (!layout.wowBars || !key) return null; // Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them. return layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots).find((slot) => slot.key === key) ?? null; } export function exportUiPreferences(preferences: UiPreferences): string { return JSON.stringify({ format: "healer-man-ui", ...normalizeUiPreferences(preferences) }, null, 2); } export function importUiPreferences(text: string): UiPreferences { if (text.length > 256_000) throw new Error("UI files must be smaller than 256 KB."); let source: Record; try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); } if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version."); const validateLayout = (value: unknown) => { const layout = record(value); if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display."); if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5 || !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration."); for (const id of Object.keys(HUD_ELEMENTS)) { // Version-one exports made before the DPS meter inherit its defaults. if (id === "dps" && record(layout.elements)[id] === undefined) continue; const element = record(record(layout.elements)[id]); const position = record(element.position); if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) || (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility."); } const keys = new Set(); for (const raw of layout.bars) { const bar = record(raw); if (!BAR_COLUMNS.includes(bar.columns as 12) || !Array.isArray(bar.slots) || bar.slots.length !== 12) throw new Error("Each bar needs exactly 12 buttons and a valid row/column layout."); for (const rawSlot of bar.slots) { const slot = record(rawSlot); if (slot.abilityId !== null && (typeof slot.abilityId !== "string" || slot.abilityId.length > 200)) throw new Error("Invalid ability assignment."); if (slot.key !== null && (!normalizeActionKey(slot.key) || keys.has(normalizeActionKey(slot.key)!))) throw new Error("Invalid or duplicate keybind."); if (slot.key) keys.add(normalizeActionKey(slot.key)!); } } }; if (source.keybinds !== undefined) { if (!source.keybinds || typeof source.keybinds !== "object" || Array.isArray(source.keybinds)) throw new Error("Invalid keybind configuration."); for (const device of ["pc", "controller"] as const) { const raw = record(source.keybinds)[device]; if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid keybind configuration."); const bindings = record(record(source.keybinds)[device]); for (const entry of INPUT_ACTIONS) { const key = bindings[entry.id]; if (key !== undefined && key !== null && !normalizeInputBinding(device, key)) throw new Error("Invalid keybind configuration."); } } } for (const device of UI_DEVICES) { validateLayout(record(source.current)[device]); const slots = record(source.slots)[device]; if (!Array.isArray(slots) || slots.length !== 3) throw new Error("UI files must contain three Mobile and three PC slots."); for (const slot of slots) { if (slot === null) continue; const template = record(slot); if (typeof template.name !== "string" || !template.name.trim() || template.name.length > 40) throw new Error("Template names must be 1–40 characters."); validateLayout(template.layout); } } return normalizeUiPreferences(source); }