Pixi only fills `detail` on the `click` event it synthesises; on `pointerdown` it is always 0, so every `e.detail === 2` branch was dead code. Asset rows, hierarchy folding and select-all in text fields never responded to a double-click. A shared click counter in src/ui/clicks.ts replaces the three readings of `e.detail`. A finished double-click ends the run, so two in a row count 2 and 2 rather than 3 and 4. The harness could only send `clickCount: 1`, which is why no test caught this: every "activate" check went through Enter. It can send real double-clicks now, and five checks drive the three paths separately.
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
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<string>();
|
||
/** 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<string>): 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);
|
||
}
|
||
}
|
||
}
|