perfect target area thing

This commit is contained in:
Your Name 2026-10-03 00:39:07 -04:00
parent 664252e0fc
commit 5bcf22e458
23 changed files with 668 additions and 432 deletions

View file

@ -51,6 +51,52 @@
(is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes id]))))
(is (empty? (clip/problems made)))))
(deftest multi-paste-has-one-destination-and-preserves-root-timing
(let [doc (update-in (fixture/document) [:symbols :main] dissoc :display)
payload (:clipboard
(clipboard/snapshot doc [(address :main :a [:a])
(address :main :b [:b])]))
r (clipboard/paste doc payload :main 20 {:fresh-id (ids)})
made (:clip r)
[a2 b2] (:roots r)]
(is (= [20 24] (node/placed-span (get-in made [:symbols :main :nodes a2]))))
(is (= [24 28] (node/placed-span (get-in made [:symbols :main :nodes b2]))))
(is (= 2 (count (:roots r))))
(is (empty? (clip/problems made)))))
(deftest multi-paste-into-a-lane-claims-all-intervals-atomically
(let [doc (fixture/document)
payload (:clipboard
(clipboard/snapshot doc [(address :main :a [:a])
(address :main :insert [:insert])]))
r (clipboard/paste doc payload :main 4 {:fresh-id (ids)})
made (:clip r)
[a2 insert2] (:roots r)]
(is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes a2]))))
(is (= [12 16] (node/placed-span (get-in made [:symbols :main :nodes insert2]))))
(is (nil? (get-in made [:symbols :main :nodes :b])))
(is (= [8 12] (node/placed-span (get-in made [:symbols :main :nodes :insert]))))
(is (= 16 (get-in made [:symbols :main :frames])))
(is (empty? (clip/problems made)))))
(deftest overlapping-multi-paste-into-a-lane-is-all-or-nothing
(let [base (fixture/document)
doc (-> base
(assoc-in [:symbols :source]
{:id :source :frames 8
:nodes {:a (get-in base [:symbols :main :nodes :a])
:b (assoc-in (get-in base [:symbols :main :nodes :b])
[:time :at] 2)}})
(assoc-in [:symbols :empty-lane]
{:id :empty-lane :frames 8 :display :lane :nodes {}}))
payload (:clipboard
(clipboard/snapshot doc [(address :source :a [:a])
(address :source :b [:b])]))
r (clipboard/paste doc payload :empty-lane 0 {:fresh-id (ids)})]
(is (= "overlapping copied things cannot be pasted into one lane" (:refused r)))
(is (nil? (:clip r)))
(is (= {} (get-in doc [:symbols :empty-lane :nodes])))))
(deftest lane-duplicate-is-the-forward-repeat-and-ripples-later-cels
(let [doc (fixture/document)
payload (:clipboard

View file

@ -1,6 +1,7 @@
(ns arthur.events.lane-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.clip :as clip]
[arthur.domain.creation :as creation]
[arthur.domain.history :as history]
[arthur.domain.leaf :as leaf]
[arthur.domain.node :as node]
@ -42,22 +43,61 @@
(node/placed-span (get-in fitted [:symbols :main :nodes instance-id])))
"the lane follows a later change to its parent's extent")
(is (= 300 (clip/frames fitted lane-id))))
(is (some? (get-in lane-db [:ui :target]))
"the new lane is aimed so drawing and pool drops can go into it"))))
(is (= :lane (:kind (creation/target document {} :main
(get-in lane-db [:ui :selection]) 6)))
"the new lane is the active creation row"))))
(deftest aiming-the-root-clears-selection-and-drawing-target
(deftest a-new-symbol-on-a-lane-is-a-one-frame-cel
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "one-frame-new-symbol")]
(reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main
:selection [:node :main nil []]}
:playback {:frame 5}})
(rf/dispatch-sync [::ui/new-symbol :inside])
(let [db @rf-db/app-db
saved (:clip (store/entry id))
[_ sid instance-id] (get-in db [:ui :selection])
instance (get-in saved [:symbols sid :nodes instance-id])
source (node/source instance)]
(is (= :main sid))
(is (= [5 6] (node/placed-span instance)))
(is (= 1 (clip/frames saved source)))
(is (every? (fn [other]
(or (= instance-id (:id other))
(let [[a b] (node/placed-span other)]
(or (<= b 5) (<= 6 a)))))
(filter node/placed-span
(vals (get-in saved [:symbols :main :nodes]))))
"claiming the frame leaves no overlapping cel"))))
(deftest a-new-lane-uses-the-symbol-selected-at-the-playhead
(let [doc (clip/blank)
id (store/install! {:clip doc :store {}} "nested-new-lane")]
(reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main} :playback {:frame 6}})
(rf/dispatch-sync [::ui/new-symbol :inside])
(let [parent-instance (nth (get-in @rf-db/app-db [:ui :selection]) 2)
parent-sid (node/source (get-in (:clip (store/entry id))
[:symbols :main :nodes parent-instance]))]
(rf/dispatch-sync [::ui/new-lane])
(let [db @rf-db/app-db
saved (:clip (store/entry id))
[_ host lane-instance] (get-in db [:ui :selection])
lane-sid (node/source (get-in saved [:symbols host :nodes lane-instance]))]
(is (= parent-sid host) "the lane is created inside the selected symbol")
(is (= :lane (get-in saved [:symbols lane-sid :display])))
(is (= [0 (clip/frames saved host)]
(node/placed-span (get-in saved [:symbols host :nodes lane-instance]))))
(is (nil? (get-in saved [:symbols :main :nodes lane-instance]))
"it does not bypass the target and land in the tab root")))))
(deftest selecting-the-root-clears-the-active-row
(let [db {:ui {:selection [:node :main :shape [:lane :shape]]
:target {:sid :main :id :lane :path [:lane]}}}
after (ui/aimed db nil)]
(is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])))))
(deftest clearing-selection-also-clears-the-creation-target
(let [db {:ui {:selection [:symbol :drawing]
:target {:sid :main :id :lane :path [:lane]}}}
:selections [[:node :main :shape [:lane :shape]]]}}
after (ui/selected db nil)]
(is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])))))
(is (empty? (get-in after [:ui :selections])))))
(deftest an-explicit-lane-is-one-row-of-clips
(let [doc (fixture/document)
@ -111,7 +151,34 @@
(is (= 12 (ui/selection-frame doc nil :main
[:node :main :a [:a]] 12)))))
(deftest paste-targeting-continues-an-instance-clock-past-its-window
(deftest creation-walks-to-the-nearest-valid-parent-at-the-playhead
(let [doc (-> (fixture/document)
(assoc-in [:symbols :shot]
{:id :shot :frames 12 :fps 24
:nodes {:girl {:id :girl :kind :instance :z "a"
:span [0 12] :time {:mode :map :at 0 :rate 1}
:source {:symbol :main}
:playback {:in 0 :speed 1 :end :stop}}}})
(assoc-in [:symbols :outer]
{:id :outer :frames 30 :fps 24
:nodes {:take {:id :take :kind :instance :z "a"
:span [0 12] :time {:mode :map :at 10 :rate 1}
:source {:symbol :shot}
:playback {:in 0 :speed 1 :end :stop}}}}))
selection [:node :main :a [:take :girl :a]]
at #(select-keys (creation/target doc nil :outer selection %)
[:kind :sid :path :frame])]
(is (= {:kind :symbol :sid :drawing-a :path [:take :girl :a] :frame 0}
(at 12))
"the selected cel is the parent while every enclosing occurrence is active")
(is (= {:kind :lane :sid :main :path [:take :girl] :frame 5}
(at 15))
"past the cel, its containing lane is the nearest valid insertion surface")
(is (= {:kind :symbol :sid :outer :path [] :frame 25}
(at 25))
"a lane behind an inactive parent occurrence is not valid")))
(deftest creation-walks-out-of-an-instance-past-its-window
(let [doc {:fps 24 :width 20 :height 20
:symbols
{:main {:id :main :frames 40
@ -121,16 +188,40 @@
:playback {:in 0 :speed 1 :end :stop}}}}
:inside {:id :inside :frames 10 :nodes {}}}}
db {:ui {:open :main
:target {:sid :main :id :drawing :path [:drawing]}}
:selection [:node :main :drawing [:drawing]]}
:playback {:frame 20}}]
(is (= {:sid :inside :path [:drawing] :frame 20}
(ui/paste-destination db doc nil))
"pasting outside a target symbol's window authors cropped content")))
(is (= {:sid :main :path [] :frame 20}
(select-keys (ui/paste-destination db doc nil) [:sid :path :frame]))
"an inactive selected symbol falls back to the open symbol")))
(deftest multi-paste-uses-one-current-primary-target-and-selects-the-batch
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "multi-paste-target")
a [:node :main :a [:a]]
insert [:node :main :insert [:insert]]]
(reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main :selection insert :selections [a insert]}
:playback {:frame 4}})
(rf/dispatch-sync [::ui/copy])
;; Changing selection after copy changes the destination, not the payload.
(rf/dispatch-sync [::ui/select [:node :main nil []]])
(rf/dispatch-sync [::ui/paste])
(let [db @rf-db/app-db
saved (:clip (store/entry id))
selections (get-in db [:ui :selections])
[a2 insert2] (map #(nth % 2) selections)]
(is (= 2 (count selections)))
(is (= (peek selections) (get-in db [:ui :selection]))
"the last pasted root is the new primary creation anchor")
(is (= [4 8] (node/placed-span (get-in saved [:symbols :main :nodes a2]))))
(is (= [12 16] (node/placed-span (get-in saved [:symbols :main :nodes insert2]))))
(is (= 1 (count (get-in (store/entry id) [:history :done])))
"the complete batch is one edit"))))
(deftest polygon-landing-obeys-the-destination-symbol-mode
(let [lane (fixture/document)
ordinary (update-in lane [:symbols :main] dissoc :display)
db {:ui {:open :main :target {:sid :main :id :plate :path [:plate]}}
db {:ui {:open :main :selection [:node :main :plate [:plate]]}
:playback {:frame 5}}]
(is (true? (:lane? (ui/polygon-landing lane {} db))))
(is (false? (:lane? (ui/polygon-landing ordinary {} db))))))
@ -148,6 +239,21 @@
(is (nil? (get-in saved [:symbols :main :display])))
(is (nil? (get-in (store/entry (:clip/current after)) [:history :done])))))
(deftest drawing-with-the-lane-row-active-creates-a-new-cel
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "draw-new-lane-cel")
db {:clip/current id :paint/revision 0
:ui {:open :main :selection [:node :main nil []]}
:playback {:frame 1}}
after (ui/beginning-polygon db)
[_ sid cel-id] (get-in after [:ui :selection])
saved (:clip (store/entry id))]
(is (= :main sid))
(is (not= :a cel-id) "the occupied cel is not reused")
(is (= [1 2] (node/placed-span (get-in saved [:symbols sid :nodes cel-id]))))
(is (= 1 (clip/frames saved (node/source (get-in saved [:symbols sid :nodes cel-id])))))
(is (= :polygon (get-in after [:ui :tool])))))
(deftest sequence-commands-use-isolated-history-transactions
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "sequence-command-test")
@ -184,24 +290,22 @@
(is (= [:vo] (mapv :id (:cels lane))))
(is (empty? (timeline/sound-rows doc :main #{})))))
(deftest aiming-the-open-lanes-own-row-is-a-selection-and-not-a-crash
(deftest selecting-the-open-lanes-own-row-is-the-lane-target
;; `rows` names the open symbol's own lane row `[:node sid nil []]`, and
;; `selected` used to open the rows above a selection with `(pop path)` —
;; which THROWS on `[]`, aborting the whole event. Nothing was selected,
;; nothing was aimed, and the next polygon went wherever the stale target
;; still pointed, which is most of what made aiming a lane feel random.
;; nothing was selected, and the next polygon used stale creation state.
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "aim-the-open-lane")
id (store/install! {:clip doc :store {}} "select-the-open-lane")
db {:clip/current id :paint/revision 0
:ui {:open :main :target {:sid :main :id :a :path [:a]}}
:ui {:open :main}
:playback {:frame 0}}
lane (first (filter :lane? (timeline/rows doc :main #{})))
after (ui/aimed db (:select lane))]
after (ui/selected db (:select lane))]
(is (= [:node :main nil []] (:select lane)))
(is (= [:node :main nil []] (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target]))
"the open symbol IS the place, so aiming its own row clears the target")
(is (= [] (ui/where-new-goes doc after)))
(is (= :lane (:kind (creation/target doc {} :main
(get-in after [:ui :selection]) 0))))
(is (empty? (get-in after [:ui :expanded]))
"and there are no rows above the top to open")))
@ -229,7 +333,7 @@
id (store/install! {:clip doc :store {}} "finish-opens-nothing")
db {:clip/current id :paint/revision 0
:ui {:open :main :tool :polygon
:target {:sid :main :id :a :path [:a]}
:selection [:node :main :a [:a]]
:draft [10 10 40 10 40 40]}
:playback {:frame 1}}]
(reset! rf-db/app-db db)
@ -237,7 +341,7 @@
(let [after @rf-db/app-db
[kind sid shape-id path] (get-in after [:ui :selection])]
(is (= :node kind))
(is (= :drawing-a sid) "the shape went into the drawing the aimed clip places")
(is (= :drawing-a sid) "the shape went into the drawing the selected clip places")
(is (= [:a shape-id] path))
(is (empty? (get-in after [:ui :expanded])))
(is (nil? (get-in after [:ui :tool]))))))
@ -260,7 +364,7 @@
:playback {:in 0 :speed 1 :end :stop}}}}))
id (store/install! {:clip doc :store {}} "clip-selected-where-it-lives")
db {:clip/current id :paint/revision 0
:ui {:open :shot :target {:sid :shot :id :girl :path [:girl]}}
:ui {:open :shot :selection [:node :shot :girl [:girl]]}
:playback {:frame 9}}
after (ui/beginning-polygon db)
[_ sid clip-id path] (get-in after [:ui :selection])

View file

@ -120,6 +120,11 @@ try {
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.palette)").length'), 1,
'an ordinary symbol is an ordinary timeline row');
// The new symbol is now the active creation parent. Return to the tab root
// to ask for a sibling lane in `main`; leaving it selected would correctly
// create the lane inside that symbol.
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), null))`);
await clickNew('lane');
s = await shot();
placed = mainInstances(s);
@ -178,8 +183,11 @@ try {
`the dropped clip remains in the explicit lane: ${JSON.stringify(s)}`);
assert.equal(laneSymbols(s).length, 1, 'the drop creates no extra lane');
// A second explicit lane is a sibling in the open symbol even though the
// first remains aimed. Move the clip between their linear tracks.
// Return to the root active row, then make a sibling lane there. Lane
// creation follows the selected target; the unit suite separately asserts
// that leaving a symbol selected creates the lane inside that symbol.
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), null))`);
await clickNew('lane');
s = await shot();
assert.equal(laneSymbols(s).length, 2, 'a second explicit command creates a second lane');
@ -239,7 +247,7 @@ try {
await shortcut('z', 'KeyZ');
assert.equal(mainInstances(await shot()).length, 2, 'one undo restores the cut');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/aim'), null))`);
cljs.core.keyword('arthur.events.ui/select'), null))`);
await sleep(80);
await shortcut('v', 'KeyV');
assert.equal(mainInstances(await shot()).length, 3, 'paste uses the copied snapshot');

View file

@ -501,6 +501,17 @@ async function main() {
// the authored node, the raster preview and the document round trip.
await page.eval(SEEK(0));
await sleep(150);
// A lane row means "new one-frame cel". This test authors drawing keys
// across the existing take, so make that cel the active row first; creation
// then enters the symbol it places, exactly as a person clicking its block.
await page.eval(`(() => {
const cel = document.querySelector('.tl-cel');
if (!cel?.arthurCel?.select) return false;
re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), cel.arthurCel.select));
return true;
})()`);
await sleep(150);
const beforePaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
@ -513,7 +524,7 @@ async function main() {
// A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => {
const button = [...document.querySelectorAll('.palette-bar button')]
.find(b => b.textContent === 'polygon');
.find(b => b.textContent === 'pen');
if (!button) return false;
button.click();
return true;