diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index ffa6d1c..8681331 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -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] diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index f318ef0..a814b87 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -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)) diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 5c7335c..7b2d49f 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -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. diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index aad1f96..ca8cddd 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -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))))))) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 01fef01..26f899b 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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 diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index 7f820b0..d58b792 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -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")))