diff --git a/.claude/worktrees/tracing-layers b/.claude/worktrees/tracing-layers new file mode 160000 index 0000000..f4dd047 --- /dev/null +++ b/.claude/worktrees/tracing-layers @@ -0,0 +1 @@ +Subproject commit f4dd04764204506fc275180364d0366d693036f4 diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index 6bd341b..c61f6af 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -57,8 +57,9 @@ THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at the stage, like solo and zoom: not an undo step, not sent to collaborators, and it cannot reach an export. A layer is shown unless it is in `:hidden`, so - footage brought in shows without being found and switched on first." - {:on? true :opacity 0.5 :hidden #{}}) + footage brought in shows without being found and switched on first — once + tracing itself is on, which it is not until asked for." + {:on? false :opacity 0.5 :hidden #{}}) (def default {;; --- the document --- diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 962d481..04df444 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -25,13 +25,14 @@ another clip is `arthur.domain.bring`. This namespace is the document and the operations that only need the document. - NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which - symbol is on screen is the editor's state, not the document's, and every - function here that needs a symbol is told which. A new document has one symbol - called `:main` because it has to be called something, and that is all the name - means — it can be renamed, placed inside another symbol or deleted like any of - them. `unplaced` answers the question a reserved root used to: which symbols - nothing else places, and so which ones a person opening the document wants. + NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen + is the editor's state, not the document's, and every function here that needs + a symbol is told which. A new document has one symbol called `:main` because + it has to be called something, and that is all the name means — it can be + renamed or placed inside another symbol like any of them. What the document + does say is `:root`, which symbol it opens on: a pointer, not a kind of + symbol, the way a Flash file names its scene. See `opens-on` for why that + cannot be worked out instead. :fps is the output grid. A symbol's optional :fps names the native grid its frames were authored or measured on; absent means the document's grid." @@ -52,7 +53,7 @@ layer must not be able to have. Add the field here and to `leaf/leaves` and `leaf/clip` in the same commit." #{:name :fps :analyses :subjects :features :groups :width :height :symbols - :palettes :default-palette}) + :palettes :default-palette :root}) (defn symbol "One of the clip's symbols, by id." @@ -201,18 +202,39 @@ (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] (vec (sort-by str (remove placed (keys (:symbols clip))))))) -(defn opens-on - "The symbol a document opens on: the longest one nothing else places, ties - broken by id. The symbol that contains everything else is the longest of the - unplaced ones in every document made so far, and a reserved name is what this - replaces. - - Never a tracing symbol: it is footage nobody has placed yet, and as long as - the take it came from, so it would otherwise win." +(defn- longest-unplaced + "The longest symbol nothing else places, ties broken by id, and never a + tracing symbol: that is footage nobody has placed yet, as long as its take." [clip] (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) (remove #(trace? (symbol clip %)) (unplaced clip))))) +(defn opens-on + "The symbol a document opens on: its `:root`. + + IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on + the theory that the one containing everything else is always that. A take + disproves it: imported footage is a symbol a thousand frames long, and the + moment its instance is deleted, or the drop lands somewhere other than the + root, nothing places it and it outranks a 120-frame `:main`. The document then + opened on the take, and `set-root-fps` rewrote the take's rate to the + project's on the way in. + + A document with no `:root` — a demo, a fixture — still gets the old answer." + [clip] + (let [root (:root clip)] + (if (contains? (:symbols clip) root) root (longest-unplaced clip)))) + +(defn pin-root + "Give a document saved before `:root` existed the root it was made with. + Every such document started as `blank`, whose root is `:main`, and ids never + change, so `:main` is the answer wherever it survives; the old rule is only + for documents that never had one." + [clip] + (cond-> clip + (not (:root clip)) + (assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip))))) + (defn set-root-fps "Set the document/output rate and the root symbol's editing rate together. @@ -250,6 +272,7 @@ :width 320 :height 200 :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id + :root :main ;; No native fps yet: an untouched canvas follows the project grid. Imported ;; and generated symbols carry their own rate explicitly. :symbols {:main {:id :main :frames blank-frames :nodes {}}}}) @@ -723,6 +746,9 @@ (when (and (contains? clip :default-palette) (map? (:palettes clip)) (not (contains? (:palettes clip) (:default-palette clip)))) [":default-palette must name a project palette"]) + (when (and (contains? clip :root) (map? (:symbols clip)) + (not (contains? (:symbols clip) (:root clip)))) + [(str ":root names missing symbol " (pr-str (:root clip)))]) (for [[id p] (:palettes clip) :when (or (not= id (:id p)) (not (pal/valid-palette? p)))] (str "palette " (pr-str id) " is invalid or has a different :id")) diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 822294d..02a28f9 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -12,6 +12,7 @@ clip//stage width, height clip//palette/ a named indexed palette asset clip//palette-default the project fallback palette id + clip//root the symbol the document opens on clip//source the analysis record this came out of clip//subject/ a tracked subject and its params clip//feature/ one feature: area, nodes, params @@ -138,6 +139,7 @@ (some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "palette-default") (select-keys clip [:default-palette])) + (some-leaf (at "root") (select-keys clip [:root])) (some-leaf (at "analyses") (:analyses clip)) (concat (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) @@ -198,6 +200,7 @@ "stage" (merge acc v) "analyses" (assoc acc :analyses v) "palette-default" (merge acc v) + "root" (merge acc v) "palette" (assoc-in acc [:palettes (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v) "feature" (assoc-in acc [:features (unsegment a)] v) @@ -239,7 +242,7 @@ false) (case (count p) ;; The clip's own facts carry no id. - 3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2)) + 3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2)) false))))] (vec diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index e6f133f..1b29bf4 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -113,7 +113,9 @@ ;; also normalizes documents saved by the earlier model, ;; where changing project FPS left the root on its old ;; editing grid. - built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))] + built (-> (:clip loaded) + clip/pin-root + (clip/set-root-fps (:fps (:clip loaded))))] (let [entry (merge (select-keys built [:fps :width :height]) {:label (str (or (.-name clip-json) cid) " (saved)") :cid cid diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index 2f2641b..18a201e 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -841,6 +841,7 @@ :width (first stage) :height (second stage) :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id + :root :main :subjects (into {} (map (fn [[id _]] [id {:id id :params {} :analysis (:id analysis) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 091ad2e..654444a 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -1241,10 +1241,9 @@ (let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))] (for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels))))) :when (and (<= 0 f) (< f frames))] - ^{:key index} (if-let [items (and (= :channel kind) (seq (get by-frame f)))] - [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] - [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) + ^{:key index} [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] + ^{:key index} [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) (defn- cursor-hint "What the drag in flight would do, beside the pointer. diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index d425c20..abe3d8f 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -216,10 +216,19 @@ (deftest no-symbol-is-special (let [c (nested)] - (testing "a document opens on the longest symbol nothing places" + (testing "a document opens on its root, however long its unplaced symbols are" (is (= [:loose :main :outer] (clip/unplaced c))) - (is (= :outer (clip/opens-on c))) - (is (= :main (clip/opens-on (clip/blank))))) + (is (= :main (clip/opens-on c))) + (is (= :main (clip/opens-on (clip/blank)))) + (is (= :loose (clip/opens-on (assoc c :root :loose)))) + (is (= c (leaf/clip "u" (leaf/leaves "u" c)))) + (is (seq (clip/problems (assoc c :root :gone))))) + (testing "without a root, the longest symbol nothing places" + (is (= :outer (clip/opens-on (dissoc c :root))))) + (testing "a document saved before roots opens on :main, not a longer take" + (is (= :main (:root (clip/pin-root (dissoc c :root))))) + (is (= :outer (:root (clip/pin-root (-> c (dissoc :root) (update :symbols dissoc :main)))))) + (is (= :loose (:root (clip/pin-root (assoc c :root :loose)))))) (testing "an instance can go into any symbol, and spans that symbol's frames" (let [[n] (vals (get-in c [:symbols :outer :nodes]))] (is (= #{:inner} (node/sources n))) diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 039c147..93d3721 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -523,32 +523,32 @@ async function main() { // same synchronous block arrives while the tool is still unset and is dropped. // 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 === 'pen'); + const button = document.querySelector('.toolbox button.tool[title^="Pen"]'); if (!button) return false; button.click(); return true; })()`); await sleep(100); - const painted = armed && await page.eval(`(() => { - const svg = document.querySelector('.paint-overlay'); - const box = svg.getBoundingClientRect(); - for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { - svg.dispatchEvent(new PointerEvent('pointerdown', { - bubbles: true, clientX: box.left + x * box.width / 320, - clientY: box.top + y * box.height / 200, - })); - } + // The overlay reaches past the stage on every side, so a stage pixel is + // found through the SVG's own transform, as the stage finds it coming back. + // Each point is its own eval: a click is a re-frame event, and the next one + // reads the draft it added to. + const PEN_AT = (x, y) => `(() => { + const svg = document.querySelector('.paint-overlay.tool-pen'); + if (!svg) return false; + const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM()); + svg.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, clientX: p.x, clientY: p.y, + })); return true; - })()`); - await sleep(100); - const finished = await page.eval(`(() => { - const button = [...document.querySelectorAll('.palette-bar button')] - .find(b => b.textContent === 'finish'); - if (!button || button.disabled) return false; - button.click(); - return true; - })()`); + })()`; + let painted = armed; + for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { + painted = painted && await page.eval(PEN_AT(x, y)); + await sleep(60); + } + // Closed as a person closes it: on the first point. + const finished = painted && await page.eval(PEN_AT(10, 10)); await sleep(250); const afterPaint = await page.eval(`(() => { const c = document.querySelector('canvas.stage'); @@ -556,7 +556,8 @@ async function main() { return [...d]; })()`); check(painted && finished && beforePaint.join(',') !== afterPaint.join(','), - 'a polygon drawn with the paint controls appears on the canvas'); + 'a polygon drawn with the paint controls appears on the canvas', + `${beforePaint} -> ${afterPaint}`); for (const f of [8, 16]) { await page.eval(SEEK(f)); await sleep(100); @@ -597,7 +598,18 @@ async function main() { return [...c.getContext('2d').getImageData(25, 20, 1, 1).data]; })()`); check(afterPaint.join(',') === reopenedPaint.join(','), - 'the painted polygon survives the project round trip'); + 'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`); + // A reopened document has nothing selected, so the shape's keys are not in + // the inspector until it is selected again, as anybody would. + await page.eval(`(() => { + const c = cljs.core, k = c.keyword; + const db = c.deref(re_frame.db.app_db); + const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')), + c.vector(k('symbols'), k('main'), k('nodes'))); + const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes))); + re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'), + c.vector(k('node'), k('main'), id, c.vector(id)))); + })()`); await page.eval(SEEK(8)); await sleep(100); check(await page.eval(`(() => {