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