New empty symbol, nested in the selected instance or the open symbol

'+ symbol' in the timeline makes an empty symbol and places it at the
playhead: inside the selected instance, beside any other selected node, or in
the open symbol. Timeline selections carry their row path so a symbol placed
twice nests into the row that was clicked. Symbols gain a :name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:49:46 -04:00
parent 5dff490162
commit 2d2eb0fc9f
6 changed files with 114 additions and 14 deletions

View file

@ -53,6 +53,11 @@
[clip sid]
(get-in clip [:symbols sid]))
(defn symbol-name
"What to call a symbol: its `:name`, or its id when it has none."
[clip sid]
(or (:name (symbol clip sid)) (name sid)))
(defn frames
"A symbol's length. Read off the symbol, never copied beside it."
[clip sid]
@ -117,7 +122,7 @@
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn place-symbol
"An instance of symbol `sid`, inside symbol `into`, at `frame` of `into`.
"An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`.
THE UUID IS AN ARGUMENT. A placement's identity is the key it has in the node
map — it is what `:linked-to`, an export target and a saved leaf all name — so
@ -125,21 +130,21 @@
called, and this namespace is the pure one.
The instance's own time starts where it was dropped: `:at frame` with `:in 0`
means local frame 0 of the symbol plays on `frame` of `into`, which is what
means local frame 0 of the symbol plays on `frame` of `host`, which is what
dragging something onto a playhead is asking for.
Refused, returning the clip unchanged, when it would make a cycle: a symbol
cannot be placed inside itself or inside anything it places."
[clip into sid frame uuid [x y]]
[clip host sid frame uuid [x y]]
(let [target (symbol clip sid)
end (frames clip into)]
end (frames clip host)]
(if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
(contains-symbol? clip sid into))
(contains-symbol? clip sid host))
clip
(update-symbol
clip into assoc-in [:nodes uuid]
clip host assoc-in [:nodes uuid]
{:id uuid
:name (name sid)
:name (symbol-name clip sid)
:kind :instance
:of sid
:parent nil
@ -150,6 +155,39 @@
:time {:mode :map :at frame :in 0 :rate 1}
:channels {[:xform :pos] {:animated? false :value [x y]}}}))))
(defn fresh-id
"The first `:symbol-N` the clip does not already hold. Readable because an id
shows up in saved leaf paths, and deterministic because this namespace is pure."
[clip]
(first (remove (:symbols clip) (map #(keyword (str "symbol-" %)) (iterate inc 1)))))
(defn new-symbol
"A new, empty symbol `sid`, placed inside `host` at `frame` and running to the
end of it. Placed at the origin, so whatever is drawn into it lands where it was
drawn until the instance is moved."
[clip host sid frame uuid]
(let [end (frames clip host)]
(if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end))
clip
(-> clip
(assoc-in [:symbols sid] {:id sid :name (name sid) :frames (- end frame) :nodes {}})
(place-symbol host sid frame uuid [0 0])))))
(defn frame-inside
"Carry frame `f` of symbol `sid` down through the nodes named by `path`, one
per level, the way a timeline row's path names them. Returns `[symbol frame]`:
the symbol the last node places, and the frame it is showing.
Each step applies the node's own time map and its ancestors' in that symbol,
outermost first, which is the order `symbol/eval-frame` composes them in."
[clip sid path f]
(reduce (fn [[sid f] id]
(let [nodes (:nodes (symbol clip sid))
f (reduce #(node/local-frame (get nodes %2) %1)
f (rseq (symbol/lineage nodes id)))]
[(:of (get nodes id)) (js/Math.floor f)]))
[sid f] path))
(defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space."
[op m path]

View file

@ -145,7 +145,7 @@
;; disagree with itself about; `clip` puts it back.
(for [[sid sym] (:symbols clip)]
{(at "symbol" (segment sid))
(select-keys sym [:frames :palette])})
(select-keys sym [:name :frames :palette])})
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)]
{(at "symbol" (segment sid) "node" (segment id))

View file

@ -514,8 +514,11 @@
a ramp belongs — `domain/symbol` takes the palette as a PARAMETER rather than
reaching for a global precisely so that a nested symbol can carry its own —
and leaving the field out would make the first one a migration instead of a
write."
#{:id :frames :nodes :palette})
write.
`:name` is what a person calls it, and is not its id: an id is what instances
and saved leaves point at, so renaming a symbol must not change it."
#{:id :name :frames :nodes :palette})
(defn problems
"Human-readable reasons this symbol will not evaluate. Empty means it will.

View file

@ -8,6 +8,7 @@
(:require [arthur.domain.clip :as clip]
[arthur.events.edit :as edit]
[arthur.events.paint :as paint]
[arthur.footage.store :as store]
[re-frame.core :as rf]))
(rf/reg-event-db
@ -75,7 +76,39 @@
::place-symbol
(fn [db [_ sid [x y]]]
(let [uuid (random-uuid)
into (get-in db [:ui :open])]
host (get-in db [:ui :open])]
(-> db
(edit/edit #(clip/place-symbol % into sid (get-in db [:playback :frame]) uuid [x y]))
(assoc-in [:ui :selection] [:node into uuid])))))
(edit/edit #(clip/place-symbol % host sid (get-in db [:playback :frame]) uuid [x y]))
(assoc-in [:ui :selection] [:node host uuid])))))
(defn- where-new-goes
"The row path, from the open symbol down, of the symbol a new thing goes into:
INSIDE the selected instance, or BESIDE any other selected node, or at the top
of the open symbol when nothing is selected.
A selection from a timeline row carries that row's path, because one symbol
placed twice is two rows and only the path says which was clicked. One made on
the stage does not, and names a node directly in the open symbol."
[clip db]
(let [[kind sid id path] (get-in db [:ui :selection])
path (when (= :node kind) (or path [id]))]
(cond
(nil? path) []
(= :instance (get-in clip [:symbols sid :nodes id :kind])) path
:else (pop path))))
(rf/reg-event-db
::new-symbol
(fn [db _]
(let [clip (:clip (store/entry (:clip/current db)))
down (where-new-goes clip db)
[host frame] (clip/frame-inside clip (get-in db [:ui :open]) down
(get-in db [:playback :frame]))
sid (clip/fresh-id clip)
uuid (random-uuid)]
(-> db
(edit/edit #(clip/new-symbol % host sid frame uuid))
(assoc-in [:ui :selection] [:node host uuid (conj down uuid)])
;; Open every row down to it, or the new row is inside a closed one and
;; the button looks like it did nothing.
(update-in [:ui :expanded] into (rest (reductions conj [] down)))))))

View file

@ -113,7 +113,7 @@
:label (node-label id n)
:kind :node
:node-kind (:kind n)
:select [:node sid id]
:select [:node sid id rpath]
:expandable? true
:expanded? open?
:span span
@ -176,6 +176,9 @@
[:button {:class (when (== r rate) "on")
:on-click #(rf/dispatch [::pb/set-rate r])}
(case r 1.0 "1x" 0.5 "½" 0.25 "¼" 2.0 "2x" 4.0 "4x" (str r))]))
[:button {:title "a new empty symbol inside the selected instance, or beside the selected node, or in the open symbol"
:on-click #(rf/dispatch [::ui/new-symbol])}
"+ symbol"]
[:span.spacer]
[:span.dim (str frame " / " frames)]
;; Measured in the loop, not derived from the clock — the whole question

View file

@ -234,3 +234,26 @@
(is (empty? (clip/problems c)))
(is (= (get-in c [:symbols :outer :nodes])
(get-in (leaf/clip "c" (leaf/leaves "c" c)) [:symbols :outer :nodes]))))))
(deftest a-new-symbol-is-empty-and-placed-where-it-was-asked-for
(let [c (nested)
u #uuid "00000000-0000-4000-8000-000000000001"
id (clip/fresh-id c)
made (clip/new-symbol c :outer id 20 u)]
(is (= :symbol-1 id))
(is (= :symbol-2 (clip/fresh-id made)) "the next one does not collide")
(is (= {:id :symbol-1 :name "symbol-1" :frames 180 :nodes {}}
(clip/symbol made :symbol-1))
"empty, and as long as the rest of what it was placed in")
(is (= {:of :symbol-1 :span [20 200] :time {:mode :map :at 20 :in 0 :rate 1}}
(select-keys (get-in made [:symbols :outer :nodes u]) [:of :span :time])))
(is (empty? (clip/problems made)))
(is (= c (clip/new-symbol c :outer :inner 0 u)) "an id already in use is refused")
(is (= c (clip/new-symbol c :outer id 200 u)) "past the end is refused")))
(deftest a-frame-is-carried-down-through-the-instances-a-row-path-names
(let [c (nested)
[id] (keys (get-in c [:symbols :outer :nodes]))]
(is (= [:outer 12] (clip/frame-inside c :outer [] 12)))
(is (= [:inner 7] (clip/frame-inside c :outer [id] 12))
"the instance starts at 5, so frame 12 outside is frame 7 inside")))