Files
feuerwerk/src/ui/widgets/ListView.ts
T
rootandClaude Fable 5.1 5a46124454 Initial commit: Game Maker phase 1
Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript.

- core: asset model (Scene/Camera/Sprite), scene objects with sparse
  overrides, property schema + resolve, command stack (undo/redo),
  serialization and project store interface (16 unit tests)
- ui: custom in-canvas widget toolkit (panels, fields, color picker,
  list/scroll, popup) with a hidden <input> keyboard bridge
- editor: asset/hierarchy panels, schema-driven inspector, toolbar
- viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan
- runtime: world/entity instantiation for play mode, input, camera follow
- api: PHP JSON project store with revision counter (409 on conflict)
- tools: CDP-based e2e smoke test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 10:07:06 +02:00

152 lines
5.3 KiB
TypeScript

import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget';
import { truncate } from './Label';
export interface ListItem {
id: string;
label: string;
/** Small secondary text on the right (e.g. the asset type). */
hint?: string;
/** Left indent in levels (hierarchies). */
depth?: number;
/** Optional marker color drawn as a small square before the label. */
swatch?: number;
}
/**
* Single-select list of rows. Rows are rebuilt from `setItems()`; selection is by id so
* it survives rebuilds. Emits select, activate (double-click / Enter) and context events.
*/
export class ListView extends Widget {
private items: ListItem[] = [];
private rows: Row[] = [];
private selectedId: string | null = null;
onSelect?: (id: string | null) => void;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
rowHeight = Theme.rowHeight;
emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
constructor() {
super();
this.focusable = true;
this.addChild(this.emptyLabel);
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.target === this) this.select(null);
});
}
get selected(): string | null {
return this.selectedId;
}
setItems(items: ListItem[]): void {
this.items = items;
for (const row of this.rows) row.destroy({ children: true });
this.rows = items.map((item, index) => {
const row = new Row(item, this.rowHeight);
row.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 2) {
this.select(item.id);
this.onContextMenu?.(item.id, e.global.x, e.global.y);
} else {
this.select(item.id);
if (e.detail === 2) this.onActivate?.(item.id);
}
});
row.position.set(0, index * this.rowHeight);
this.addChild(row);
return row;
});
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
this.layout();
this.redraw();
}
select(id: string | null, silent = false): void {
if (id === this.selectedId) return;
this.selectedId = id;
this.redraw();
if (!silent) this.onSelect?.(id);
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) };
}
override onKeyDown(event: KeyboardEvent): boolean {
const index = this.items.findIndex((i) => i.id === this.selectedId);
if (event.key === 'ArrowDown') {
const next = this.items[Math.min(this.items.length - 1, index + 1)];
if (next) this.select(next.id);
return true;
}
if (event.key === 'ArrowUp') {
const prev = this.items[Math.max(0, index - 1)];
if (prev) this.select(prev.id);
return true;
}
if (event.key === 'Enter' && this.selectedId) {
this.onActivate?.(this.selectedId);
return true;
}
return false;
}
override layout(): void {
for (const row of this.rows) row.resize(this.w, this.rowHeight);
}
override redraw(): void {
for (const row of this.rows) row.setSelected(row.item.id === this.selectedId, this.focused);
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2));
}
}
class Row extends Widget {
private readonly bg = new Graphics();
private readonly labelText = new Text({ text: '', style: textStyle('body') });
private readonly hint = new Text({ text: '', style: textStyle('small') });
private hovered = false;
private selected = false;
private listFocused = false;
constructor(readonly item: ListItem, height: number) {
super();
this.cursor = 'pointer';
this.minHeight = height;
this.addChild(this.bg, this.labelText, this.hint);
this.on('pointerover', () => { this.hovered = true; this.invalidate(); });
this.on('pointerout', () => { this.hovered = false; this.invalidate(); });
}
setSelected(selected: boolean, listFocused: boolean): void {
if (selected === this.selected && listFocused === this.listFocused) return;
this.selected = selected;
this.listFocused = listFocused;
this.invalidate();
}
override redraw(): void {
const c = Theme.colors;
const indent = Theme.padding + (this.item.depth ?? 0) * 14;
this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
let x = indent;
if (this.item.swatch !== undefined) {
this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch);
this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border });
x += 18;
}
this.hint.text = this.item.hint ?? '';
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2));
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2));
}
}