Files
feuerwerk/src/ui/widgets/ListView.ts
T
root ed1fe31c77 Count clicks ourselves so double-clicks work at all
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.
2026-09-09 14:13:05 +02:00

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}