From da7e293814376b42e968c87054d0e4d80ace55ba Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Wed, 30 Sep 2026 01:44:24 -0400 Subject: [PATCH] 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 --- frontend/src/arthur/domain/clip.cljs | 6 ++-- frontend/src/arthur/events/ui.cljs | 12 ++++++++ frontend/src/arthur/subs/render.cljs | 30 +++++++++++++++++++ frontend/src/arthur/ui/player.cljs | 2 +- frontend/src/arthur/ui/timeline.cljs | 12 ++++++-- frontend/test/arthur/domain/nest_test.cljs | 2 +- .../test/arthur/flow/multi_face_test.cljs | 2 +- static/arthur/app.css | 4 +++ 8 files changed, 63 insertions(+), 7 deletions(-) diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index c7b93c3..e854ca2 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -135,12 +135,14 @@ :symbols {:main {:id :main :frames blank-frames :nodes {}}}}) (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] (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))]) 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) :poly (let [out (js/Float64Array. (.-length (:pts op)))] (dotimes [i (:n op)] diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index ca9e43c..9fcaed2 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -25,6 +25,18 @@ (fn [db [_ 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 "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 diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 60f30c3..a645fde 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -19,6 +19,7 @@ (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 ::solo (fn [db _] (get-in db [:ui :solo (get-in db [:ui :open])]))) (rf/reg-sub ::clip @@ -91,6 +92,12 @@ ;; new blocks, the resolver cannot find them, and the frame throws. (: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 ::resolver :<- [::clip] @@ -101,3 +108,26 @@ (fn [[document sid store palette picture-fps] _] (when (and document (clip/symbol document sid)) (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))))))) diff --git a/frontend/src/arthur/ui/player.cljs b/frontend/src/arthur/ui/player.cljs index 63956c3..9ac0ad8 100644 --- a/frontend/src/arthur/ui/player.cljs +++ b/frontend/src/arthur/ui/player.cljs @@ -100,7 +100,7 @@ (reset! tracker (ratom/run! (let [was (:resolver @snapshot) - now @(rf/subscribe [::render/resolver])] + now @(rf/subscribe [::render/shown])] (reset! snapshot {:resolver now :palette @(rf/subscribe [::render/palette]) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 35b9981..22e190b 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -204,7 +204,7 @@ (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]} - selection over] + selection over solo] (let [node? (= :node kind) [over-path where] @over] [:div (cond-> {:class (str "tl-label" (when (and select (= select selection)) " on") @@ -260,6 +260,13 @@ (when expandable? (if expanded? "▾" "▸"))] [:span.name label] (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)) [:button.tl-delete {:title "delete, with everything in it (⌫)" :on-click (fn [^js e] @@ -329,6 +336,7 @@ selection @(rf/subscribe [::sub/selection]) expanded @(rf/subscribe [::sub/expanded]) 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 ;; top: its own length, starting on the frame it would start on. The ;; stage's drop shows it too, at the playhead. @@ -357,7 +365,7 @@ (rf/dispatch [::ui/move-node from []]))))} [:div.tl-corner] (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 {:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) :on-drag-over (fn [^js e] diff --git a/frontend/test/arthur/domain/nest_test.cljs b/frontend/test/arthur/domain/nest_test.cljs index ebc313b..bb3f70f 100644 --- a/frontend/test/arthur/domain/nest_test.cljs +++ b/frontend/test/arthur/domain/nest_test.cljs @@ -63,7 +63,7 @@ (turn :main u [40 20] (/ js/Math.PI 2) 2) (turn :mid v [5 -3] 0.3 1.5) (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)))))) {:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16) out (js/Float64Array. 2) diff --git a/frontend/test/arthur/flow/multi_face_test.cljs b/frontend/test/arthur/flow/multi_face_test.cljs index bb34d1b..3902b12 100644 --- a/frontend/test/arthur/flow/multi_face_test.cljs +++ b/frontend/test/arthur/flow/multi_face_test.cljs @@ -134,7 +134,7 @@ (let [before (snapshot (:clip entry) (:store entry) f) after (snapshot (:clip back) (:store back) f)] (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) (snapshot (:clip back) (:store back) f)))))) diff --git a/static/arthur/app.css b/static/arthur/app.css index 0808ea3..c3114e6 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -646,6 +646,10 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-twist:disabled { opacity: 0; cursor: default; } .tl-delete { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); } .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 rows scroll under them: the ruler is the scrubber, and scrolling down to