import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import { pressCount } from '../clicks'; import { centreY } from '../math'; import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; import { ListRow } from './ListRow'; import type { Popup } from './Popup'; export interface ListItem { id: string; label: string; /** Small secondary text on the right. */ hint?: string; /** Left indent in levels. */ depth?: number; /** Marker color drawn as a small square before the label. */ swatch?: number; /** Whether the subtree is shown; such rows get a chevron. Left undefined for leaves. */ expanded?: boolean; /** Draws the label in the override colour – the row, or something inside it, deviates. */ overridden?: boolean; } /** Pixels of travel before a press turns into a drag. */ const DRAG_THRESHOLD = 4; /** Modifier keys held while a row was clicked. */ export interface PickModifiers { /** Ctrl or ⌘. */ ctrl: boolean; shift: boolean; } /** * Where a drop would land. `onto` is the row itself (the tree meaning: inside it), `before` and * `after` are the gaps around it, `end` is the empty space below the last row. */ export type DropSpot = { at: 'onto' | 'before' | 'after'; targetId: string } | { at: 'end' }; interface DragState { id: string; startX: number; startY: number; /** Set once the pointer travelled past `DRAG_THRESHOLD`. */ active: boolean; spot: DropSpot | null; } /** * List of rows with one selected row (by id, so it survives `setItems()` rebuilds) and, * optionally, further highlighted rows the owner manages as a multi-selection. A click with * Ctrl/Shift held does not change the selection itself but reports the modifiers. */ export class ListView extends Widget { private items: ListItem[] = []; private rows: ListRow[] = []; private selectedId: string | null = null; private highlighted = new Set(); /** Row an open context menu acts on – drawn like a selected row, but only until it closes. */ private contextId: string | null = null; onSelect?: (id: string | null, modifiers?: PickModifiers) => void; onActivate?: (id: string) => void; /** Returning the opened menu keeps its row marked for as long as it is up. */ onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void; /** Called when a dragged row is released somewhere that could take it. */ onDrop?: (draggedId: string, spot: DropSpot) => void; /** * Called once a press has travelled far enough to be a drag, for rows that are dragged out of * the list rather than within it. The list then drops the gesture: the owner owns it from here. */ onDragOut?: (id: string) => void; /** * Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list * a tree: every row reserves the chevron slot, so labels stay aligned across a level. */ onToggle?: (id: string, expand: boolean) => void; rowHeight: number = Theme.rowHeight; emptyText = 'Nothing here yet'; private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); private readonly dropMarker = new Graphics(); private drag: DragState | null = null; constructor() { super(); this.focusable = true; this.dropMarker.eventMode = 'none'; this.addChild(this.emptyLabel, this.dropMarker); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.target === this) this.select(null); }); this.on('globalpointermove', (e: FederatedPointerEvent) => this.onDragMove(e)); this.onPointerRelease(() => this.onDragEnd()); } get selected(): string | null { return this.selectedId; } /** * Rebuilds all rows. The selection survives when its id is still present and is dropped * silently otherwise; highlights are kept as they are (the owner refreshes them). */ 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 ListRow(item, this.rowHeight, Boolean(this.onToggle)); row.on('pointerdown', (e: FederatedPointerEvent) => this.onRowPress(row, e)); row.position.set(0, index * this.rowHeight); this.addChild(row); return row; }); this.addChild(this.dropMarker); // keep the marker above the rows if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null; if (this.contextId && !items.some((i) => i.id === this.contextId)) this.contextId = null; this.layout(); this.redraw(); } /** Selects by id (null clears). `silent` skips `onSelect`, for model → view sync. */ select(id: string | null, silent = false): void { if (id === this.selectedId) return; this.selectedId = id; this.redraw(); if (!silent) this.onSelect?.(id); } private setContext(id: string | null): void { if (id === this.contextId) return; this.contextId = id; this.redraw(); } /** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */ highlight(ids: Iterable): void { this.highlighted = new Set(ids); this.redraw(); } /** Label colour of a row as `#rrggbb`; null when no such row is shown (test hook). */ rowColor(id: string): string | null { return this.rows.find((r) => r.item.id === id)?.labelColor ?? null; } /** Global centre of a row; null when no such row is shown (test hook). */ rowGlobal(id: string): { x: number; y: number } | null { const row = this.rows.find((r) => r.item.id === id); if (!row) return null; const p = row.getGlobalPosition(); return { x: p.x + this.w / 2, y: p.y + this.rowHeight / 2 }; } /** Global centre of a row's chevron; null when the row has none. */ chevronGlobal(id: string): { x: number; y: number } | null { return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null; } 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 === 'ArrowRight' || event.key === 'ArrowLeft') { const item = this.items[index]; const expand = event.key === 'ArrowRight'; if (!item || item.expanded === undefined || item.expanded === expand) return false; this.onToggle?.(item.id, expand); 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) { const id = row.item.id; row.setSelected( id === this.selectedId || id === this.contextId || this.highlighted.has(id), this.focused, ); } this.emptyLabel.text = this.items.length === 0 ? this.emptyText : ''; this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height)); this.drawDropMarker(); } // --- pointer input ------------------------------------------------------------------------- private onRowPress(row: ListRow, e: FederatedPointerEvent): void { const { item } = row; const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }; // The chevron only folds the row – it never selects, so folding keeps the selection put. if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) { this.onToggle?.(item.id, !item.expanded); } else if (e.button === 2) { // Marked, not selected: selecting also loads the row into the editor, which would swap the // very tab out from under a menu opened to place something into it. const menu = this.onContextMenu?.(item.id, e.global.x, e.global.y); this.setContext(menu ? item.id : null); if (menu) menu.onClose = () => this.setContext(null); } else if (modifiers.ctrl || modifiers.shift) { this.onSelect?.(item.id, modifiers); } else { // A row that leaves the list is selected on release instead (see `onDragEnd`): picking // something up is not choosing it, and selecting an asset can move the very view it is // being dragged into. if (!this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId)) this.select(item.id); if (pressCount(e, item.id) === 2) this.onActivate?.(item.id); else if (this.onDrop || this.onDragOut) { this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null }; } } } private onDragMove(e: FederatedPointerEvent): void { const drag = this.drag; if (!drag) return; if (!drag.active) { if (Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) return; drag.active = true; this.cursor = 'grabbing'; if (this.onDragOut) { this.drag = null; this.cursor = 'default'; this.onDragOut(drag.id); return; } } const local = this.toLocal(e.global); drag.spot = this.spotAt(local.x, local.y, drag.id); this.invalidate(); } /** * Where a drop at this point would land. The outer quarter of a row is its gap, so the same * drag can put a row inside another (middle) or next to it (edge); a point on the dragged row * itself, or outside the list, takes nothing. */ private spotAt(x: number, y: number, draggedId: string): DropSpot | null { if (x < 0 || x > this.w || y < 0) return null; const index = Math.floor(y / this.rowHeight); const item = this.items[index]; if (!item) return { at: 'end' }; if (item.id === draggedId) return null; const offset = y - index * this.rowHeight; const edge = this.rowHeight / 4; if (offset < edge) return { at: 'before', targetId: item.id }; if (offset > this.rowHeight - edge) return { at: 'after', targetId: item.id }; return { at: 'onto', targetId: item.id }; } private onDragEnd(): void { const drag = this.drag; this.drag = null; this.cursor = 'default'; if (!drag?.active) { // The press never travelled, so it was a click after all – now it may select. if (drag && this.onDragOut) this.select(drag.id); return; } this.invalidate(); if (drag.spot) this.onDrop?.(drag.id, drag.spot); } /** Outlines the row a drag would drop into, or draws a line in the gap it would drop between. */ private drawDropMarker(): void { this.dropMarker.clear(); const spot = this.drag?.active ? this.drag.spot : null; if (!spot) return; const line = (y: number): void => { this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent); }; if (spot.at === 'end') { line(Math.min(this.h, this.items.length * this.rowHeight) - 1); return; } const index = this.items.findIndex((i) => i.id === spot.targetId); if (index < 0) return; if (spot.at === 'onto') { this.dropMarker .rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2) .stroke({ width: 2, color: Theme.colors.accent }); } else { line((index + (spot.at === 'after' ? 1 : 0)) * this.rowHeight); } } }