Solo an instance from its timeline row, shift-click for more than one

A nested mark is now named by the flat path its row has, [a b mark] rather
than [a [b mark]], so a soloed row is a prefix of what it shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-30 01:44:24 -04:00
parent 7aaf0a15bd
commit da7e293814
8 changed files with 63 additions and 7 deletions

View file

@ -135,12 +135,14 @@
:symbols {:main {:id :main :frames blank-frames :nodes {}}}}) :symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn- transform-op (defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space." "Put a symbol's already resolved mark into its instance's parent space. Its
name becomes its path of instances down to it, the path its timeline row has."
[op m path] [op m path]
(let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4)) (let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4))
(+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))]) (+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))])
scale (node/mean-scale m) scale (node/mean-scale m)
op (assoc op :node (conj path (:node op)))] n (:node op)
op (assoc op :node (if (vector? n) (into path n) (conj path n)))]
(case (:kind op) (case (:kind op)
:poly (let [out (js/Float64Array. (.-length (:pts op)))] :poly (let [out (js/Float64Array. (.-length (:pts op)))]
(dotimes [i (:n op)] (dotimes [i (:n op)]

View file

@ -25,6 +25,18 @@
(fn [db [_ path]] (fn [db [_ path]]
(update-in db [:ui :expanded] #(if (contains? % path) (disj % path) (conj % path))))) (update-in db [:ui :expanded] #(if (contains? % path) (disj % path) (conj % path)))))
(rf/reg-event-db
::solo
(fn [db [_ path more?]]
;; Per open symbol, because a row path only means something from the symbol
;; it was walked from. A click solos that row alone, or un-solos it if it
;; already was; a shift-click adds it to or takes it out of the ones soloed.
(update-in db [:ui :solo (get-in db [:ui :open])]
(fn [on]
(cond more? (if (contains? on path) (disj on path) (conj (set on) path))
(= on #{path}) #{}
:else #{path})))))
(defn- where-new-goes (defn- where-new-goes
"The row path, from the open symbol down, of the symbol a new thing goes into: "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 INSIDE the selected instance, or BESIDE any other selected node, or at the top

View file

@ -19,6 +19,7 @@
(rf/reg-sub ::open (fn [db _] (get-in db [:ui :open]))) (rf/reg-sub ::open (fn [db _] (get-in db [:ui :open])))
(rf/reg-sub ::sliding (fn [db _] (get-in db [:ui :sliding]))) (rf/reg-sub ::sliding (fn [db _] (get-in db [:ui :sliding])))
(rf/reg-sub ::solo (fn [db _] (get-in db [:ui :solo (get-in db [:ui :open])])))
(rf/reg-sub (rf/reg-sub
::clip ::clip
@ -91,6 +92,12 @@
;; new blocks, the resolver cannot find them, and the frame throws. ;; new blocks, the resolver cannot find them, and the frame throws.
(:store (footage/entry id)))) (:store (footage/entry id))))
(defn- placed?
"Does row `path` from symbol `sid` still name an instance, all the way down?"
[clip sid path]
(reduce (fn [sid id] (or (get-in clip [:symbols sid :nodes id :of]) (reduced nil)))
sid path))
(rf/reg-sub (rf/reg-sub
::resolver ::resolver
:<- [::clip] :<- [::clip]
@ -101,3 +108,26 @@
(fn [[document sid store palette picture-fps] _] (fn [[document sid store palette picture-fps] _]
(when (and document (clip/symbol document sid)) (when (and document (clip/symbol document sid))
(clip/resolver document store palette sid {:picture-fps picture-fps})))) (clip/resolver document store palette sid {:picture-fps picture-fps}))))
(rf/reg-sub
::shown
:<- [::resolver]
:<- [::clip]
:<- [::open]
:<- [::solo]
(fn [[resolve document sid solo] _]
;; What the stage draws: `::resolver`, less whatever is not under a soloed
;; row. Its own layer, so soloing does not rebuild the resolver.
;;
;; A soloed row that has since been deleted or moved would otherwise leave
;; the stage blank, with no row left to un-solo it from.
(let [solo (filter #(placed? document sid %) solo)]
(if (or (nil? resolve) (empty? solo))
resolve
;; An op inside an instance is named by the path its row has; one at the
;; top by its bare id.
(fn [f]
(filterv (fn [{n :node}]
(let [p (if (vector? n) n [n])]
(some #(= % (take (count %) p)) solo)))
(resolve f)))))))

View file

@ -100,7 +100,7 @@
(reset! tracker (reset! tracker
(ratom/run! (ratom/run!
(let [was (:resolver @snapshot) (let [was (:resolver @snapshot)
now @(rf/subscribe [::render/resolver])] now @(rf/subscribe [::render/shown])]
(reset! snapshot (reset! snapshot
{:resolver now {:resolver now
:palette @(rf/subscribe [::render/palette]) :palette @(rf/subscribe [::render/palette])

View file

@ -204,7 +204,7 @@
(cond (< y 0.3) :front (> y 0.7) :back :else :into))) (cond (< y 0.3) :front (> y 0.7) :back :else :into)))
(defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded? of]} (defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded? of]}
selection over] selection over solo]
(let [node? (= :node kind) (let [node? (= :node kind)
[over-path where] @over] [over-path where] @over]
[:div (cond-> {:class (str "tl-label" (when (and select (= select selection)) " on") [:div (cond-> {:class (str "tl-label" (when (and select (= select selection)) " on")
@ -260,6 +260,13 @@
(when expandable? (if expanded? "▾" "▸"))] (when expandable? (if expanded? "▾" "▸"))]
[:span.name label] [:span.name label]
(when node? [:span.kind (str "·" (name node-kind))]) (when node? [:span.kind (str "·" (name node-kind))])
(when (= :instance node-kind)
[:button {:class (str "tl-solo" (when (contains? solo path) " on"))
:title "show only this on the stage (⇧ for more than one)"
:on-click (fn [^js e]
(.stopPropagation e)
(rf/dispatch [::ui/solo path (.-shiftKey e)]))}
"S"])
(when (and node? (= select selection)) (when (and node? (= select selection))
[:button.tl-delete {:title "delete, with everything in it (⌫)" [:button.tl-delete {:title "delete, with everything in it (⌫)"
:on-click (fn [^js e] :on-click (fn [^js e]
@ -329,6 +336,7 @@
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
expanded @(rf/subscribe [::sub/expanded]) expanded @(rf/subscribe [::sub/expanded])
drop @(rf/subscribe [::sub/drop]) drop @(rf/subscribe [::sub/drop])
solo (set @(rf/subscribe [::render/solo]))
;; Where a drag out of the pool would land, as a row of its own at the ;; Where a drag out of the pool would land, as a row of its own at the
;; top: its own length, starting on the frame it would start on. The ;; top: its own length, starting on the frame it would start on. The
;; stage's drop shows it too, at the playhead. ;; stage's drop shows it too, at the playhead.
@ -357,7 +365,7 @@
(rf/dispatch [::ui/move-node from []]))))} (rf/dispatch [::ui/move-node from []]))))}
[:div.tl-corner] [:div.tl-corner]
(doall (for [row visible] (doall (for [row visible]
^{:key (str (:path row))} [label-cell row selection over]))] ^{:key (str (:path row))} [label-cell row selection over solo]))]
[:div.tl-tracks [:div.tl-tracks
{:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) {:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e)))
:on-drag-over (fn [^js e] :on-drag-over (fn [^js e]

View file

@ -63,7 +63,7 @@
(turn :main u [40 20] (/ js/Math.PI 2) 2) (turn :main u [40 20] (/ js/Math.PI 2) 2)
(turn :mid v [5 -3] 0.3 1.5) (turn :mid v [5 -3] 0.3 1.5)
(paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow)) (paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow))
draw #(take 6 (array-seq (:pts (first (filter (fn [op] (= [u [v :shape]] (:node op))) draw #(take 6 (array-seq (:pts (first (filter (fn [op] (= [u v :shape] (:node op)))
((clip/resolver % nil pal/index-of :main) 16)))))) ((clip/resolver % nil pal/index-of :main) 16))))))
{:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16) {:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16)
out (js/Float64Array. 2) out (js/Float64Array. 2)

View file

@ -134,7 +134,7 @@
(let [before (snapshot (:clip entry) (:store entry) f) (let [before (snapshot (:clip entry) (:store entry) f)
after (snapshot (:clip back) (:store back) f)] after (snapshot (:clip back) (:store back) f)]
(is (seq before)) (is (seq before))
(is (some #(= [:face-2 :mouth] (second (:node %))) before))) (is (some #(= [:face-2 :mouth] (rest (:node %))) before)))
(is (= (snapshot (:clip entry) (:store entry) f) (is (= (snapshot (:clip entry) (:store entry) f)
(snapshot (:clip back) (:store back) f)))))) (snapshot (:clip back) (:store back) f))))))

View file

@ -646,6 +646,10 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.tl-twist:disabled { opacity: 0; cursor: default; } .tl-twist:disabled { opacity: 0; cursor: default; }
.tl-delete { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); } .tl-delete { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); }
.tl-delete:hover { color: var(--fg); } .tl-delete:hover { color: var(--fg); }
.tl-solo { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); font-size: 10px; }
.tl-solo:hover { color: var(--fg); }
.tl-solo.on { color: var(--sel); font-weight: 600; }
.tl-solo + .tl-delete { margin-left: 0; }
/* The ruler and the corner above the labels stay at the top of the body while /* The ruler and the corner above the labels stay at the top of the body while
the rows scroll under them: the ruler is the scrubber, and scrolling down to the rows scroll under them: the ruler is the scrubber, and scrolling down to