nesting.kt
src/jvmMain/kotlin/afield/document/graph/nesting.kt · 354 lines
package afield.document.graph
import afield.app.diagnostics.named
import afield.app.server.documents
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.persistence.SavingContext
import afield.document.workspace.metadata
import afield.runtime.execution.plus
import afield.runtime.lifecycle.forever
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.string
import afield.ui.notation.with
import afield.ui.output.templates.templates
import afield.ui.visualization.gesture
import afield.audio.synth.*
import afield.data.table.get
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.ui.output.templates.filter
import afield.ui.visualization.h
import afield.app.diagnostics.logging.lprintln
import afield.document.properties.Delegation
Nesting: drawing one box inside another, through the nested and nestedIn properties.
A nested box is also connected to its container, so it inherits the container's imports and
marked preamble lines. Setting nested or nestedIn keeps both sides and the edge in step;
nestInto also converts the frame to and from parent-relative coordinates, and refuses a cycle.
reconcileNestingEdges re-derives missing edges for boxes loaded from disk. The behaviour is
covered by NestingConnectsTest.
private val nestingMutationDepth = ThreadLocal.withInitial { 0 }
private inline fun <T> withNestingGuard(body: () -> T): T {
nestingMutationDepth.set(nestingMutationDepth.get() + 1)
return try {
body()
} finally {
nestingMutationDepth.set(nestingMutationDepth.get() - 1)
}
}
private fun inNestingGuard(): Boolean = nestingMutationDepth.get() > 0
var Box.nested by Delegation<MutableSet<Box>>(defaultValue = { mutableSetOf() }, hook = ::onNestedChange)
var Box.nested by Delegation<MutableSet<Box>>(defaultValue = { mutableSetOf() }, hook = ::onNestedChange)
var Box.nestedIn by Delegation<Box?>(defaultValue = { null }, hook = ::onNestedInChange)
source — 55 lines, from line 59
private fun onNestedChange(owner: HasProperties, currentValue: MutableSet<Box>, incomingValue: MutableSet<Box>): MutableSet<Box> {
val parent = owner as Box
if (inNestingGuard()) return incomingValue.toMutableSet()
return withNestingGuard {
val target = incomingValue.toMutableSet()
val removed = currentValue - target
val added = target - currentValue
removed.forEach { child ->
if (child.nestedIn == parent) {
child.nestedIn = null
}
// Disconnect parent-child relationship when removing from nested
parent.disconnect(child)
}
added.forEach { child ->
if (child.nestedIn != parent) {
child.nestedIn = parent
}
// Connect parent-child relationship when adding to nested
parent.connect(child)
}
target
}
}
private fun onNestedInChange(owner: HasProperties, currentValue: Box?, incomingValue: Box?): Box? {
val child = owner as Box
if (inNestingGuard()) return incomingValue
return withNestingGuard {
val oldParent = currentValue
val newParent = incomingValue
if (oldParent != null && oldParent != newParent) {
val updated = oldParent.nested.toMutableSet()
if (updated.remove(child)) {
oldParent.nested = updated
}
// Disconnect parent-child relationship from old parent
oldParent.disconnect(child)
}
if (newParent != null && !newParent.nested.contains(child)) {
val updated = newParent.nested.toMutableSet()
updated.add(child)
newParent.nested = updated
// Connect parent-child relationship to new parent
newParent.connect(child)
}
newParent
}
}
fun reconcileNestingEdges(boxes: Collection<Box>): Int
Re-derive the container edges that nesting implies, over boxes, and return how many edges were added.
The invariant. A nested box is connected to its container: nested/nestedIn and a
children/parents edge are two halves of one relation, because gatherPreambleUpwards
(macros/macros.kt) walks parents, and the edge is what carries a container's imports and
marked lines into what is drawn inside it. Two routes maintain that going forward —
onNestedChange / onNestedInChange since cbeded171, and nestInto since 398cb0ca9. This is
the third enforcement point, and it is the one for state that arrives already broken.
A document written before 398cb0ca9 has nestedIn set on the child and nothing on either
parents set, because nestInto was the only route a person could reach from the UI and it did
not connect. Measured over one real workspace — 401 documents holding a metadata.edn, 4479
boxes: of the 33 live boxes with nestedIn set, 31 have no persisted edge, and the two that do
were made by the copy/duplicate route, which assigns nestedIn and so fired the hook. So for
nesting this is not a corner case, it is the whole installed base.
This has run at EDN load since 03ad66360 (2026-04-30), inline and unnamed inside
SavingContext.reconcileLoadedTopology — which is why 398cb0ca9, written four months later,
describes the missing edge as a live condition when it is a file-only one. Lifted here under a name
so the other paths that materialise boxes from persisted state share the one implementation rather
than each growing their own or (as they did) going without.
Deliberately additive, and why that is sound rather than a heuristic. A missing edge is
indistinguishable from a deliberately removed one: the metadata says "parents" #{} beside
"nestedIn" #afield/box "…" and records no reason, no provenance, no tombstone for the edge, so a
-disconnect and a never-connected box are byte-identical. Re-deriving therefore cannot be a
repair of intent — only of a derived value, which is exactly what the edge now is. Since nesting
always connects and un-nesting always disconnects, on every route, "nested but not connected" is
no longer a state any gesture can produce and keep; a person who wants no edge wants no nesting.
There is no intent here to overrule.
Tombstoned boxes are skipped in both directions, and only the nest edge is ever added — a box's other edges, its document-root edge included, are untouched.
body — 47 lines, from line 151
fun reconcileNestingEdges(boxes: Collection<Box>): Int {
var added = 0
fun ensureConnection(parent: Box, child: Box) {
var changed = false
if (!parent.children.contains(child)) {
parent.setPropertyRaw("children", parent.children.toMutableSet().apply { add(child) })
changed = true
}
if (!child.parents.contains(parent)) {
child.setPropertyRaw("parents", child.parents.toMutableSet().apply { add(parent) })
changed = true
}
if (changed) added++
}
boxes.forEach { box ->
// A deleted box gets no edges re-derived either way round. The version of this that lived in
// `SavingContext` filtered tombstoned *children* and a tombstoned *container* but never asked
// whether `box` itself was deleted, so a tombstone that still carried `nestedIn` — 11 of them
// in the surveyed workspace — re-added a `children` edge on its live container, and a live
// box under a deleted container gained a `parents` edge that would have fed the deleted box's
// preamble back into it. On the document-open path that was invisible, because
// `agent_backdoor.kt` prunes tombstoned parents and children immediately afterwards. The
// other callers of this function have no such prune.
if (box.getPropertyRaw("_tombstone").isTrue()) return@forEach
box.nested
.filter { !it.getPropertyRaw("_tombstone").isTrue() }
.forEach { child ->
if (child.nestedIn != box) {
child.setPropertyRaw("nestedIn", box)
}
ensureConnection(box, child)
}
val nestedParent = box.nestedIn
if (nestedParent != null && !nestedParent.getPropertyRaw("_tombstone").isTrue()) {
if (!nestedParent.nested.contains(box)) {
nestedParent.setPropertyRaw("nested", nestedParent.nested.toMutableSet().apply { add(box) })
}
ensureConnection(nestedParent, box)
}
}
return added
}
fun Box.addNested(child: Box)
fun Box.addNested(child: Box) {
nested = nested.toMutableSet().apply { add(child) }
}
fun Box.removeNested(child: Box)
fun Box.removeNested(child: Box) {
if (nested.contains(child)) {
nested = nested.toMutableSet().apply { remove(child) }
}
}
fun Box.clearNested()
fun Box.clearNested() {
if (nested.isNotEmpty()) {
nested = mutableSetOf()
}
}
fun Box.nestInto(parent: Box?): Boolean
Nest this box inside parent — or, with null, un-nest it — converting the frame to and from
parent-relative coordinates, and moving the connection edge along with the containment.
Nesting connects. That is what onNestedChange and onNestedInChange have done since
cbeded171 ("Auto-parent nested boxes to nest container"), but this function did not, for two
compounding reasons: it runs inside withNestingGuard, which those hooks bail out of, and it
mutates nested in place, so a Delegation set-hook would never have fired for it anyway. Since
nestInto is the path the -nest-into / -un-nest actions and subtree import all take, the effect
was that almost every nested box in a document had no edge, while a box nested by assigning
nestedIn — workspace/copy.kt, which duplicates a nested box — had one. Duplicating a nested box
gave the copy a parent its original never had, and with it the container's inherited preamble.
So the two are the same operation now, and a box nested into a graphics box does inherit its
imports and marked preamble lines (macros/macros.kt#fun gatherPreambleUpwards).
One correction to the paragraph above, found while migrating the installed base: "had no edge" was
true of the file, not of the running document. reconcileNestingEdges — which lived inline in
SavingContext.reconcileLoadedTopology from 03ad66360, four months before this function was
fixed, and which nothing named or tested — re-derives the edge from nested/nestedIn on every
EDN load. So a document saved before this fix opens with its nest edges and inherits correctly;
what it lacks is only the persisted copy, until the next save. The measured survey is on
reconcileNestingEdges.
Un-nesting disconnects, and that is deliberate. The residual asymmetry this function was
shipped with — "a box that held an edge to its nest parent for some other reason loses it" — is
settled by the same argument that licenses re-deriving at load: an edge that exists for some other
reason is indistinguishable from the derived one. Nothing in parents records why an edge is
there. So un-nest has to pick, and removing is the choice consistent with re-deriving: the edge
lives and dies with the nesting, and a person who wants it back after un-nesting can reconnect,
where nothing will now re-derive it away. Keeping it would instead leave a phantom edge with no
gesture that explains it. Box.disconnect removes only this one edge, so a box's document-root
edge is untouched either way.
Refuses to nest a box inside itself or inside one of its own nested descendants, returning
false and changing nothing. Nesting has always allowed that — one such cycle exists in the
surveyed workspace, 35ee4ea3 ⇄ 79c4856c in nestedAgain2, where each box is the other's
nestedIn — and it used to be contained: collectAllNestedBoxes guards its walk explicitly for
exactly this reason. What changed is that connecting on nest puts the same cycle into the
parents graph, which is walked by far more code than nested is. Every walker checked survives
it (searchUpwards carries a seen set, so preamble gathering terminates), so this is a bad
state rather than a hang — but it is a bad state that no gesture should be able to ask for.
@return true if the nesting was applied, false if it was refused as a cycle.
fun Box.nestInto(parent: Box?): Boolean {
if (parent != null && parent.isNestedWithin(this)) {
lprintln(
"warning,nesting",
"refusing to nest ${this.name} into ${parent.name}: that would make a nesting cycle"
)
return false
}
withNestingGuard {
val oldParent = nestedIn
if (oldParent != null) {
val oldOffsetX = oldParent.frame.x
val oldOffsetY = oldParent.frame.y + oldParent.frame.h
frame = frame.copy(x = frame.x + oldOffsetX, y = frame.y + oldOffsetY)
oldParent.nested.remove(this)
needsResync.add(oldParent to "nested")
if (oldParent != parent) oldParent.disconnect(this)
}
if (parent != null) {
val newOffsetX = parent.frame.x
val newOffsetY = parent.frame.y + parent.frame.h
frame = frame.copy(x = frame.x - newOffsetX, y = frame.y - newOffsetY)
parent.nested.add(this)
needsResync.add(parent to "nested")
parent.connect(this)
}
nestedIn = parent
needsResync.add(this to "nestedIn")
}
return true
}
fun Box.isNestedWithin(ancestor: Box): Boolean
Is this box ancestor itself, or nested somewhere inside it?
Walks nestedIn upward, and carries a seen set because it has to survive the cycles it exists to
stop being created: nestedAgain2 in the surveyed workspace already holds one, so the very first
caller can be handed a graph that would otherwise loop forever.
fun Box.isNestedWithin(ancestor: Box): Boolean {
val seen = mutableSetOf<Box>()
var at: Box? = this
while (at != null && seen.add(at)) {
if (at == ancestor) return true
at = at.nestedIn
}
return false
}
fun collectAllNestedBoxes(boxes: Set<Box>): Set<Box>
Recursively collects all nested boxes from the given set of boxes. This ensures that when saving templates or copying boxes, all nested children are included.
@param boxes The initial set of boxes @return A new set containing the original boxes plus all their recursively nested children
fun collectAllNestedBoxes(boxes: Set<Box>): Set<Box> {
val result = mutableSetOf<Box>()
val queue = ArrayDeque(boxes)
while (queue.isNotEmpty()) {
val box = queue.removeFirst()
if (result.add(box)) {
// result.add() returns true only if the box wasn't already in the set,
// which prevents infinite loops in circular references and duplicate processing
queue.addAll(box.nested)
}
}
return result
}
fun formatBoxCountMessage(selectedCount: Int, totalCount: Int, actionVerb: String = ""): String
Creates a notification message describing the number of boxes and nested boxes.
@param selectedCount The number of originally selected boxes @param totalCount The total number including nested boxes @param actionVerb The action being performed (e.g., "Copied", "Saved"). Pass empty string for no verb. @return A formatted message string
fun formatBoxCountMessage(selectedCount: Int, totalCount: Int, actionVerb: String = ""): String {
val nestedCount = totalCount - selectedCount
val boxWord = if (selectedCount == 1) "box" else "boxes"
val prefix = if (actionVerb.isNotEmpty()) "$actionVerb " else ""
return if (nestedCount > 0) {
"$prefix$selectedCount $boxWord + $nestedCount nested".trim()
} else {
"$prefix$selectedCount $boxWord".trim()
}
}