Find the first matching item with a Find … Match block (#255)
Searching a list with For each needs the test result wired back into break on the left of the loop node. The wires then run across the node and the graph reads badly. Find … Match keeps the whole search flowing left to right, while the test in between stays a free graph. - Find takes exec and list and offers item and index. It sets them to each element in turn and has no exec output of its own. - Match reads its bool input match once per element. The first true value continues at found with item and index of the hit. No hit or an empty list continues at not found with an empty item and index -1. - The pair is derived from the wiring and stored nowhere. A Match belongs to the Find whose item or index its test is built from. Copy, paste and undo therefore need no extra code (findBlock.ts). - An unpaired Find or Match, two Matches on one Find and a Match fed by two Finds show a warning on the node. At run time the flow ends at an unpaired Find and the console says so. - Adding Find from the palette places its Match 480 px to the right in the same undo step. - The graph draws a quiet frame behind Find, Match and the nodes of the test. Literals feeding the test from outside stay outside the frame. - The test consists of data nodes only, like the break of For each. Tests: - ./check.sh: 184 files, 2694 tests, CHECK EXIT 0 - e2e GM_STEPS=1-6: ALL PASSED, 272 checks - New find-match.test.ts with 12 tests, 2 catalog cases, e2e 6z-v with 3 checks using a real mouse. - Counter-checks: ignoring the test fails 4 unit tests, no ambiguity check fails 2, framing everything fails 1. In the browser, no frame and no Match from the palette each fail their check. - Check total in README: 1517. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1d3c0cadb
commit
2aaca01929
16 files changed
+608
-5
No files matched your search
@@ -22,6 +22,8 @@ import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
export class AddNodeCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly node: GraphNode;
|
||||
/** The end of the block the node opens (`Find` → `Match`), added beside it in the same step. */
|
||||
readonly closing: GraphNode | null;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
@@ -40,6 +42,16 @@ export class AddNodeCommand implements Command {
|
||||
y: Math.round(y),
|
||||
params: { ...defaultsOf(def?.params ?? []), ...params },
|
||||
};
|
||||
const opens = def?.opens;
|
||||
this.closing = opens
|
||||
? {
|
||||
id: newId(),
|
||||
type: opens.type,
|
||||
x: this.node.x + opens.dx,
|
||||
y: this.node.y,
|
||||
params: defaultsOf(nodeDef(opens.type)?.params ?? []),
|
||||
}
|
||||
: null;
|
||||
this.label = def ? `Add ${def.label}` : `Add ${type}`;
|
||||
}
|
||||
|
||||
@@ -51,11 +63,13 @@ export class AddNodeCommand implements Command {
|
||||
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
|
||||
throw new CommandError(`This graph already has a ${nodeDef(this.node.type)?.label} node`);
|
||||
graph.nodes.push(this.node);
|
||||
if (this.closing) graph.nodes.push(this.closing);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
graph.nodes = removeById(graph.nodes, this.node.id);
|
||||
if (this.closing) graph.nodes = removeById(graph.nodes, this.closing.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { blockProblem, MATCH_INPUT, matchOf } from './findBlock';
|
||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||
import { type GraphLookup, NO_LOOKUP } from './lookup';
|
||||
import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested';
|
||||
@@ -159,6 +160,7 @@ export class GraphInterpreter {
|
||||
this.trace?.onNode(node.id);
|
||||
if (this.trace?.onValues) this.record(node.id, def.read ? this.readAll(node, def, ctx) : produced);
|
||||
if (result.repeat) this.loop(node, result.repeat, ctx, depth);
|
||||
if (result.find) this.search(node, result.find, ctx, depth);
|
||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||
for (const port of next) this.follow(node, port, ctx, depth);
|
||||
}
|
||||
@@ -247,6 +249,41 @@ export class GraphInterpreter {
|
||||
this.frame.outputCache.set(node.id, { ...after, [repeat.stopped]: stopped });
|
||||
}
|
||||
|
||||
/**
|
||||
* A Find's rounds: its `item` and `index` are set to one element after the other, and the Match
|
||||
* closing the block reads its test anew each time, the way a loop's stop input is read. The first
|
||||
* true one ends the search. Both nodes then hold the hit, and the flow goes on from the Match.
|
||||
*/
|
||||
private search(find: GraphNode, values: readonly Value[], ctx: NodeContext, depth: number): void {
|
||||
const match = matchOf(this.frame.graph, find.id);
|
||||
const test = match && this.def(match)?.inputs.find((p) => p.key === MATCH_INPUT);
|
||||
if (!match || !test) {
|
||||
ctx.log(`Find: ${blockProblem(this.frame.graph, find) ?? 'no Match closes it'}`);
|
||||
return;
|
||||
}
|
||||
let hit = -1;
|
||||
for (let round = 0; round < values.length; round++) {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||
this.frame.dataCache.clear();
|
||||
const roundOutputs = { item: values[round] ?? null, index: round };
|
||||
this.frame.outputCache.set(find.id, roundOutputs);
|
||||
this.record(find.id, roundOutputs);
|
||||
if (asBoolean(this.inputValue(match, test, ctx))) {
|
||||
hit = round;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.frame.dataCache.clear();
|
||||
const found = hit >= 0;
|
||||
const outputs = found ? { item: values[hit] ?? null, index: hit } : { item: null, index: -1 };
|
||||
this.frame.outputCache.set(find.id, outputs);
|
||||
this.frame.outputCache.set(match.id, outputs);
|
||||
this.trace?.onNode(match.id);
|
||||
this.record(match.id, outputs);
|
||||
this.follow(match, found ? 'found' : 'notFound', ctx, depth);
|
||||
}
|
||||
|
||||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||||
const inputs: Record<string, Value> = {};
|
||||
|
||||
@@ -70,6 +70,11 @@ export interface RunResult {
|
||||
repeat?: RepeatResult;
|
||||
/** Runs another behavior before continuing along `next`; its results join `outputs`. */
|
||||
call?: CallResult;
|
||||
/**
|
||||
* The elements a `Find` tests one by one against the `Match` closing its block. The flow goes on
|
||||
* from that Match, not from the Find.
|
||||
*/
|
||||
find?: readonly Value[];
|
||||
}
|
||||
|
||||
/** What a node may compute its ports from: itself, the graph it sits in, and the project around it. */
|
||||
@@ -115,6 +120,8 @@ export interface NodeDef {
|
||||
* between whole states, not as signal flowing left to right into a particular socket.
|
||||
*/
|
||||
transitionEdges?: true;
|
||||
/** The node that comes along when this one is added, `dx` to the right: the end of its block. */
|
||||
opens?: { type: string; dx: number };
|
||||
/**
|
||||
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
|
||||
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { findNode, type Graph, type GraphEdge, type GraphNode } from './Graph';
|
||||
import { nodeDef } from './NodeRegistry';
|
||||
|
||||
/*
|
||||
* A `Find` … `Match` block is not stored anywhere: it is the wiring. A Match closes the one Find its
|
||||
* test is built from – whatever reaches its `match` input through data edges and comes from that
|
||||
* Find's `item` or `index`. So copying, pasting and undo need nothing of their own.
|
||||
*/
|
||||
|
||||
export const FIND = 'Find';
|
||||
export const MATCH = 'Match';
|
||||
/** The boolean input a `Match` reads its test from. */
|
||||
export const MATCH_INPUT = 'match';
|
||||
|
||||
function isExecPort(type: string | undefined, key: string, side: 'inputs' | 'outputs'): boolean {
|
||||
return (type && nodeDef(type)?.[side].find((p) => p.key === key)?.type) === 'exec';
|
||||
}
|
||||
|
||||
function carriesData(graph: Graph, edge: GraphEdge): boolean {
|
||||
const from = findNode(graph, edge.from.node)?.type;
|
||||
const to = findNode(graph, edge.to.node)?.type;
|
||||
return !isExecPort(from, edge.from.port, 'outputs') && !isExecPort(to, edge.to.port, 'inputs');
|
||||
}
|
||||
|
||||
/** Nodes feeding `nodeId` through data edges, directly or through others. A path ends at a Find. */
|
||||
function feeders(graph: Graph, nodeId: string): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const todo = [nodeId];
|
||||
while (todo.length > 0) {
|
||||
const id = todo.pop()!;
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.to.node !== id || seen.has(edge.from.node) || !carriesData(graph, edge)) continue;
|
||||
seen.add(edge.from.node);
|
||||
if (findNode(graph, edge.from.node)?.type !== FIND) todo.push(edge.from.node);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** The Finds a Match's test is built from. */
|
||||
export function findsOf(graph: Graph, match: GraphNode): GraphNode[] {
|
||||
return [...feeders(graph, match.id)]
|
||||
.map((id) => findNode(graph, id))
|
||||
.filter((node): node is GraphNode => node?.type === FIND);
|
||||
}
|
||||
|
||||
/** The Matches whose test is built from this Find, alone or together with another. */
|
||||
function matchesOf(graph: Graph, findId: string): GraphNode[] {
|
||||
return graph.nodes.filter((n) => n.type === MATCH && findsOf(graph, n).some((f) => f.id === findId));
|
||||
}
|
||||
|
||||
/** The Match closing a Find's block: the only one testing it, and testing nothing else. */
|
||||
export function matchOf(graph: Graph, findId: string): GraphNode | null {
|
||||
const matches = matchesOf(graph, findId);
|
||||
const match = matches.length === 1 ? matches[0]! : null;
|
||||
return match && findsOf(graph, match).length === 1 ? match : null;
|
||||
}
|
||||
|
||||
/** Every node of a closed block: the Find, the nodes its item or index runs through, the Match. */
|
||||
export function blockOf(graph: Graph, find: GraphNode): string[] | null {
|
||||
const match = matchOf(graph, find.id);
|
||||
if (!match) return null;
|
||||
const between = [...feeders(graph, match.id)].filter(
|
||||
(id) => id !== find.id && feeders(graph, id).has(find.id),
|
||||
);
|
||||
return [find.id, ...between, match.id];
|
||||
}
|
||||
|
||||
/** What keeps a Find or a Match from forming a block, or null. */
|
||||
export function blockProblem(graph: Graph, node: GraphNode): string | null {
|
||||
if (node.type === FIND) {
|
||||
const matches = matchesOf(graph, node.id);
|
||||
if (matches.length === 0) return 'No Match tests its item';
|
||||
if (matches.length > 1) return `${matches.length} Matches test its item`;
|
||||
return matchOf(graph, node.id) ? null : 'Its Match tests another Find';
|
||||
}
|
||||
if (node.type === MATCH) {
|
||||
const finds = findsOf(graph, node);
|
||||
if (finds.length === 0) return 'match is not built from a Find';
|
||||
if (finds.length > 1) return `match is built from ${finds.length} Finds`;
|
||||
return matchesOf(graph, finds[0]!.id).length > 1 ? 'Another Match tests its Find' : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import './nodes';
|
||||
export * from './dropTarget';
|
||||
export * from './Graph';
|
||||
export * from './lookup';
|
||||
export * from './findBlock';
|
||||
export * from './nested';
|
||||
export * from './NodeContext';
|
||||
export * from './NodeRegistry';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FIND, MATCH } from '../../findBlock';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList } from '../../values';
|
||||
import { any, exec, list, num } from '../shared';
|
||||
|
||||
export const Find: NodeDef = {
|
||||
type: FIND,
|
||||
label: 'Find',
|
||||
category: 'flow',
|
||||
description:
|
||||
'Opens a search block, closed by a Match. "item" and "index" go through one element after the ' +
|
||||
'other; whatever is built from them into the Match\'s "match" is the test. The first element it ' +
|
||||
'is true for continues at "found" of the Match, none at "not found".',
|
||||
inputs: [exec(), list('list')],
|
||||
outputs: [any('item'), num('index')],
|
||||
params: [],
|
||||
opens: { type: MATCH, dx: 480 },
|
||||
run: (_ctx, inputs) => ({ outputs: { item: null, index: -1 }, find: asList(inputs.list) }),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MATCH, MATCH_INPUT } from '../../findBlock';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { any, bool, exec, num } from '../shared';
|
||||
|
||||
/** Never runs itself: the Find its test is built from hands it the hit and continues from here. */
|
||||
export const Match: NodeDef = {
|
||||
type: MATCH,
|
||||
label: 'Match',
|
||||
category: 'flow',
|
||||
description:
|
||||
'Closes the search block of the Find its "match" is built from. Continues at "found" with the ' +
|
||||
'first element "match" was true for, or at "not found" with no item and index -1.',
|
||||
inputs: [{ ...bool(MATCH_INPUT), inline: false }],
|
||||
outputs: [exec('found', 'found'), any('item'), num('index'), exec('notFound', 'not found')],
|
||||
params: [],
|
||||
};
|
||||
@@ -114,7 +114,9 @@ import { OnUpdate } from './events/OnUpdate';
|
||||
import { BehaviorEnd } from './flow/BehaviorEnd';
|
||||
import { BehaviorStart } from './flow/BehaviorStart';
|
||||
import { Branch } from './flow/Branch';
|
||||
import { Find } from './flow/Find';
|
||||
import { ForEach } from './flow/ForEach';
|
||||
import { Match } from './flow/Match';
|
||||
import { Repeat } from './flow/Repeat';
|
||||
import { RunBehavior } from './flow/RunBehavior';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
@@ -153,6 +155,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Sequence,
|
||||
Repeat,
|
||||
ForEach,
|
||||
Find,
|
||||
Match,
|
||||
RunBehavior,
|
||||
StateBranch,
|
||||
SetState,
|
||||
|
||||
@@ -46,6 +46,7 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId),
|
||||
/** The types the node's ports are drawn with, after a shared group had its say (#207). */
|
||||
portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId),
|
||||
blockFrames: () => graphEditor.canvas.blockFrames(),
|
||||
foldMark: (edgeId: string, isOutput: boolean) => graphEditor.canvas.foldMarkGlobal(edgeId, isOutput),
|
||||
foldMarks: () => graphEditor.canvas.foldMarksDrawn(),
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AddAnnotationCommand, type AnnotationBox } from '../../core/commands/an
|
||||
import {
|
||||
type AnnotationKind,
|
||||
BEHAVIOR_PARAM,
|
||||
blockProblem,
|
||||
callProblem,
|
||||
drawnDef,
|
||||
edgesInto,
|
||||
@@ -48,6 +49,7 @@ import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '..
|
||||
import { annotationBoxes, boxOf } from './annotationDrag';
|
||||
import { AnnotationLayer } from './annotationLayer';
|
||||
import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView';
|
||||
import { type BlockFrame, blockFrames, drawBlockFrames } from './blockFrames';
|
||||
import { EdgeLayer } from './EdgeLayer';
|
||||
import { DRAG_THRESHOLD, GraphDrags } from './graphDrags';
|
||||
import type { GraphCanvasHost } from './graphHost';
|
||||
@@ -86,6 +88,8 @@ export class GraphCanvas extends Widget {
|
||||
private readonly clip = new Graphics();
|
||||
private readonly canvas = new Container();
|
||||
private readonly annotationLayer = new AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e));
|
||||
private readonly blockLayer = new Graphics();
|
||||
private drawnBlocks: BlockFrame[] = [];
|
||||
private readonly edgeLayer = new EdgeLayer();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
@@ -148,7 +152,13 @@ export class GraphCanvas extends Widget {
|
||||
};
|
||||
this.wiring = new GraphWiring(host);
|
||||
this.drags = new GraphDrags(host);
|
||||
this.canvas.addChild(this.annotationLayer, this.edgeLayer, this.nodeLayer, this.tempEdge);
|
||||
this.canvas.addChild(
|
||||
this.annotationLayer,
|
||||
this.blockLayer,
|
||||
this.edgeLayer,
|
||||
this.nodeLayer,
|
||||
this.tempEdge,
|
||||
);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
|
||||
this.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
@@ -271,7 +281,8 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
const problem =
|
||||
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
|
||||
ghostProblem(node, asset.graph, resolved);
|
||||
ghostProblem(node, asset.graph, resolved) ??
|
||||
blockProblem(asset.graph, node);
|
||||
view.sync(node, asset.graph, this.state.project, def, problem);
|
||||
}
|
||||
for (const [id, view] of this.views) {
|
||||
@@ -796,6 +807,12 @@ export class GraphCanvas extends Widget {
|
||||
view.redraw();
|
||||
}
|
||||
|
||||
this.drawnBlocks = blockFrames(asset.graph, (id) => {
|
||||
const view = this.views.get(id);
|
||||
return view ? { id, x: view.x, y: view.y, w: view.w, h: view.h } : null;
|
||||
});
|
||||
drawBlockFrames(this.blockLayer, this.drawnBlocks);
|
||||
|
||||
this.edgeLayer.draw(
|
||||
asset.graph.edges,
|
||||
this.views,
|
||||
@@ -847,6 +864,11 @@ export class GraphCanvas extends Widget {
|
||||
return this.wiring.edgeShown;
|
||||
}
|
||||
|
||||
/** The frames the last redraw put behind the `Find` … `Match` blocks, in graph units. */
|
||||
blockFrames(): BlockFrame[] {
|
||||
return this.drawnBlocks;
|
||||
}
|
||||
|
||||
/** Whether the last redraw stroked this edge as a curve. */
|
||||
edgeDrawn(edgeId: string): boolean {
|
||||
return this.edgeLayer.wasStroked(edgeId);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Graphics } from 'pixi.js';
|
||||
import { blockOf, FIND } from '../../core/graph/findBlock';
|
||||
import type { Graph } from '../../core/graph/Graph';
|
||||
import { Theme } from '../../ui/Theme';
|
||||
import { type Bounds, type Box, boundsOf } from '../framing';
|
||||
|
||||
/** Room between a block's frame and the nodes it holds. */
|
||||
export const BLOCK_PAD = 14;
|
||||
|
||||
export interface BlockFrame {
|
||||
find: string;
|
||||
bounds: Bounds;
|
||||
}
|
||||
|
||||
/** One frame per closed `Find` … `Match` block, around every node of it that is drawn. */
|
||||
export function blockFrames(graph: Graph, boxOf: (id: string) => Box | null): BlockFrame[] {
|
||||
const frames: BlockFrame[] = [];
|
||||
for (const node of graph.nodes) {
|
||||
if (node.type !== FIND) continue;
|
||||
const boxes = (blockOf(graph, node) ?? []).map(boxOf).filter((b): b is Box => b !== null);
|
||||
const b = boundsOf(boxes);
|
||||
if (!b) continue;
|
||||
frames.push({
|
||||
find: node.id,
|
||||
bounds: {
|
||||
minX: b.minX - BLOCK_PAD,
|
||||
minY: b.minY - BLOCK_PAD,
|
||||
maxX: b.maxX + BLOCK_PAD,
|
||||
maxY: b.maxY + BLOCK_PAD,
|
||||
},
|
||||
});
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function drawBlockFrames(g: Graphics, frames: readonly BlockFrame[]): void {
|
||||
const color = Theme.colors.categoryFlow;
|
||||
g.clear();
|
||||
for (const { bounds: b } of frames)
|
||||
g.roundRect(b.minX, b.minY, b.maxX - b.minX, b.maxY - b.minY, 8)
|
||||
.fill({ color, alpha: 0.1 })
|
||||
.stroke({ width: 1, color, alpha: 0.6 });
|
||||
}
|
||||
Reference in new issue
Block a user