89 lines
3.9 KiB
TypeScript
89 lines
3.9 KiB
TypeScript
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
|
||
import { Widget } from '../ui';
|
||
|
||
/** The global box of a widget: where it starts, and how large it is. */
|
||
export function rectOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
} {
|
||
const p = widget.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
|
||
}
|
||
|
||
/** The overlap of two global rectangles; an empty one when they do not meet. */
|
||
function intersect(a: Rectangle, b: Rectangle): Rectangle {
|
||
const x = Math.max(a.x, b.x);
|
||
const y = Math.max(a.y, b.y);
|
||
return new Rectangle(
|
||
x,
|
||
y,
|
||
Math.max(0, Math.min(a.right, b.right) - x),
|
||
Math.max(0, Math.min(a.bottom, b.bottom) - y),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Finding widgets by the text they show – the ground floor of the test hook, and the only part of
|
||
* it the sign-in screen needs, since it exists before there is an editor to ask.
|
||
*/
|
||
export function textProbe(app: Application): {
|
||
eachText: (root: Container, visit: (node: Text, point: { x: number; y: number }) => void) => void;
|
||
locateAll: (text: string) => { x: number; y: number }[];
|
||
locate: (text: string, last?: boolean) => { x: number; y: number } | null;
|
||
} {
|
||
/**
|
||
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
|
||
* to `visit` with the centre a click on it would use.
|
||
*
|
||
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
|
||
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
|
||
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
|
||
* centre falls outside the clipping it sits in counts as not being there at all.
|
||
*
|
||
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
|
||
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
|
||
* the one hook and then cannot find it with the other.
|
||
*
|
||
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
|
||
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
|
||
* empty rectangle at the origin – which would throw away every masked text in the editor.
|
||
*/
|
||
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
|
||
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
|
||
if (!node.visible) return;
|
||
const holder = node instanceof Widget ? node : owner;
|
||
let inner = clip;
|
||
if (node.mask && holder) {
|
||
const r = rectOf(holder);
|
||
const box = new Rectangle(r.x, r.y, r.w, r.h);
|
||
inner = clip ? intersect(clip, box) : box;
|
||
}
|
||
if (node instanceof Text) {
|
||
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
|
||
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
|
||
const box = node.getBounds();
|
||
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
|
||
return;
|
||
}
|
||
for (const child of node.children) walk(child, inner, holder);
|
||
};
|
||
walk(root, null, null);
|
||
};
|
||
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
|
||
const locateAll = (text: string): { x: number; y: number }[] => {
|
||
const hits: { x: number; y: number }[] = [];
|
||
eachText(app.stage, (node, point) => {
|
||
if (node.text === text) hits.push(point);
|
||
});
|
||
return hits;
|
||
};
|
||
const locate = (text: string, last = false): { x: number; y: number } | null => {
|
||
const hits = locateAll(text);
|
||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||
};
|
||
return { eachText, locateAll, locate };
|
||
}
|