[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:
type-two 2026-07-17 17:27:45 +10:00
parent 491fbf2d68
commit cd733445d9
3 changed files with 196 additions and 52 deletions

View File

@ -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,
};
}

View File

@ -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
View 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));
}