A lane is a generic row of symbol clips
A lane was a drawing lane: the only thing that could go in one was a one-frame held cel, and every other symbol instance stayed a permanent root row of its own. Those are not two kinds of timing, they are one kind with two creation policies. `lane/place-symbol` drops any library symbol in as a clip that plays naturally at speed one, `lane/adopt` moves an instance that is already in the document into a lane keeping its source, span, playback and corrections, and `append-drawing`/`overwrite-drawing` keep being the policy that makes a new empty symbol a one-frame hold. The child shape they produce is the same. Both new commands claim their interval through `blank` before they write, so the partition rule is unchanged and unduplicated: placing into occupied lane time trims, removes or splits the incumbents, and a lane still never stores an overlap. Real compositing overlap is another lane, where the order is explicit. Creating a symbol with nothing aimed now makes a lane and a clip in it instead of a loose root instance, and a pool drop prefers an explicitly targeted lane, then the selected one, and makes a lane only when there is neither. That is what stops the row-per-symbol growth coming back in through the drop path, and it is why `add-lane` now takes a z in front of the existing root nodes and calls what it makes a "lane" rather than "drawings". The timeline learned the two gestures that a generic lane needs. A clip body dragged over another lane's track previews there as a dashed block and lands through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its selection read back off the element, because a pointer capture does not retarget. A pool drop over an existing lane previews as a dashed clip inside that lane instead of a temporary new row that appears and then vanishes -- which also needed the drag-leave check to be geometric, since inserting the preview changes the element under the pointer and Chromium then reports a leave with no related target. Lanes are renameable from their label, by double-click, F2, or the pencil, through `::rename-node`. `symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the handoff now separates the two words it had merged: a clip is an instance in a lane, and a cel is specifically the one-frame held source that drawing creation makes. Keeping `cel` for the policy is what lets the lane stop being about drawings at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
26ada03591
commit
5ebe776ce4
12 changed files with 590 additions and 174 deletions
|
|
@ -102,16 +102,16 @@
|
|||
:extent :grow-symbol}))
|
||||
shrink (:clip (lane/resize-out doc :main :a 2 {:ripple? true}))]
|
||||
(is (= [[0 6] [6 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in plain [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in plain [:symbols :main :nodes]) :girl)))
|
||||
"a normal grow eats the beginning of the adjacent cel")
|
||||
(is (= [[0 9] [9 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in across [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in across [:symbols :main :nodes]) :girl)))
|
||||
"a long grow removes wholly consumed cels and trims the survivor")
|
||||
(is (= [[0 6] [6 10] [10 14]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in ripple [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in ripple [:symbols :main :nodes]) :girl)))
|
||||
"shift-grow moves every later cel")
|
||||
(is (= [[0 2] [2 6] [6 10]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in shrink [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in shrink [:symbols :main :nodes]) :girl)))
|
||||
"shift-shrink pulls every later cel left")
|
||||
(is (:refused (lane/resize-out doc :main :a 0 {})))
|
||||
(is (:refused (lane/resize-out doc :main :a 2.5 {})))))
|
||||
|
|
@ -122,13 +122,13 @@
|
|||
right-only (:clip (lane/resize-in doc :main :b 6))
|
||||
grown-left (:clip (lane/resize-in doc :main :b 2))]
|
||||
(is (= [[0 6] [6 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in rolled [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in rolled [:symbols :main :nodes]) :girl)))
|
||||
"the shared cut moves without moving either clip")
|
||||
(is (= [[0 4] [6 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in right-only [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in right-only [:symbols :main :nodes]) :girl)))
|
||||
"the right side of the junction trims only the right clip")
|
||||
(is (= [[0 2] [2 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in grown-left [:symbols :main :nodes]) :girl)))
|
||||
(mapv node/placed-span (symbol/lane-clips (get-in grown-left [:symbols :main :nodes]) :girl)))
|
||||
"growing the right clip left trims the neighbour instead of overlapping")
|
||||
(is (:refused (lane/roll doc :main :a :b 0)))
|
||||
(is (:refused (lane/roll doc :main :a :insert 6)))))
|
||||
|
|
@ -223,6 +223,39 @@
|
|||
(is (= 0 (get-in b [:symbols :main :nodes :b :playback :speed])))
|
||||
(is (:refused (lane/append-drawing b :main :girl :a :new {})))))
|
||||
|
||||
(deftest arbitrary-symbols-drop-into-the-same-lane-and-claim-their-time
|
||||
(let [doc (document)
|
||||
dropped (lane/place-symbol doc nil :main :girl :clip :wave 2
|
||||
{:extent :grow-symbol :remainder-id :tail})
|
||||
after (:clip dropped)
|
||||
clips (symbol/lane-clips (get-in after [:symbols :main :nodes]) :girl)]
|
||||
(is (= :clip (:selection dropped)))
|
||||
(is (= [[0 2] [2 12]] (mapv node/placed-span clips))
|
||||
"the natural ten-frame symbol claims [2,12), trimming/removing incumbents")
|
||||
(is (= :wave (node/source (second clips))))
|
||||
(is (= 1 (:speed (node/playback-of (second clips))))
|
||||
"a dropped symbol plays; it is not converted into a drawing hold")
|
||||
(is (empty? (clip/problems after)))))
|
||||
|
||||
(deftest an-existing-symbol-row-can-be-adopted-by-a-lane
|
||||
(let [doc (assoc-in (document) [:symbols :main :nodes :badge]
|
||||
{:id :badge :kind :instance :z "z"
|
||||
:source {:symbol :wave} :span [0 3]
|
||||
:time {:at 1 :rate 1}
|
||||
:playback {:in 2 :speed 1 :end :stop}})
|
||||
result (lane/adopt doc :main :girl :badge 5
|
||||
{:extent :grow-symbol :remainder-id :tail})
|
||||
after (:clip result)
|
||||
n (get-in after [:symbols :main :nodes :badge])]
|
||||
(is (= :girl (:parent n)))
|
||||
(is (= [5 8] (node/placed-span n)))
|
||||
(is (= {:in 2 :speed 1 :end :stop} (:playback n))
|
||||
"adoption changes placement, not source timing")
|
||||
(is (= [[0 4] [4 5] [5 8] [8 12]]
|
||||
(mapv node/placed-span
|
||||
(symbol/lane-clips (get-in after [:symbols :main :nodes]) :girl))))
|
||||
(is (empty? (clip/problems after)))))
|
||||
|
||||
(deftest fractional-placement-rates-convert-the-hold-delta
|
||||
(let [doc (-> (document)
|
||||
(assoc-in [:symbols :main :nodes :a :time :rate] 2)
|
||||
|
|
@ -557,7 +590,7 @@
|
|||
;; drawing must not quietly shorten the film.
|
||||
(let [doc (document)
|
||||
empty-lane (:clip (lane/blank doc :main :girl [0 12] {}))]
|
||||
(is (empty? (symbol/lane-cels (get-in empty-lane [:symbols :main :nodes]) :girl)))
|
||||
(is (empty? (symbol/lane-clips (get-in empty-lane [:symbols :main :nodes]) :girl)))
|
||||
(is (= 12 (get-in empty-lane [:symbols :main :frames])))
|
||||
(is (empty? (clip/problems empty-lane)))
|
||||
;; Growing is still the caller's word, and only ever grows.
|
||||
|
|
|
|||
|
|
@ -160,13 +160,74 @@ try {
|
|||
});
|
||||
await sleep(250);
|
||||
};
|
||||
const dropPoolSymbol = async frame => {
|
||||
const points = await evaluate(`(() => {
|
||||
const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]');
|
||||
const track = document.querySelector('.tl-track');
|
||||
if (!source || !track) return null;
|
||||
source.scrollIntoView({block: 'center'});
|
||||
const a = source.getBoundingClientRect(), b = track.getBoundingClientRect();
|
||||
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
|
||||
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
|
||||
tx: b.left + b.width * (${frame} + 0.25) / frames,
|
||||
ty: b.top + b.height / 2};
|
||||
})()`);
|
||||
assert(points, 'a library symbol and lane are available to drag');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx, y: points.sy});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy,
|
||||
button: 'left', buttons: 1, clickCount: 1});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx + 12, y: points.sy,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(120);
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(120);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0,
|
||||
'targeting an existing lane does not preview a temporary new row');
|
||||
const lanePreview = await evaluate(`(() => {
|
||||
const db = cljs.core.deref(re_frame.db.app_db), k = cljs.core.keyword;
|
||||
return {ghosts: document.querySelectorAll('.tl-track .tl-cel.ghost').length,
|
||||
drop: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('drop')]))};
|
||||
})()`);
|
||||
assert.equal(lanePreview.ghosts, 1,
|
||||
`the pool drop preview is drawn inside the targeted lane: ${JSON.stringify(lanePreview)}`);
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 0, clickCount: 1});
|
||||
await sleep(300);
|
||||
};
|
||||
const dragClipBetweenLanes = async frame => {
|
||||
const points = await evaluate(`(() => {
|
||||
const tracks = [...document.querySelectorAll('.tl-track')];
|
||||
const source = tracks[1]?.querySelector('.tl-cel');
|
||||
const target = tracks[0];
|
||||
if (!source || !target) return null;
|
||||
const a = source.getBoundingClientRect(), b = target.getBoundingClientRect();
|
||||
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
|
||||
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
|
||||
tx: b.left + b.width * (${frame} + 0.25) / frames,
|
||||
ty: b.top + b.height / 2};
|
||||
})()`);
|
||||
assert(points, 'two lanes and a source clip are available');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy,
|
||||
button: 'left', buttons: 1, clickCount: 1});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(150);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-track")[0].querySelectorAll(".tl-cel.ghost").length'), 1,
|
||||
'cross-lane movement previews in the destination lane');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 0, clickCount: 1});
|
||||
await sleep(300);
|
||||
};
|
||||
|
||||
await click('lane');
|
||||
assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true,
|
||||
'timing buttons are disabled when the selected row is a lane, not a symbol clip');
|
||||
'timing buttons are disabled without a symbol clip');
|
||||
await click('inside');
|
||||
let s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 1]], 'new inside an aimed lane is a one-frame symbol');
|
||||
assert.deepEqual(placed(s), [[0, 1]],
|
||||
'new at the root automatically makes a lane and a one-frame symbol clip');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-label .kind").length'), 1,
|
||||
'new temporal content creates a lane row rather than a row per symbol');
|
||||
assert.equal(await evaluate(`document.querySelectorAll('.cel-sheet, [aria-label="time view"]').length`), 0,
|
||||
'there is one temporal interface');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".timing-controls > button").length'), 3,
|
||||
|
|
@ -207,8 +268,41 @@ try {
|
|||
await drag('.tl-cel:first-of-type .tl-edge.out', 2);
|
||||
assert.deepEqual(placed(await shot()), [[0, 6], [6, 7], [7, 8]],
|
||||
'ordinary growth trims adjacent spans and never overlaps');
|
||||
|
||||
await dropPoolSymbol(10);
|
||||
s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 6], [6, 7], [7, 8], [10, 11]],
|
||||
'an arbitrary library symbol drops into an existing lane');
|
||||
assert.equal(instances(s).at(-1).playback.speed, 1,
|
||||
'a dropped symbol plays naturally instead of becoming a held drawing');
|
||||
|
||||
await evaluate(`re_frame.core.dispatch(cljs.core.vector(cljs.core.keyword('arthur.events.ui/new-lane')))`);
|
||||
await sleep(180);
|
||||
s = await shot();
|
||||
const renameControls = await evaluate('document.querySelectorAll(".tl-label .tl-rename").length');
|
||||
assert.equal(renameControls, 2,
|
||||
`both lanes expose rename controls: ${JSON.stringify(s.clip.symbols.main.nodes)}`);
|
||||
await evaluate('document.querySelector(".tl-label .tl-rename").click()');
|
||||
await sleep(80);
|
||||
assert(await evaluate(`(() => {
|
||||
const input = document.querySelector('.tl-name-input');
|
||||
if (!input) return false;
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'Foreground');
|
||||
input.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText', data: 'Foreground'}));
|
||||
input.blur(); return true;
|
||||
})()`), 'lane rename editor opens');
|
||||
await sleep(180);
|
||||
s = await shot();
|
||||
assert(Object.values(s.clip.symbols.main.nodes).some(n => n.layout === 'sequence' && n.name === 'Foreground'),
|
||||
'a lane name is editable and persisted in the document');
|
||||
|
||||
await dragClipBetweenLanes(12);
|
||||
s = await shot();
|
||||
const lanes = Object.values(s.clip.symbols.main.nodes).filter(n => n.layout === 'sequence');
|
||||
assert.deepEqual(lanes.map(l => instances(s).filter(n => n.parent === l.id).length).sort(), [1, 3],
|
||||
'a clip body can move from one lane to another');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: one timeline creates, trims, rolls, and ripples drawing-lane symbols');
|
||||
console.log('PASS: generic lanes preview, rename, move, place, trim, roll, and ripple clips');
|
||||
} finally {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue