Thinking and Drawing in Parallel
Part 2 of 5 in Drawing Without Bookkeeping — a form on one side, its annotated twin on the other, and nothing copied between them.
Series: Drawing Without Bookkeeping
- Ask the Path —
query()andqueryAll()- Thinking and Drawing in Parallel (this post) —
subscribe()- A Linkage That Dimensions Itself — a four-bar linkage
- The Panel Prints Its Own Drill Schedule — a Eurorack front panel
- The Fretboard Is a Formula — a fretboard from one scale length
Prerequisites: This post assumes Ask the Path — the selector grammar and the structs it returns — and reuses its panel idiom without re-introducing it: a form layer on the left, a twin on the right, both drawing the same block. Lambdas and trailing blocks are covered in The Shape of a Stroke.
Part 1 asked a path questions after it was drawn. Asking removed the coordinates from the annotation and left the ordering in place. You drew, then you queried, then you drew the annotation, and the three had to stay in that order every time the program grew.
A subscription is a query that runs itself. You hand a layer a selector and a block, and the compiler calls the block once per match. The calls come in the order the geometry was drawn, wherever in the program the drawing happened. The annotation is declared once, and it follows.
rightForm.subscribe('endpoint') {|corner|
rightDots.apply {
circle(corner.x, corner.y, 3);
}
};
//-- Declared before the drawing. Left: the form. Right: a subscription on
//-- the twin's own form layer, registered before a single command is drawn;
//-- the dots arrive at program end, one per joint, in drawing order.
define ViewBox(0, 0, 480, 230);
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = oklch(0.55 0.16 260);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 230);
}
let tab = @{
h 120
a 20 20 0 0 1 20 20
v 50
a 20 20 0 0 1 -20 20
h -120
z
};
// ─── Left panel: the form ──────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 50;
translate-y: 75;
};
let leftForm = PathLayer('left-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let leftEyebrow = TextLayer('left-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let leftNote = TextLayer('left-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
leftPanel.append(leftForm, leftEyebrow, leftNote);
// ─── Right panel: the twin subscribes before it draws ──────────
let rightPanel = GroupLayer('right') #{
translate-x: 290;
translate-y: 75;
};
let rightForm = PathLayer('right-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: accent;
stroke: none;
};
let rightEyebrow = TextLayer('right-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let rightNote = TextLayer('right-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
rightPanel.append(rightForm, rightDots, rightEyebrow, rightNote);
// The annotation is declared before the drawing exists. Nothing is matched
// yet; at program end the callback runs once per joint, in drawing order.
rightForm.subscribe('endpoint') {|corner|
rightDots.apply {
circle(corner.x, corner.y, 3);
}
};
leftForm.apply {
M 0 0 tab.draw()
}
rightForm.apply {
M 0 0 tab.draw()
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 112)`drawn once, annotated nowhere`;
}
rightEyebrow.apply {
text(0, -18)`DECLARED FIRST`;
}
rightNote.apply {
text(0, 112)`the dots were declared before the tab`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 45
L 240 200
}
Everything that follows is about what "runs itself" means precisely, because the precision is where the useful behaviour comes from. The reference is the Subscriptions docs.
When, exactly
Nothing is matched while you draw. The compiler keeps every layer in memory until the last statement has run, then works through the subscriptions. Each one runs its selector once over the finished geometry inside the stretch of drawing it is watching, its window, and the results are replayed in the order the geometry was drawn. Two subscriptions on different layers interleave the way the drawing did, and two on the same statement fire in the order they were registered.
That timing has consequences worth stating before the pictures, in the spirit of the docs' first section:
- A callback sees the finished picture. Corner operations are
already applied; every
Endpointknows itsnextandturn; other layers are complete. - Variables hold their final values. The block is a closure, and it runs last. Change a variable after subscribing and the callback reads the changed value. The last sample makes this visible.
- A callback may not draw into the layer it subscribed to. Drawing there is an error the moment it happens. Subscriptions annotate other layers.
- Subscription output lands after everything drawn directly into the target. That moves where a dash pattern sits in its cycle and where markers land. Give annotations their own layer and it never matters.
- Only drawing after the
subscribecall is delivered. Query the layer for what came before. - Only path layers can be subscribed to. Text and group layers have
no geometry to observe, so
subscribeon one is an error. They are fine as targets: a callback writes into any layer it likes.
One block, two layers
The block receives up to three parameters: the match, its index among
this subscription's matches starting at 0, and the subscription itself.
Because the callback runs as ordinary top-level code, it can open apply
blocks on as many layers as it likes, text layers included.
rightForm.subscribe('endpoint') {|corner, i, sub|
// corner: the match — i: its index, from 0 — sub: the subscription
};
//-- Fan-out. One subscription, two target layers: a dot on every joint and
//-- its number beside it, from the ordinal the callback receives.
define ViewBox(0, 0, 480, 230);
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = oklch(0.55 0.16 260);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 230);
}
let tab = @{
h 120
a 20 20 0 0 1 20 20
v 50
a 20 20 0 0 1 -20 20
h -120
z
};
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 50;
translate-y: 75;
};
let leftForm = PathLayer('left-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let leftEyebrow = TextLayer('left-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let leftNote = TextLayer('left-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
leftPanel.append(leftForm, leftEyebrow, leftNote);
// ─── Right panel ───────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 290;
translate-y: 75;
};
let rightForm = PathLayer('right-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: accent;
stroke: none;
};
let rightLabels = TextLayer('right-labels') #{
font-family: font;
font-size: 7;
font-weight: 700;
letter-spacing: 0.8;
fill: accent;
text-anchor: middle;
};
let rightEyebrow = TextLayer('right-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let rightNote = TextLayer('right-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
rightPanel.append(rightForm,
rightDots,
rightLabels,
rightEyebrow,
rightNote);
// The callback's second parameter is the ordinal among this subscription's
// matches; the label sits a fixed distance out along the joint's own turn.
rightForm.subscribe('endpoint') {|corner, i|
rightDots.apply {
circle(corner.x, corner.y, 3);
}
// Outward at the joint: the incoming command's end tangent, turned half
// the corner's own turn, then a quarter turn off the path.
let heading = corner.command.block.tangent(1).angle;
let away = heading + corner.turn.rad / 2 - PI() / 2;
rightLabels.apply {
text(corner.x + cos(away) * 11, corner.y + sin(away) * 11 + 2.5)`${i}`;
}
};
leftForm.apply {
M 0 0 tab.draw()
}
rightForm.apply {
M 0 0 tab.draw()
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 112)`six joints, drawn in order`;
}
rightEyebrow.apply {
text(0, -18)`DOTS AND NUMBERS`;
}
rightNote.apply {
text(0, 112)`{|corner, i|} — one block, two layers`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 45
L 240 200
}
The number is pushed away from the corner along a direction the match
already knows: the incoming command's end tangent, turned by half the
joint's own turn. That is the whole of the label-placement problem,
answered by the struct rather than by a table of offsets. Parts 3 to 5
lean on the same move.
Windows
subscribe opens a window over the layer and unsubscribe() closes
it. Drawing between the two calls is what gets delivered:
//-- Windows. Two shapes drawn in two apply blocks. The subscription opens
//-- before the first and is closed with unsubscribe() before the second, so
//-- only the first shape's joints are annotated.
define ViewBox(0, 0, 480, 230);
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = oklch(0.55 0.16 260);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 230);
}
let square = @{
h 60
v 60
h -60
z
};
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 40;
translate-y: 70;
};
let leftForm = PathLayer('left-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let leftEyebrow = TextLayer('left-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let leftNote = TextLayer('left-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
leftPanel.append(leftForm, leftEyebrow, leftNote);
// ─── Right panel ───────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 270;
translate-y: 70;
};
let rightForm = PathLayer('right-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: accent;
stroke: none;
};
let rightEyebrow = TextLayer('right-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let rightNote = TextLayer('right-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
rightPanel.append(rightForm, rightDots, rightEyebrow, rightNote);
let corners = rightForm.subscribe('endpoint') {|corner|
rightDots.apply {
circle(corner.x, corner.y, 3);
}
};
leftForm.apply {
M 0 20 square.draw()
M 100 20 square.draw()
}
rightForm.apply {
M 0 20 square.draw()
}
// Closing the window: drawing after this point is not delivered.
corners.unsubscribe();
rightForm.apply {
M 100 20 square.draw()
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 112)`the same layer, drawn twice`;
}
rightEyebrow.apply {
text(0, -18)`ONE WINDOW`;
}
rightNote.apply {
text(0, 112)`subscribe · draw · unsubscribe · draw`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 45
L 240 200
}
Inside a callback, unsubscribe() means something slightly different:
the window is already fixed by then, so it cancels the matches that have
not fired yet. A callback that stops after the third match reads
if (i >= 2) { sub.unsubscribe(); }.
Selectors that need the whole window
Position pseudo-selectors that count from the end, :last and negative
:nth, cannot be answered until the window is complete. That is
exactly when callbacks run, so they simply work:
//-- Completion-only selectors. `endpoint:nth(-3..-1)` can only be answered
//-- once the whole window is known — which is exactly when callbacks run.
define ViewBox(0, 0, 480, 230);
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = oklch(0.55 0.16 260);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 230);
}
let tab = @{
h 120
a 20 20 0 0 1 20 20
v 50
a 20 20 0 0 1 -20 20
h -120
z
};
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 50;
translate-y: 75;
};
let leftForm = PathLayer('left-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let leftEyebrow = TextLayer('left-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let leftNote = TextLayer('left-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
let leftDots = PathLayer('left-dots') #{
fill: bg_color;
stroke: accent;
stroke-width: 1;
};
let leftNums = TextLayer('left-nums') #{
font-family: font;
font-size: 7;
font-weight: 700;
letter-spacing: 0.8;
fill: accent;
text-anchor: middle;
};
leftPanel.append(leftForm,
leftDots,
leftNums,
leftEyebrow,
leftNote);
// ─── Right panel ───────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 290;
translate-y: 75;
};
let rightForm = PathLayer('right-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: accent;
stroke: none;
};
let rightEyebrow = TextLayer('right-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let rightNote = TextLayer('right-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
rightPanel.append(rightForm, rightDots, rightEyebrow, rightNote);
// The left panel numbers every joint in drawing order, so the right
// panel's three can be checked against it.
leftForm.subscribe('endpoint') {|corner, i|
leftDots.apply {
circle(corner.x, corner.y, 3);
}
let heading = corner.command.block.tangent(1).angle;
let away = heading + corner.turn.rad / 2 - PI() / 2;
leftNums.apply {
text(corner.x + cos(away) * 11, corner.y + sin(away) * 11 + 2.5)`${i}`;
}
};
// Negative indexes count from the end of the window, as .slice() does. The
// selector runs once over the finished window before anything fires.
rightForm.subscribe('endpoint:nth(-3..-1)') {|corner|
rightDots.apply {
circle(corner.x, corner.y, 3.5);
}
};
leftForm.apply {
M 0 0 tab.draw()
}
rightForm.apply {
M 0 0 tab.draw()
}
leftEyebrow.apply {
text(0, -18)`THE FORM`;
}
leftNote.apply {
text(0, 112)`six joints in drawing order`;
}
rightEyebrow.apply {
text(0, -18)`ENDPOINT:NTH(-3..-1)`;
}
rightNote.apply {
text(0, 112)`the whole window first, then delivery`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 45
L 240 200
}
If subscriptions fired while you drew, this would be impossible; a "last" would keep moving. End-of-program delivery is what makes a completed window askable at all.
Annotations, annotated
A callback may draw into a layer that has subscriptions of its own. Those fire in a following round, and the rounds continue until no new drawing appears:
//-- Annotations, annotated. The dots layer has a subscription of its own:
//-- every circle() the first callback drew gets a ring in a second round.
define ViewBox(0, 0, 480, 230);
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = oklch(0.55 0.16 260);
let accent2 = oklch(0.55 0.16 27);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 230);
}
let tab = @{
h 120
a 20 20 0 0 1 20 20
v 50
a 20 20 0 0 1 -20 20
h -120
z
};
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 50;
translate-y: 75;
};
let leftForm = PathLayer('left-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let leftDots = PathLayer('left-dots') #{
fill: accent;
stroke: none;
};
let leftEyebrow = TextLayer('left-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let leftNote = TextLayer('left-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
leftPanel.append(leftForm, leftDots, leftEyebrow, leftNote);
// ─── Right panel ───────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 290;
translate-y: 75;
};
let rightForm = PathLayer('right-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: accent;
stroke: none;
};
let rings = PathLayer('rings') #{
stroke: accent2;
stroke-width: 1;
fill: none;
};
let rightEyebrow = TextLayer('right-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let rightNote = TextLayer('right-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
rightPanel.append(rightForm,
rightDots,
rings,
rightEyebrow,
rightNote);
// Round one: joints become dots.
leftForm.subscribe('endpoint') {|corner|
leftDots.apply {
circle(corner.x, corner.y, 3);
}
};
rightForm.subscribe('endpoint') {|corner|
rightDots.apply {
circle(corner.x, corner.y, 3);
}
};
// Round two: the dots layer is a source too. Its callback fires after the
// first round has drawn into it.
rightDots.subscribe('call(circle)') {|dot|
let hub = dot.block.centerPoint();
rings.apply {
circle(hub.x, hub.y, 7);
}
};
leftForm.apply {
M 0 0 tab.draw()
}
rightForm.apply {
M 0 0 tab.draw()
}
leftEyebrow.apply {
text(0, -18)`ONE ROUND`;
}
leftNote.apply {
text(0, 112)`a subscription on the form`;
}
rightEyebrow.apply {
text(0, -18)`TWO ROUNDS`;
}
rightNote.apply {
text(0, 112)`a second subscription, on the dots`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 45
L 240 200
}
A loop that never settles stops after eight rounds and reports the chain
it found, shape → dots → shape style. The self-write from the list
above is the shortest such loop and is caught at once.
The caveat, made visible
The closure rule is the one thing here that can surprise, so it gets a picture. A radius is bound, a subscription reads it, the form is drawn, and then the radius changes:
//-- The closure caveat, made visible. A radius is changed after subscribing.
//-- Left: a hand-written loop at the value the program had when it subscribed.
//-- Right: the callback, which runs at program end and sees the final value.
define ViewBox(0, 0, 480, 230);
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = oklch(0.55 0.16 260);
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 480, 230);
}
let tab = @{
h 120
a 20 20 0 0 1 20 20
v 50
a 20 20 0 0 1 -20 20
h -120
z
};
let radius = 3;
// ─── Left panel ────────────────────────────────────────────────
let leftPanel = GroupLayer('left') #{
translate-x: 50;
translate-y: 75;
};
let leftForm = PathLayer('left-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let leftDots = PathLayer('left-dots') #{
fill: accent;
stroke: none;
};
let leftEyebrow = TextLayer('left-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let leftNote = TextLayer('left-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
leftPanel.append(leftForm, leftDots, leftEyebrow, leftNote);
// ─── Right panel ───────────────────────────────────────────────
let rightPanel = GroupLayer('right') #{
translate-x: 290;
translate-y: 75;
};
let rightForm = PathLayer('right-form') #{
stroke: fg_auto;
stroke-width: 1.5;
fill: none;
};
let rightDots = PathLayer('right-dots') #{
fill: accent;
stroke: none;
};
let rightEyebrow = TextLayer('right-eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
text-anchor: start;
};
let rightNote = TextLayer('right-note') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
text-anchor: start;
};
rightPanel.append(rightForm, rightDots, rightEyebrow, rightNote);
// The callback reads `radius` when it runs, at the end of the program.
rightForm.subscribe('endpoint') {|corner|
rightDots.apply {
circle(corner.x, corner.y, radius);
}
};
leftForm.apply {
M 0 0 tab.draw()
}
rightForm.apply {
M 0 0 tab.draw()
}
// A query, by contrast, runs right here — with radius still 3.
leftDots.apply {
for (corner in leftForm.queryAll('endpoint')) {
circle(corner.x, corner.y, radius);
}
}
radius = 7;
leftEyebrow.apply {
text(0, -18)`WHEN YOU ASKED`;
}
leftNote.apply {
text(0, 112)`queryAll now: radius is 3`;
}
rightEyebrow.apply {
text(0, -18)`WHEN IT RAN`;
}
rightNote.apply {
text(0, 112)`the callback ran last: radius is 7`;
}
// ─── Divider ───────────────────────────────────────────────────
let divider = PathLayer('divider') #{
stroke: fg_hair;
stroke-width: 0.5;
fill: none;
};
divider.apply {
M 240 45
L 240 200
}
Bind what a callback needs before subscribing, or put it in the selector. If you need the value as of a line, that is what a query at that line is for.
The handle
subscribe returns a Subscription. source and selector say what it
watches. active is true while the window is open and nothing has
cancelled it. count is how many matches have been delivered, which is
the number you want inside a callback. log(handle) prints
Subscription(shape: 'endpoint', 4 delivered). The docs
table has the rest.
What this project taught the language
This series doubles as a working friction log (part 1 explains the convention). One entry this time, surfaced by the sample that draws rings around dots.
A run that begins with a move answers for its own geometry. The
ring sample asks the dots layer for call(circle) and centres a ring on
each block. Every circle() begins with a move, and that move's recorded
start is the pen position before it, which belongs to the previous
statement. The block's bounding box reached back to that point, so every
ring sat halfway to the previous dot. A quieter version of the same error
had put the boxes part 1 drew around each call at the panel origin.
Blocks
taken from a layer now begin where their move lands. The Path
Queries tables describe block as
the run alone; it is now true.
Where to go next
The next three posts put both mechanisms to work on real drawings. A four-bar linkage has its pivots numbered and its links dimensioned. A front panel prints its own drill schedule. A fretboard places and labels every fret from one scale length.
The reference is Subscriptions. Every sample above opens in the playground with one click: move a subscribe call, add a statement after an unsubscribe, and watch what is and is not delivered.