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 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-03 03:52:39 -04:00
parent 09b74de162
commit 90b1fbe2f8
3 changed files with 89 additions and 36 deletions

View file

@ -760,8 +760,13 @@
(nil? target) [] (nil? target) []
(= :instance (get-in document [:symbols sid :nodes id :kind])) path0 (= :instance (get-in document [:symbols sid :nodes id :kind])) path0
:else (vec (butlast 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} {destination :sid at :frame matrix :matrix}
(nest/inside document st open path frame)] (nest/inside document st from path frame)]
(cond (cond
(nil? destination) {:refused "what you are dropping into is not on screen at this frame"} (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"} (not (integer? at)) {:refused "the drop is not on one frame of that symbol"}
@ -802,34 +807,45 @@
(edit/transaction (constantly (:clip result))) (edit/transaction (constantly (:clip result)))
(selected [:node sid uuid (conj (vec path) uuid)])))) (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 (rf/reg-event-db
::new-symbol-at ::new-symbol-at
;; One gesture and one creation path for every lane. The destination decides ;; ONE GESTURE AND ONE CREATION PATH FOR EVERY LANE. Empty space in a lane
;; the kind: an ordinary lane gets a blank symbol; the synthetic palette row ;; takes a new one-frame symbol at the frame double-clicked -- the pointer
;; gets a blank palette symbol whose placement starts by inheriting. ;; names a place in time as well as a lane, which is the whole of the gesture
(fn [db [_ _pointer-frame target]] ;; 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)) (let [{document :clip st :store} (store/entry (:clip/current db))
;; Creation always happens at the playhead. The double-click only [document target] (lane-of-palette-row document target)
;; names the lane; it is not a second, pointer-based time cursor. where (drop-destination db document st frame target)
frame (editing-frame db document) palette? (= :palette-track (get-in document [:symbols (:sid where) :type]))
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))
sid (clip/fresh-id document) sid (clip/fresh-id document)
uuid (random-uuid)] uuid (random-uuid)]
(if (:refused where) (if (:refused where)

View file

@ -336,17 +336,25 @@
(walk sid [] 0 identity) (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]) (let [track-id (get-in clip [:symbols sid :palette-track])
track (get-in clip [:symbols track-id])] track (get-in clip [:symbols track-id])]
(if (= :palette-track (:type track)) (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)) (-> (first (rows clip track-id expanded chosen))
(assoc :path [::palette-track] :owner track-id (assoc :path [::palette-track] :owner track-id
:label "palette" :kind :palette :label "palette" :kind :palette
:expandable? false :expanded? false :expandable? false :expanded? false))
:select [::palette-track sid]))
{:path [::palette-track] :depth 0 :label "palette" :kind :palette {: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 []}))) :span [0 frames] :keys [] :cels []})))
(defn sound-rows (defn sound-rows

View file

@ -72,11 +72,14 @@
(vals (get-in saved [:symbols :main :nodes])))) (vals (get-in saved [:symbols :main :nodes]))))
"claiming the frame leaves no overlapping cel")))) "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) (let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "double-click-new-symbol")] 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 (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 []]]) (rf/dispatch-sync [::ui/new-symbol-at 5 [:node :main nil []]])
(let [saved (:clip (store/entry id)) (let [saved (:clip (store/entry id))
[_ sid instance-id] (get-in @rf-db/app-db [:ui :selection]) [_ sid instance-id] (get-in @rf-db/app-db [:ui :selection])
@ -88,14 +91,28 @@
(is (= (node/source instance) (is (= (node/source instance)
(:sid (creation/target saved {} :main selection 5))) (:sid (creation/target saved {} :main selection 5)))
"the new cel can immediately be selected as the creation target") "the new cel can immediately be selected as the creation target")
(is (= 5 (get-in @rf-db/app-db [:playback :frame])) (is (= 9 (get-in @rf-db/app-db [:playback :frame]))
"the playhead chooses the new cel's time")))) "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 (deftest the-same-double-click-command-creates-a-palette-symbol-on-the-palette-row
(let [doc (clip/blank) (let [doc (clip/blank)
id (store/install! {:clip doc :store {}} "double-click-palette-symbol")] id (store/install! {:clip doc :store {}} "double-click-palette-symbol")]
(reset! rf-db/app-db {:clip/current id :paint/revision 0 (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 (rf/dispatch-sync [::ui/new-symbol-at 5
[:arthur.ui.timeline/palette-track :main]]) [:arthur.ui.timeline/palette-track :main]])
(let [saved (:clip (store/entry id)) (let [saved (:clip (store/entry id))
@ -108,7 +125,19 @@
(is (= :palette (get-in saved [:symbols source :type]))) (is (= :palette (get-in saved [:symbols source :type])))
(is (= [5 6] (node/placed-span instance))) (is (= [5 6] (node/placed-span instance)))
(is (= pal/inherit (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 (deftest a-new-lane-uses-the-symbol-selected-at-the-playhead
(let [doc (clip/blank) (let [doc (clip/blank)