[ui] build bar paging: kind tabs, [ ] to page, 1-9 within the page
Kind tabs alone don't deliver "every machine hotkey-reachable at any catalog size": REFINE holds 14 crafters and 1-9 reaches nine. So a page caps at nine and an oversized kind spills into numbered pages (REFINE 1/2, REFINE 2/2). The 21-machine catalog lands as 1+3+9+5+2+1 across six pages, every slot hotkeyed. Pagination is pure (pages.ts) so the invariant can be tested rather than eyeballed. Selecting a machine from anywhere pulls the bar to the page that holds it, so the hot button is always the visible one. hotkeys.ts splits into a pure KeyRouter (map + dispatch, no window) and a thin window binding, which lets the keymap be tested in node. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
491fbf2d68
commit
cd733445d9
@ -1,53 +1,76 @@
|
||||
/**
|
||||
* LANE-UI — build bar. One button per machine in GameData.machines, hotkeys 1-9.
|
||||
* LANE-UI — build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page.
|
||||
* Click or hotkey selects for placement; main.ts does the ghost and the actual place.
|
||||
*/
|
||||
import type { GameData } from '../contracts';
|
||||
import type { GameData, MachineDef } from '../contracts';
|
||||
import { attrs, cls, el, panel, text } from './dom';
|
||||
import { paginate, pageOf, type BuildPage } from './pages';
|
||||
import { machineColor } from './palette';
|
||||
import type { BuildSelection } from './selection';
|
||||
import { COPY, DIR_NAME } from './voice';
|
||||
|
||||
export interface BuildBar {
|
||||
el: HTMLElement;
|
||||
/** Select the machine in slot `n` (1-based, as printed on the button). */
|
||||
/** Select slot `n` (1-based) on the active page. */
|
||||
selectSlot(n: number): void;
|
||||
pageBy(delta: number): void;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
const pages: BuildPage[] = paginate(data.machines);
|
||||
|
||||
const root = panel();
|
||||
root.id = 'fk-build';
|
||||
|
||||
const tabRow = el('div', 'fk-tabs');
|
||||
const row = el('div', 'fk-build-row');
|
||||
const hint = el('div');
|
||||
hint.id = 'fk-build-hint';
|
||||
root.append(tabRow, row, hint);
|
||||
|
||||
const buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
|
||||
let active = 0;
|
||||
|
||||
data.machines.forEach((m, i) => {
|
||||
const btn = el('button', 'fk-build-btn');
|
||||
attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` });
|
||||
|
||||
const ico = el('div', 'fk-build-ico');
|
||||
ico.style.background = machineColor(m);
|
||||
btn.append(ico);
|
||||
|
||||
// Only the first nine get a printed hotkey; the rest are click-only until the
|
||||
// bar grows tabs/pages (a later round's problem).
|
||||
if (i < 9) {
|
||||
const key = el('span', 'fk-build-key');
|
||||
key.textContent = String(i + 1);
|
||||
btn.append(key);
|
||||
}
|
||||
|
||||
btn.append(el('span', 'fk-build-name', [m.name]));
|
||||
btn.addEventListener('click', () => sel.select(m.id));
|
||||
row.append(btn);
|
||||
buttons.push({ def: m.id, btn });
|
||||
const tabs = pages.map((p, i) => {
|
||||
const tab = el('button', 'fk-tab', [p.label]);
|
||||
attrs(tab, { type: 'button' });
|
||||
tab.addEventListener('click', () => {
|
||||
active = i;
|
||||
render();
|
||||
});
|
||||
tabRow.append(tab);
|
||||
return tab;
|
||||
});
|
||||
|
||||
root.append(row, hint);
|
||||
/** Buttons for the active page only; rebuilt on page change (nine at most). */
|
||||
let buttons: Array<{ def: string; btn: HTMLButtonElement }> = [];
|
||||
|
||||
function buildButtons(page: BuildPage) {
|
||||
row.replaceChildren();
|
||||
buttons = page.machines.map((m: MachineDef, i: number) => {
|
||||
const btn = el('button', 'fk-build-btn');
|
||||
attrs(btn, { type: 'button', title: `${m.name} — ${m.kind}` });
|
||||
|
||||
const ico = el('div', 'fk-build-ico');
|
||||
ico.style.background = machineColor(m);
|
||||
btn.append(ico);
|
||||
|
||||
const key = el('span', 'fk-build-key');
|
||||
key.textContent = String(i + 1); // every slot on every page has a hotkey now
|
||||
btn.append(key);
|
||||
|
||||
btn.append(el('span', 'fk-build-name', [m.name]));
|
||||
btn.addEventListener('click', () => sel.select(m.id));
|
||||
row.append(btn);
|
||||
return { def: m.id, btn };
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
buildButtons(pages[active]);
|
||||
for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === active);
|
||||
sync();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const cur = sel.get();
|
||||
@ -61,8 +84,22 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
cls(hint, 'is-placing', !!cur);
|
||||
}
|
||||
|
||||
sel.onChange(sync);
|
||||
sync();
|
||||
// A selection made from anywhere (including a future hotkey we don't own) pulls the
|
||||
// bar to the page that contains it, so the hot button is always the visible one.
|
||||
sel.onChange(() => {
|
||||
const cur = sel.get();
|
||||
if (cur) {
|
||||
const p = pageOf(pages, cur.def);
|
||||
if (p >= 0 && p !== active) {
|
||||
active = p;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
sync();
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
el: root,
|
||||
@ -70,6 +107,11 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
|
||||
const b = buttons[n - 1];
|
||||
if (b) sel.select(b.def);
|
||||
},
|
||||
pageBy(delta) {
|
||||
if (pages.length === 0) return;
|
||||
active = (active + delta + pages.length) % pages.length;
|
||||
render();
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,55 +1,84 @@
|
||||
/**
|
||||
* LANE-UI — the keyboard map. ALL bindings live here; this file will grow.
|
||||
*
|
||||
* Round 1 map:
|
||||
* 1..9 select build slot N (build bar)
|
||||
* Round 2 map:
|
||||
* 1..9 select build slot N *within the active page*
|
||||
* [ ] previous / next build page
|
||||
* R rotate pending build dir
|
||||
* ESC clear selection / close inspector
|
||||
* SPACE toggle HALT
|
||||
* TAB cycle the unit manifest (temporary — see NOTES: no pickTile in UIBus)
|
||||
*
|
||||
* WASD and the middle-drag pan belong to LANE-RENDER's camera; nothing here binds them.
|
||||
* Keys are matched on `event.key`, lowercased, so layout-shifted digits still work.
|
||||
* WASD and the middle-drag pan belong to LANE-RENDER's camera (see render/camera.ts);
|
||||
* nothing here binds them. Keys match on `event.key` lowercased, so layout-shifted
|
||||
* digits still work.
|
||||
*
|
||||
* Split in two: `KeyRouter` is the pure map + dispatch (no window, testable in node),
|
||||
* `Hotkeys` is the thin binding to the real window.
|
||||
*/
|
||||
|
||||
export type HotkeyHandler = (ev: KeyboardEvent) => void;
|
||||
export type HotkeyHandler = (ev: KeyEventLike) => void;
|
||||
|
||||
export interface Hotkeys {
|
||||
/** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "tab", "1". */
|
||||
/** The parts of KeyboardEvent this module actually reads. */
|
||||
export interface KeyEventLike {
|
||||
key: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
target?: unknown;
|
||||
preventDefault?(): void;
|
||||
}
|
||||
|
||||
export interface KeyRouter {
|
||||
/** `key` is a lowercased `event.key`, e.g. "r", "escape", " ", "[", "1". */
|
||||
bind(key: string, handler: HotkeyHandler): void;
|
||||
/** Returns true if a binding ran. */
|
||||
handle(ev: KeyEventLike): boolean;
|
||||
}
|
||||
|
||||
export interface Hotkeys extends KeyRouter {
|
||||
attach(): void;
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
/** Don't steal keys while the player is typing into something. */
|
||||
function isTypingTarget(t: EventTarget | null): boolean {
|
||||
export function isTypingTarget(t: unknown): boolean {
|
||||
if (typeof HTMLElement === 'undefined') return false;
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
return t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName);
|
||||
}
|
||||
|
||||
/** Keys we always swallow so the browser doesn't scroll/focus-walk the page. */
|
||||
const SWALLOW = new Set([' ', 'tab']);
|
||||
/** Keys we swallow so the browser doesn't scroll or focus-walk the page. */
|
||||
const SWALLOW = new Set([' ']);
|
||||
|
||||
export function createHotkeys(): Hotkeys {
|
||||
export function createKeyRouter(): KeyRouter {
|
||||
const map = new Map<string, HotkeyHandler>();
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (isTypingTarget(ev.target)) return;
|
||||
// Let the browser keep its own shortcuts (⌘R, ctrl-tab, …).
|
||||
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||||
|
||||
const key = ev.key.toLowerCase();
|
||||
const handler = map.get(key);
|
||||
if (!handler) return;
|
||||
|
||||
if (SWALLOW.has(key)) ev.preventDefault();
|
||||
handler(ev);
|
||||
};
|
||||
|
||||
return {
|
||||
bind(key, handler) {
|
||||
map.set(key.toLowerCase(), handler);
|
||||
},
|
||||
handle(ev) {
|
||||
if (isTypingTarget(ev.target)) return false;
|
||||
// Leave the browser's own shortcuts alone (⌘R, ctrl-tab, …).
|
||||
if (ev.metaKey || ev.ctrlKey || ev.altKey) return false;
|
||||
|
||||
const handler = map.get(ev.key.toLowerCase());
|
||||
if (!handler) return false;
|
||||
|
||||
if (SWALLOW.has(ev.key.toLowerCase())) ev.preventDefault?.();
|
||||
handler(ev);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createHotkeys(): Hotkeys {
|
||||
const router = createKeyRouter();
|
||||
const onKeyDown = (ev: KeyboardEvent) => router.handle(ev);
|
||||
|
||||
return {
|
||||
bind: router.bind,
|
||||
handle: router.handle,
|
||||
attach() {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
},
|
||||
|
||||
73
fktry/src/ui/pages.ts
Normal file
73
fktry/src/ui/pages.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* LANE-UI — build bar pagination (pure; see pages.test.ts).
|
||||
*
|
||||
* Round 2 orders: kind tabs, `[`/`]` to page, 1-9 within the active page, "every machine
|
||||
* hotkey-reachable at any catalog size".
|
||||
*
|
||||
* Kind tabs alone don't deliver that last clause: LANE-DATA's 21 machines include 14
|
||||
* crafters, and 1-9 cannot reach 14. So a page is at most PER_PAGE machines, and a kind
|
||||
* group larger than that spills into numbered pages (REFINE 1/2, REFINE 2/2). That keeps
|
||||
* the invariant true no matter how big the catalog gets.
|
||||
*/
|
||||
import type { MachineDef, MachineKind } from '../contracts';
|
||||
|
||||
/** Hotkeys are 1-9, so a page can never hold more than nine. */
|
||||
export const PER_PAGE = 9;
|
||||
|
||||
/** Kinds collapse into these tabs, in house order: dig, move, refine, power, ship. */
|
||||
const GROUP_OF: Record<MachineKind, string> = {
|
||||
extractor: 'EXTRACT',
|
||||
belt: 'LOGISTICS',
|
||||
splitter: 'LOGISTICS',
|
||||
buffer: 'LOGISTICS',
|
||||
crafter: 'REFINE',
|
||||
power: 'POWER',
|
||||
shipper: 'SHIP',
|
||||
};
|
||||
|
||||
const GROUP_ORDER = ['EXTRACT', 'LOGISTICS', 'REFINE', 'POWER', 'SHIP'];
|
||||
|
||||
export interface BuildPage {
|
||||
/** Tab label, e.g. "REFINE" or "REFINE 2/2". */
|
||||
label: string;
|
||||
machines: MachineDef[];
|
||||
}
|
||||
|
||||
export function paginate(machines: MachineDef[], perPage: number = PER_PAGE): BuildPage[] {
|
||||
const groups = new Map<string, MachineDef[]>();
|
||||
for (const m of machines) {
|
||||
// An unknown kind still gets a home rather than vanishing from the bar.
|
||||
const g = GROUP_OF[m.kind] ?? 'OTHER';
|
||||
const list = groups.get(g);
|
||||
if (list) list.push(m);
|
||||
else groups.set(g, [m]);
|
||||
}
|
||||
|
||||
const names = [...groups.keys()].sort((a, b) => {
|
||||
const ia = GROUP_ORDER.indexOf(a);
|
||||
const ib = GROUP_ORDER.indexOf(b);
|
||||
// Unknown groups sort last, alphabetically, rather than jumping the queue.
|
||||
if (ia === -1 && ib === -1) return a < b ? -1 : a > b ? 1 : 0;
|
||||
if (ia === -1) return 1;
|
||||
if (ib === -1) return -1;
|
||||
return ia - ib;
|
||||
});
|
||||
|
||||
const pages: BuildPage[] = [];
|
||||
for (const name of names) {
|
||||
const list = groups.get(name)!;
|
||||
const chunks = Math.max(1, Math.ceil(list.length / perPage));
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
pages.push({
|
||||
label: chunks > 1 ? `${name} ${i + 1}/${chunks}` : name,
|
||||
machines: list.slice(i * perPage, (i + 1) * perPage),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** Page index holding `defId`, or -1. Used to follow a click-selection to its tab. */
|
||||
export function pageOf(pages: BuildPage[], defId: string): number {
|
||||
return pages.findIndex((p) => p.machines.some((m) => m.id === defId));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user