From 90b1fbe2f8793c216b6f381fea76d234dbd9c983 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 3 Oct 2026 03:52:39 -0400 Subject: [PATCH] Create in a lane at the frame double-clicked, palette lanes included The gesture was handed the pointer's frame and threw it away, creating at the playhead instead; and the palette row carried :lane? only once its track existed, so the first double-click on a palette lane -- the one that has to make the track -- dispatched nothing at all. Both are one rule now. ::new-symbol-at uses the frame it is given and resolves every row through drop-destination, with the destination deciding what is created: a clip of a palette track is a palette symbol, a clip of any other lane is blank. A row with no path of its own resolves to the symbol it names, which is what lets a palette track -- hanging off its owner by :palette-track rather than placed in it -- be reached without a special case; it also stops a palette cel's slide resolving against the open symbol and looking like a transfer out of the track. The one thing left that knows about palettes is materializing the lane a palette row names before anything asks where the row leads. Co-Authored-By: Claude Opus 5 --- frontend/src/arthur/events/ui.cljs | 68 +++++++++++++--------- frontend/src/arthur/ui/timeline.cljs | 16 +++-- frontend/test/arthur/events/lane_test.cljs | 41 +++++++++++-- 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index c6999f0..0a8e829 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -760,8 +760,13 @@ (nil? target) [] (= :instance (get-in document [:symbols sid :nodes id :kind])) path0 :else (vec (butlast path0))) + ;; A ROW WITH NO PATH IS THE SYMBOL IT NAMES, on this very frame: the + ;; open symbol's own row, and a palette track's, which hangs off its + ;; owner by `:palette-track` instead of being placed in it and so is + ;; reached by naming it rather than by walking to it. + from (if (seq path) open (or sid open)) {destination :sid at :frame matrix :matrix} - (nest/inside document st open path frame)] + (nest/inside document st from path frame)] (cond (nil? destination) {:refused "what you are dropping into is not on screen at this frame"} (not (integer? at)) {:refused "the drop is not on one frame of that symbol"} @@ -802,34 +807,45 @@ (edit/transaction (constantly (:clip result))) (selected [:node sid uuid (conj (vec path) uuid)])))) +(defn- lane-of-palette-row + "`document` and `target` with the palette row of a symbol that has no palette + track yet replaced by that track, created here. + + THE ONE ROW THAT MAY NAME A LANE THAT DOES NOT EXIST, and the only thing + anything downstream needs to know about palettes: materializing it turns that + row into an ordinary lane row, so one gesture resolves and places through one + rule. The track borrows its owner's clock because it is read frame for frame + against it." + [document target] + (if-let [owner (when (= :arthur.ui.timeline/palette-track (first target)) + (second target))] + (let [track (or (get-in document [:symbols owner :palette-track]) + (clip/fresh-id document))] + [(-> document + (assoc-in [:symbols owner :palette-track] track) + ;; What is there already wins, so this fills a track in and can never + ;; empty one. + (update-in [:symbols track] + #(merge {:id track :name "palette" :type :palette-track + :display :lane :frames (clip/frames document owner) + :fps (clip/fps document owner) :nodes {}} + %))) + [:node track nil []]]) + [document target])) + (rf/reg-event-db ::new-symbol-at - ;; One gesture and one creation path for every lane. The destination decides - ;; the kind: an ordinary lane gets a blank symbol; the synthetic palette row - ;; gets a blank palette symbol whose placement starts by inheriting. - (fn [db [_ _pointer-frame target]] + ;; ONE GESTURE AND ONE CREATION PATH FOR EVERY LANE. Empty space in a lane + ;; takes a new one-frame symbol at the frame double-clicked -- the pointer + ;; names a place in time as well as a lane, which is the whole of the gesture + ;; and the reason it is not the playhead's. The DESTINATION decides what is + ;; created there: a clip of a palette track is a palette symbol whose + ;; placement starts by inheriting, a clip of any other lane is a blank symbol. + (fn [db [_ frame target]] (let [{document :clip st :store} (store/entry (:clip/current db)) - ;; Creation always happens at the playhead. The double-click only - ;; names the lane; it is not a second, pointer-based time cursor. - frame (editing-frame db document) - palette? (= :arthur.ui.timeline/palette-track (first target)) - root-sid (second target) - root (when palette? (clip/symbol document root-sid)) - old-track (:palette-track root) - track-id (when palette? - (if (= :palette-track (get-in document [:symbols old-track :type])) - old-track (clip/fresh-id document))) - document (if (and palette? (not= track-id old-track)) - (-> document - (assoc-in [:symbols root-sid :palette-track] track-id) - (assoc-in [:symbols track-id] - {:id track-id :name "palette" :type :palette-track - :display :lane :frames (:frames root) - :fps (clip/fps document root-sid) :nodes {}})) - document) - where (if palette? - {:clip document :sid track-id :at frame :path []} - (drop-destination db document st frame target)) + [document target] (lane-of-palette-row document target) + where (drop-destination db document st frame target) + palette? (= :palette-track (get-in document [:symbols (:sid where) :type])) sid (clip/fresh-id document) uuid (random-uuid)] (if (:refused where) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index d6287ca..544e76b 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -336,17 +336,25 @@ (walk sid [] 0 identity) []))))) -(defn- palette-track-row [clip sid frames expanded chosen] +(defn palette-track-row + "`sid`'s palette row: ONE LANE ROW WHETHER OR NOT THE LANE EXISTS YET, because + a person putting a palette transition somewhere is doing the same thing in + either case. Empty, it names the symbol whose palette it would be; filled, it + is the lane symbol's own row under this pane's name for it." + [clip sid frames expanded chosen] (let [track-id (get-in clip [:symbols sid :palette-track]) track (get-in clip [:symbols track-id])] (if (= :palette-track (:type track)) + ;; A MATERIALIZED TRACK IS AN ORDINARY LANE ROW, selection and all: it is + ;; the row `rows` already draws for a lane symbol, wearing this pane's + ;; name for it. Only the row of a track that does not exist yet has to say + ;; whose palette it would be, which is what creating in it needs. (-> (first (rows clip track-id expanded chosen)) (assoc :path [::palette-track] :owner track-id :label "palette" :kind :palette - :expandable? false :expanded? false - :select [::palette-track sid])) + :expandable? false :expanded? false)) {:path [::palette-track] :depth 0 :label "palette" :kind :palette - :owner nil :select [::palette-track sid] + :lane? true :owner nil :select [::palette-track sid] :span [0 frames] :keys [] :cels []}))) (defn sound-rows diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index b4b3783..c523827 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -72,11 +72,14 @@ (vals (get-in saved [:symbols :main :nodes])))) "claiming the frame leaves no overlapping cel")))) -(deftest double-click-creation-uses-the-lane-and-playhead +(deftest double-click-creation-uses-the-lane-and-the-frame-double-clicked (let [doc (fixture/document) id (store/install! {:clip doc :store {}} "double-click-new-symbol")] + ;; THE PLAYHEAD IS SOMEWHERE ELSE, deliberately: the gesture names a frame + ;; as well as a lane, and the empty space double-clicked is where the new + ;; symbol goes. (reset! rf-db/app-db {:clip/current id :paint/revision 0 - :ui {:open :main} :playback {:frame 5}}) + :ui {:open :main} :playback {:frame 9}}) (rf/dispatch-sync [::ui/new-symbol-at 5 [:node :main nil []]]) (let [saved (:clip (store/entry id)) [_ sid instance-id] (get-in @rf-db/app-db [:ui :selection]) @@ -88,14 +91,28 @@ (is (= (node/source instance) (:sid (creation/target saved {} :main selection 5))) "the new cel can immediately be selected as the creation target") - (is (= 5 (get-in @rf-db/app-db [:playback :frame])) - "the playhead chooses the new cel's time")))) + (is (= 9 (get-in @rf-db/app-db [:playback :frame])) + "creating in a lane does not move the playhead")))) + +(deftest the-palette-row-is-a-lane-row-whether-or-not-its-track-exists + (let [empty-row (timeline/palette-track-row (clip/blank) :main 120 #{} nil) + filled (-> (clip/blank) + (assoc-in [:symbols :main :palette-track] :track) + (assoc-in [:symbols :track] + {:id :track :type :palette-track :display :lane + :frames 120 :nodes {}})) + filled-row (timeline/palette-track-row filled :main 120 #{} nil)] + ;; The double-click that creates in a lane asks nothing else of a row. + (is (:lane? empty-row)) + (is (:lane? filled-row)) + (is (= [:node :track nil []] (:select filled-row)) + "a track that exists is addressed as the lane it is"))) (deftest the-same-double-click-command-creates-a-palette-symbol-on-the-palette-row (let [doc (clip/blank) id (store/install! {:clip doc :store {}} "double-click-palette-symbol")] (reset! rf-db/app-db {:clip/current id :paint/revision 0 - :ui {:open :main} :playback {:frame 5}}) + :ui {:open :main} :playback {:frame 9}}) (rf/dispatch-sync [::ui/new-symbol-at 5 [:arthur.ui.timeline/palette-track :main]]) (let [saved (:clip (store/entry id)) @@ -108,7 +125,19 @@ (is (= :palette (get-in saved [:symbols source :type]))) (is (= [5 6] (node/placed-span instance))) (is (= pal/inherit - (get-in instance [:channels [:palette] :value])))))) + (get-in instance [:channels [:palette] :value]))) + ;; ONCE THE TRACK EXISTS ITS ROW IS AN ORDINARY LANE ROW, which is the + ;; address the pane then hands back: the second transition is created by + ;; the same command resolving the same way as any other lane's. + (rf/dispatch-sync [::ui/new-symbol-at 20 [:node track-id nil []]]) + (let [saved (:clip (store/entry id)) + [_ sid2 id2] (get-in @rf-db/app-db [:ui :selection]) + second-clip (get-in saved [:symbols sid2 :nodes id2])] + (is (= track-id sid2) "it lands in the same palette track") + (is (= :palette (get-in saved [:symbols (node/source second-clip) :type]))) + (is (= [20 21] (node/placed-span second-clip))) + (is (= pal/inherit + (get-in second-clip [:channels [:palette] :value]))))))) (deftest a-new-lane-uses-the-symbol-selected-at-the-playhead (let [doc (clip/blank)