Holding a drawing and looping a one-frame symbol are the same picture, and the second is a case the model already carries, so the first is a special case kept for nothing. Speed 0 goes; a cel becomes a one-frame symbol with `:end :loop` and a span. Looping and span are already properties of the instance and `:frames` already belongs to the symbol, so nothing moves — a mode is deleted. Two spellings of looping collapse to one, `:time :loop?` giving way to `:playback :end :loop`, and `extend-hold` collapses into `resize-out`, since it exists only to refuse anything that is not frozen before editing a span. What it buys is one rule where there were three refusals. `nest/inside` has no invertible clock for a hold, for `:end :hold`, or for a loop, so shift-to-reparent refuses all three — which is most of what anybody would drag onto. Resolving the move with the destination's map at the CURRENT frame covers every one: a loop is affine within the period the frame falls in, a one-frame loop is that rule with a period of one — which lands exactly where the nesting notes argued it should from first principles — and `:end :hold` is affine in the played part and frozen in the tail. The trap is written down beside it, because it would be found the hard way: `audio-tracks` expands a loop into one walk per period, and is saved today only by the `(pos? speed)` guard that a held cel fails. Make every drawing a loop and a drawing held for 120 frames becomes 120 walks emitting any nested sound 120 times. An audibility precheck has to land in the same change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
14 KiB
Markdown
266 lines
14 KiB
Markdown
# A lane is a view
|
|
|
|
Plan, 2026-10-01, written at `2dc5735`. It undoes the lane model as a thing in
|
|
the document and keeps what it was for. Build on what is there and tear out
|
|
half of it.
|
|
|
|
## The decision
|
|
|
|
> A lane is a view over a symbol with sequential, non-overlapping children.
|
|
|
|
Nothing in the document is a lane. There is no lane type, no lane group, no
|
|
`:layout :sequence`, no lane commands and no lane validation. The word
|
|
survives in exactly two places: the UI, where a symbol can be DRAWN as a lane,
|
|
and the drag handling that re-spans a symbol's children while it is being
|
|
drawn that way.
|
|
|
|
The display model goes back to a row per symbol. A symbol in lane mode draws
|
|
its children as blocks on its own single row; expanded, they are rows like
|
|
anything else. Everything else is an ordinary row that expands into what it
|
|
places.
|
|
|
|
## What a lane was, and what each part becomes
|
|
|
|
| was | becomes |
|
|
| --- | --- |
|
|
| a group node with `:layout :sequence` | nothing — the symbol is the container |
|
|
| `node/lane?` | a view question: is this symbol drawn in lane mode |
|
|
| `symbol/lane-clips nodes lane-id` | the children of a symbol, sorted by `node/placed-span` |
|
|
| `symbol/lane-problems` | `symbol/overlaps`, a diagnostic the write path calls |
|
|
| `lane/lane-frame` | `clip/source-time` — one clock instead of two |
|
|
| `domain/lane.cljs` | re-based onto `domain/span.cljs`: re-spanning a symbol's children |
|
|
| `clip/lane-node`, the born-with lane | gone; a symbol is born empty again |
|
|
| `::ui/new-lane`, `::ui/adopt-in-lane`, lane renaming | gone, gone, and ordinary node renaming |
|
|
|
|
`lane.cljs`'s fourteen commands are not deleted — they are what "endpoint drag
|
|
overlap handling" means, and they already do the right arithmetic. What
|
|
changes is their subject: every one of them currently takes a host symbol AND
|
|
a lane id and asks `lane-clips nodes lane-id`; each takes a symbol and asks
|
|
for its children. `extend-hold`, `resize-out`, `resize-in`, `roll`, `blank`,
|
|
`place-symbol`, `adopt`, `append-drawing`, `reuse-drawing`,
|
|
`duplicate-drawing`, `overwrite-drawing`, `make-unique`. `span/finish` is
|
|
already the one commit path and stays exactly as it is.
|
|
|
|
Put them in `span.cljs`, which already owns "one write to one node's span" and
|
|
`finish`. The sequence operations are the same subject — re-spanning children
|
|
— and keeping them apart was a consequence of lanes existing.
|
|
|
|
## Children in lane mode never overlap
|
|
|
|
This is an invariant, not a condition to check for and report. Placement
|
|
claims time: anything placed, moved or grown over occupied time TRIMS the
|
|
extents it lands on — trimming the incumbent, removing one wholly covered, or
|
|
splitting one it lands inside — so the result has no overlap because the
|
|
operation that could have made one did not. That is `blank` followed by a
|
|
non-rippling placement, which is what `overwrite-drawing` already composes.
|
|
|
|
Enforced at the boundary, which already exists: `span/finish` is the single
|
|
commit path for every one of these commands, it validates before it returns,
|
|
and it refuses rather than half-applying. So `finish` gains the overlap check
|
|
for a symbol in lane mode, and no command can commit one. An overlap that
|
|
appears anyway is a bug in a command, not a state to design around.
|
|
|
|
Keep the check as a named diagnostic — `symbol/overlaps`, taking a symbol and
|
|
returning the pairs — used three ways:
|
|
|
|
1. `span/finish` refuses when it would commit one.
|
|
2. The test suite asserts no command can produce one: a property over the
|
|
commands in the style of `drawn` in `lane_test`, which samples rather than
|
|
computing expected numbers by hand.
|
|
3. A document that somehow arrives holding one still LOADS — a display hint
|
|
must never be able to stop a document loading — and the timeline draws it
|
|
visibly wrong with the status line saying so. Not `clip/problems`, which
|
|
means the document will not load, and not `clip/conflicts`, which means a
|
|
person has a decision to make. This is neither: it is a bug report.
|
|
|
|
Toggling lane mode ON for a symbol whose children already overlap is the one
|
|
place a person can ask for the impossible. Refuse it and say why, with the
|
|
`:required-frames` retry pattern offering to trim them into a sequence — the
|
|
domain reports what it would need, the UI offers one button.
|
|
|
|
Outside lane mode nothing is enforced, because overlapping children are what
|
|
compositing IS. An endpoint drag there is an ordinary span edit that may
|
|
overlap; the claim-time rule follows the mode.
|
|
|
|
## The two drag intentions
|
|
|
|
Unchanged from `docs/lane-nesting-notes.md`, and both kept:
|
|
|
|
- **Plain drag** of a clip body is temporal: it moves in time, within its
|
|
symbol or into another symbol drawn as a lane, and it REPLACES — trimming,
|
|
removing and splitting extents as needed so nothing overlaps.
|
|
- **Shift-drag** is structural: the dragged node goes INSIDE the symbol the
|
|
clip under the pointer places, through `nest/move-node`, which preserves the
|
|
world transform and the root timing. This must keep working for symbols
|
|
contained in a lane, which is the case it exists for.
|
|
|
|
Overlap cannot distinguish them — dropping on occupied time already means
|
|
claiming it — so the modifier says which, and the label by the pointer says it
|
|
back. `nest/move-refusal` already answers before the drop.
|
|
|
|
## Where lane mode lives
|
|
|
|
A symbol is drawn as a lane because somebody said so, not because of what its
|
|
children happen to look like at this moment. Deriving it from "the children do
|
|
not currently overlap" means a symbol stops being a lane the moment anything
|
|
overlaps, and the rules that maintain non-overlap switch off exactly when they
|
|
are needed.
|
|
|
|
Two options:
|
|
|
|
1. **Editor state**, `[:ui :lane-mode #{sid}]`. Purest reading of "a lane is a
|
|
view". But the drag rules follow the mode, so an unsaved, per-person toggle
|
|
would decide whether dropping a symbol trims its neighbour or composites
|
|
over it — the same gesture doing two different things to the document
|
|
depending on something the document does not record.
|
|
2. **A display hint on the symbol**, e.g. `:display :lane`, saved like any
|
|
other field (`clip-keys`, `leaf/leaves`, `leaf/clip` in the same commit).
|
|
Still not a type: nothing in evaluation reads it, `symbol/problems` does
|
|
not check it, and a symbol with it set behaves identically on the stage.
|
|
|
|
Recommended: 2. It is one field, it keeps editing rules reproducible between
|
|
people, and it does not make the symbol a different kind of thing. The thing
|
|
to hold the line on is that nothing outside the timeline, and the commit
|
|
path's overlap check, is allowed to read it.
|
|
|
|
## Tear out the held cel
|
|
|
|
A held cel is a 1-frame symbol shown for many frames. A 1-frame symbol with
|
|
`:end :loop`, lengthened, is the same picture by a different route — and the
|
|
second route is a case the model already has, so keeping the first one is
|
|
keeping a special case for free.
|
|
|
|
**What is already true**, so that nothing has to move: `:span` is on the node,
|
|
in its own frames; `:time` (`:at`, `:rate`) is on the node; looping is on the
|
|
node. The SYMBOL owns only `:frames`, the authored window. So looping and span
|
|
are instance properties already, and this change is about deleting a mode, not
|
|
relocating a field.
|
|
|
|
**What has to be decided and collapsed:**
|
|
|
|
- There are two spellings of looping — `:time :loop?` and `:playback :end
|
|
:loop` — and both are read, in `node/placed-frame` and in
|
|
`nest/audio-tracks`. Keep one. `:playback {:in :speed :end}` already says
|
|
what happens at the ends, so `:end :loop` is the one to keep and
|
|
`:time :loop?` is the one to delete.
|
|
- `:playback :speed 0` stops being produced. Make it illegal in
|
|
`node/problems` rather than legal-but-unused, so a frozen clock has exactly
|
|
one spelling: a 1-frame loop.
|
|
- `lane/extend-hold` exists only because holds were special — it refuses
|
|
anything whose speed is not 0 and then edits a span. Once a hold is a loop,
|
|
lengthening one IS `resize-out`, and the command collapses into it.
|
|
- `cel` survives as the word for the creation policy — a new empty symbol is
|
|
one frame — but stops naming a playback mode.
|
|
|
|
**What it buys, and this is the point:** one rule for nesting, which settles
|
|
the refusal that blocks shift-to-reparent today. `nest/inside` currently has
|
|
no `:time` for a hold, for `:end :hold`, or for a loop, and so refuses all
|
|
three. The general rule that covers all of them: **resolve the move with the
|
|
destination's map at the CURRENT frame — the affine piece the current frame
|
|
falls in.**
|
|
|
|
- A loop of length L is affine within the period the current frame is in.
|
|
Timing is preserved inside that period and repeats after it, which is what
|
|
looping means.
|
|
- A 1-frame loop — the ex-hold — has a period of length 1, so the map within
|
|
it is trivially invertible and lands the moved node on frame 0, aligned to
|
|
the current frame. That is exactly the answer `docs/lane-nesting-notes.md`
|
|
argues for from first principles, arrived at here as an instance of the
|
|
general rule instead of a special case.
|
|
- `:end :hold` is affine in the played part and frozen in the tail, which the
|
|
same sentence covers.
|
|
|
|
**The trap, which must be handled in the same change.** `nest/audio-tracks`
|
|
expands a loop into one walk PER PERIOD:
|
|
|
|
periods (range (floor (/ (to-local source lo) length))
|
|
(ceil (/ (to-local source hi) length)))
|
|
|
|
Today a held cel is skipped entirely — `(pos? speed)` is the guard, and the
|
|
comment says a visual freeze does not emit a sustained audio sample. Turn
|
|
every drawing into a 1-frame loop and that guard stops firing: a drawing held
|
|
for 120 frames becomes 120 recursive walks, and any sound inside it is emitted
|
|
120 times. That is both a wrong mix and a performance cliff on the most common
|
|
node in the document. Required with this change: a cheap `symbol/audible?`
|
|
precheck so a source with no audio anywhere inside it is never period-expanded,
|
|
and a cap or a different formulation for the ones that are.
|
|
|
|
Also worth knowing before the change: `ui/timeline`'s own docstring already
|
|
says a looping instance draws only its first pass. With every drawing a loop,
|
|
that sentence now describes every drawing — harmless, since one pass of a
|
|
1-frame symbol is the whole of it, but the docstring should stop sounding like
|
|
a limitation.
|
|
|
|
**Dropping into a 1-frame symbol.** The window is authored and crops what it
|
|
holds, so a 10-frame symbol dropped into a 1-frame drawing shows its frame 0
|
|
and nothing else. That is consistent — `:frames` is the shot length and
|
|
`:extent :grow-symbol` is the opt-in — but it is probably not what somebody
|
|
dragging means. Offer the growth through the `:required-frames` retry the
|
|
model already uses: the command reports what it would need, the UI offers one
|
|
button.
|
|
|
|
## Order of work
|
|
|
|
Each step compiles, passes `npm test`, and leaves the editor usable.
|
|
|
|
1. **Row per symbol.** In `ui/timeline.cljs`, delete `portal`, the `::portal`
|
|
hint row, the `under?` lineage predicate and the `chosen` argument; emit a
|
|
row for a clip whose parent is a lane instead of skipping it. Keep the
|
|
`:cels` blocks for the collapsed row, and keep `inside-rows` — including
|
|
its `:unmapped?` branch, which is what makes a held drawing's contents
|
|
reachable at all.
|
|
2. **Lane mode as a hint.** Add the field and the toggle, draw a symbol's
|
|
children as blocks when it is set and as rows when it is not, and move the
|
|
lane-row drag handling onto it. Both display paths now exist and nothing in
|
|
the domain has changed.
|
|
3. **Re-base the commands.** Move `lane.cljs` into `span.cljs`, replacing
|
|
`(lane-clips nodes lane-id)` with the symbol's children and dropping the
|
|
`lane-id` argument. `frontend/test/arthur/domain/lane_test.cljs` is the
|
|
proof: its fixtures should change and its assertions should not, and any
|
|
assertion that has to change is a behaviour change worth noticing.
|
|
4. **Move the invariant.** `symbol/lane-problems` becomes `symbol/overlaps`,
|
|
called by `span/finish` for a symbol in lane mode, plus the property test
|
|
that no command can produce an overlap.
|
|
5. **Delete the rest.** `node/lane?`, `symbol/lane-clips`, `clip/lane-node`,
|
|
`::ui/new-lane`, `::ui/adopt-in-lane`, the lane branch of
|
|
`::ui/new-symbol`, lane renaming, `aimed-lane`, and the
|
|
`:lane?`/`sound-lane?` row flags. Rename what is left so the word does not
|
|
appear outside the timeline.
|
|
6. **Audio falls out.** An audio node is already a parent-less child of a
|
|
symbol, which is exactly the new shape — so the audio-in-lane rules added
|
|
in `2dc5735` (`holds-other?`, the mixed-lane refusal, the `in-lane` filter
|
|
in `sound-rows`) delete rather than migrate. A symbol drawn as a lane whose
|
|
children are sounds is an audio lane, and that is the whole of it.
|
|
7. **Shift-to-reparent stays** as it is: `nest/move-node` and
|
|
`nest/move-refusal` never knew about lanes.
|
|
|
|
## What must not be lost
|
|
|
|
All of this was broken at some point today and is now proved; each has a test
|
|
to keep.
|
|
|
|
- A held clip's contents are reachable from the root timeline, with no keys
|
|
and no draggable edges — `source-time` is nil for a hold, and the walk used
|
|
to stop there.
|
|
- Double-clicking a clip opens its symbol as a tab, and the editor survives
|
|
it: `symbol/lineage` must not report a cycle for an id the symbol does not
|
|
hold, and opening a symbol must drop a selection pointing into the one being
|
|
left.
|
|
- Selection waits for pointer-up, so a press does not re-draw the timeline out
|
|
from under the gesture it is starting.
|
|
- A drop never silently deletes what it lands on.
|
|
- A sound is drawn once, not twice.
|
|
|
|
## Open questions
|
|
|
|
1. **Is lane mode a property of the symbol or of the instance placing it?** A
|
|
symbol placed twice would be drawn the same way in both places under the
|
|
first reading. That is probably right, and worth saying out loud.
|
|
2. **Does a lane row's edge drag trim the placing instance's span, or ripple
|
|
the children?** Same handle, two commands; the row is now an instance, so
|
|
it has a span of its own for the first time.
|
|
3. **Does tearing out the held cel come before or after the lane work?** It
|
|
is independent of it — `nest` never knew about lanes — and it is what makes
|
|
shift-to-reparent work on the thing people would actually drag onto. Doing
|
|
it first means the lane work lands on a model with one playback mode fewer;
|
|
doing it after means two changes to `lane_test`'s fixtures instead of one.
|