Files
feuerwerk/src/editor/textProbe.ts
T
adminandClaude Opus 5.5 5e09ba9a50 Group the asset panel by category, then by type
The asset panel listed one heading per type, 30 of them side by side.
Now the types sit under five categories, and each type heading reads
like its entry in the + menu.

- ASSET_GROUPS replaces ASSET_GROUP_LABELS and gives every type a name
  and a category: Basics, Visuals, Logics, Physics or Audio.
- The categories keep that fixed order. The groups in a category and
  the assets in a group stay sorted by name.
- A category heading folds like a group heading. A project opens with
  every category open and every group folded, as before.
- Selecting an asset unfolds its category and its group. Folding the
  category of the selected asset lets go of the selection.
- The headings are singular now (Scene, Sprite, Sound FX). The old
  plural names only kept them apart from the + menu entries.
- locate looks in the popup layer first. A click by text meant for a
  menu entry hit the heading of the same name, since the panel is drawn
  first. Below an open menu a click only closes the menu anyway.
- New test hook __gm.assetCategories().

Tests:
- Unit asset-groups: 6 green, counter-check (empty categories kept)
  1 red
- e2e 04: ALL PASSED, 63 checks (1 new). Counter-check with locate
  taking the first hit again: 2 red, the click on Behavior created
  no asset
- Gate: CHECK EXIT 0, 261 files, 4040 tests
- The category names got their final plural form after these runs.
  The unit test ran again on them (6 green), e2e 04 did not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:59:08 +02:00

132 lines
5.7 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 Application, type Container, Graphics, Rectangle, Text } from 'pixi.js';
import { Button, ui, Widget } from '../ui';
import { pictureText } from '../viewport/textPicture';
/** 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),
);
}
/** A picture on screen, found by the id of its file. */
export interface IconHit {
x: number;
y: number;
menu: boolean;
}
/**
* 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: (text: string, point: { x: number; y: number }) => void) => void;
locateAll: (text: string) => { x: number; y: number }[];
locate: (text: string, last?: boolean) => { x: number; y: number } | null;
locateIcons: (name: string) => IconHit[];
} {
/**
* 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 eachShown = (
root: Container,
visit: (node: Container, clip: Rectangle | null, owner: Widget | null) => boolean,
): void => {
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
// A tooltip is no part of what a check looks for: it would answer for the button under it.
if (!node.visible || node.label === 'tooltip') 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 (visit(node, inner, holder)) return;
for (const child of node.children) walk(child, inner, holder);
};
walk(root, null, null);
};
const eachText = (
root: Container,
visit: (text: string, point: { x: number; y: number }) => void,
): void => {
eachShown(root, (node, clip) => {
// A text object in the game is a picture of its glyphs (#169), not a Pixi `Text`.
const text = node instanceof Text ? node.text : pictureText(node);
if (text === undefined) return false;
// 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 (!clip || clip.contains(point.x, point.y)) visit(text, point);
return true;
});
};
/**
* Every picture drawn from the file with this id (`icon:<id>`, see `drawSvg`), topmost last. The
* point is the file's origin, its centre; `menu` says whether the button it sits on opens a menu.
*/
const locateIcons = (name: string): IconHit[] => {
const hits: IconHit[] = [];
eachShown(app.stage, (node, clip, owner) => {
if (!(node instanceof Graphics) || node.label !== `icon:${name}`) return false;
const p = node.getGlobalPosition();
if (!clip || clip.contains(p.x, p.y))
hits.push({ x: p.x, y: p.y, menu: owner instanceof Button && owner.opensMenu });
return true;
});
return hits;
};
const hitsIn = (root: Container, text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
eachText(root, (shown, point) => {
if (shown === text) hits.push(point);
});
return hits;
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => hitsIn(app.stage, text);
/**
* The first such text – but one in an open popup before any beneath it, since a click below an
* open menu only closes the menu. The asset panel's headings read like the `+` menu's entries.
*/
const locate = (text: string, last = false): { x: number; y: number } | null => {
const onTop = last ? undefined : hitsIn(ui().overlay, text)[0];
const hits = locateAll(text);
return onTop ?? (last ? hits.at(-1) : hits[0]) ?? null;
};
return { eachText, locateAll, locate, locateIcons };
}