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