diff --git a/.claude/worktrees/tracing-layers b/.claude/worktrees/tracing-layers new file mode 160000 index 0000000..f4dd047 --- /dev/null +++ b/.claude/worktrees/tracing-layers @@ -0,0 +1 @@ +Subproject commit f4dd04764204506fc275180364d0366d693036f4 diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index 6bd341b..c61f6af 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -57,8 +57,9 @@ THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at the stage, like solo and zoom: not an undo step, not sent to collaborators, and it cannot reach an export. A layer is shown unless it is in `:hidden`, so - footage brought in shows without being found and switched on first." - {:on? true :opacity 0.5 :hidden #{}}) + footage brought in shows without being found and switched on first — once + tracing itself is on, which it is not until asked for." + {:on? false :opacity 0.5 :hidden #{}}) (def default {;; --- the document --- diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 7f83e45..04df444 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -25,13 +25,14 @@ another clip is `arthur.domain.bring`. This namespace is the document and the operations that only need the document. - NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which - symbol is on screen is the editor's state, not the document's, and every - function here that needs a symbol is told which. A new document has one symbol - called `:main` because it has to be called something, and that is all the name - means — it can be renamed, placed inside another symbol or deleted like any of - them. `unplaced` answers the question a reserved root used to: which symbols - nothing else places, and so which ones a person opening the document wants. + NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen + is the editor's state, not the document's, and every function here that needs + a symbol is told which. A new document has one symbol called `:main` because + it has to be called something, and that is all the name means — it can be + renamed or placed inside another symbol like any of them. What the document + does say is `:root`, which symbol it opens on: a pointer, not a kind of + symbol, the way a Flash file names its scene. See `opens-on` for why that + cannot be worked out instead. :fps is the output grid. A symbol's optional :fps names the native grid its frames were authored or measured on; absent means the document's grid." @@ -52,7 +53,7 @@ layer must not be able to have. Add the field here and to `leaf/leaves` and `leaf/clip` in the same commit." #{:name :fps :analyses :subjects :features :groups :width :height :symbols - :palettes :default-palette}) + :palettes :default-palette :root}) (defn symbol "One of the clip's symbols, by id." @@ -201,18 +202,39 @@ (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] (vec (sort-by str (remove placed (keys (:symbols clip))))))) -(defn opens-on - "The symbol a document opens on: the longest one nothing else places, ties - broken by id. The symbol that contains everything else is the longest of the - unplaced ones in every document made so far, and a reserved name is what this - replaces. - - Never a tracing symbol: it is footage nobody has placed yet, and as long as - the take it came from, so it would otherwise win." +(defn- longest-unplaced + "The longest symbol nothing else places, ties broken by id, and never a + tracing symbol: that is footage nobody has placed yet, as long as its take." [clip] (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) (remove #(trace? (symbol clip %)) (unplaced clip))))) +(defn opens-on + "The symbol a document opens on: its `:root`. + + IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on + the theory that the one containing everything else is always that. A take + disproves it: imported footage is a symbol a thousand frames long, and the + moment its instance is deleted, or the drop lands somewhere other than the + root, nothing places it and it outranks a 120-frame `:main`. The document then + opened on the take, and `set-root-fps` rewrote the take's rate to the + project's on the way in. + + A document with no `:root` — a demo, a fixture — still gets the old answer." + [clip] + (let [root (:root clip)] + (if (contains? (:symbols clip) root) root (longest-unplaced clip)))) + +(defn pin-root + "Give a document saved before `:root` existed the root it was made with. + Every such document started as `blank`, whose root is `:main`, and ids never + change, so `:main` is the answer wherever it survives; the old rule is only + for documents that never had one." + [clip] + (cond-> clip + (not (:root clip)) + (assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip))))) + (defn set-root-fps "Set the document/output rate and the root symbol's editing rate together. @@ -250,6 +272,7 @@ :width 320 :height 200 :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id + :root :main ;; No native fps yet: an untouched canvas follows the project grid. Imported ;; and generated symbols carry their own rate explicitly. :symbols {:main {:id :main :frames blank-frames :nodes {}}}}) @@ -351,6 +374,58 @@ (active-palette [this] "The palette selected by this resolver's most recently resolved frame.")) +(defn- channel-value [store selection frame] + (cond + (nil? selection) nil + (and (map? selection) (contains? selection :animated?)) + (ch/value-at selection frame store) + :else selection)) + +(defn palette-at + "The palette symbol `owner` draws in at `frame`, as the stage's root does: a + palette id, or `{:from :to :t}` while its palette lane blends from one to the + next. `inherited` is what an unset palette falls back to, then `default`." + [clip store default owner frame inherited] + ;; A palette track has meaningful uncovered time. Ordinary held + ;; channels clamp to their first key before it, but doing that + ;; here would erase the gap before the first palette segment. + (let [channel-value (partial channel-value store) + fallback (or (channel-value (:palette owner) frame) + inherited default) + materialize (fn [choice] + (cond + (= pal/inherit choice) fallback + (map? choice) (-> choice + (update :from #(if (= pal/inherit %) fallback %)) + (update :to #(if (= pal/inherit %) fallback %))) + :else choice)) + track (:palette-channel owner) + track-value (if-let [ks (:keys track)] + (some->> (keys ks) + (filter #(<= % frame)) + sort last + (get ks)) + (channel-value track frame))] + (or (when-let [track-sid (and (keyword? (:palette-track owner)) + (:palette-track owner))] + (let [track-symbol (symbol clip track-sid) + palette-clip (first + (filter (fn [n] + (let [[a b] (node/placed-span n)] + (and a (<= a frame) (< frame b)))) + (symbol/children (:nodes track-symbol)))) + palette-symbol (some-> palette-clip node/source + (#(symbol clip %))) + fallback (when (= :palette (:type palette-symbol)) + (:palette-ref palette-symbol)) + choice (get-in palette-clip [:channels [:palette]]) + start (some-> palette-clip node/placed-span first)] + (or (when (and choice start) + (materialize (channel-value choice (- frame start)))) + fallback))) + track-value + fallback))) + (defn resolver "Resolve an output frame, selecting native content at each symbol boundary. Every instance owns its cursors and buffers. The IResolver queries return @@ -358,51 +433,8 @@ [clip sid store palette opts] (let [context? (and (map? palette) (:palettes palette) (:offsets palette)) active-palette-state (atom (:default palette))] - (letfn [(channel-value [selection frame] - (cond - (nil? selection) nil - (and (map? selection) (contains? selection :animated?)) - (ch/value-at selection frame store) - :else selection)) - (root-selection-at [owner frame inherited] - ;; A palette track has meaningful uncovered time. Ordinary held - ;; channels clamp to their first key before it, but doing that - ;; here would erase the gap before the first palette segment. - (let [fallback (or (channel-value (:palette owner) frame) - inherited (:default palette)) - materialize (fn [choice] - (cond - (= pal/inherit choice) fallback - (map? choice) (-> choice - (update :from #(if (= pal/inherit %) fallback %)) - (update :to #(if (= pal/inherit %) fallback %))) - :else choice)) - track (:palette-channel owner) - track-value (if-let [ks (:keys track)] - (some->> (keys ks) - (filter #(<= % frame)) - sort last - (get ks)) - (channel-value track frame))] - (or (when-let [track-sid (and (keyword? (:palette-track owner)) - (:palette-track owner))] - (let [track-symbol (symbol clip track-sid) - palette-clip (first - (filter (fn [n] - (let [[a b] (node/placed-span n)] - (and a (<= a frame) (< frame b)))) - (symbol/children (:nodes track-symbol)))) - palette-symbol (some-> palette-clip node/source - (#(symbol clip %))) - fallback (when (= :palette (:type palette-symbol)) - (:palette-ref palette-symbol)) - choice (get-in palette-clip [:channels [:palette]]) - start (some-> palette-clip node/placed-span first)] - (or (when (and choice start) - (materialize (channel-value choice (- frame start)))) - fallback))) - track-value - fallback))) + (letfn [(root-selection-at [owner frame inherited] + (palette-at clip store (:default palette) owner frame inherited)) (selection-at [owner frame inherited] (let [selection (:palette owner) chosen (cond @@ -714,6 +746,9 @@ (when (and (contains? clip :default-palette) (map? (:palettes clip)) (not (contains? (:palettes clip) (:default-palette clip)))) [":default-palette must name a project palette"]) + (when (and (contains? clip :root) (map? (:symbols clip)) + (not (contains? (:symbols clip) (:root clip)))) + [(str ":root names missing symbol " (pr-str (:root clip)))]) (for [[id p] (:palettes clip) :when (or (not= id (:id p)) (not (pal/valid-palette? p)))] (str "palette " (pr-str id) " is invalid or has a different :id")) diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 822294d..02a28f9 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -12,6 +12,7 @@ clip//stage width, height clip//palette/ a named indexed palette asset clip//palette-default the project fallback palette id + clip//root the symbol the document opens on clip//source the analysis record this came out of clip//subject/ a tracked subject and its params clip//feature/ one feature: area, nodes, params @@ -138,6 +139,7 @@ (some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "palette-default") (select-keys clip [:default-palette])) + (some-leaf (at "root") (select-keys clip [:root])) (some-leaf (at "analyses") (:analyses clip)) (concat (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) @@ -198,6 +200,7 @@ "stage" (merge acc v) "analyses" (assoc acc :analyses v) "palette-default" (merge acc v) + "root" (merge acc v) "palette" (assoc-in acc [:palettes (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v) "feature" (assoc-in acc [:features (unsegment a)] v) @@ -239,7 +242,7 @@ false) (case (count p) ;; The clip's own facts carry no id. - 3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2)) + 3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2)) false))))] (vec diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index e6f133f..1b29bf4 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -113,7 +113,9 @@ ;; also normalizes documents saved by the earlier model, ;; where changing project FPS left the root on its old ;; editing grid. - built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))] + built (-> (:clip loaded) + clip/pin-root + (clip/set-root-fps (:fps (:clip loaded))))] (let [entry (merge (select-keys built [:fps :width :height]) {:label (str (or (.-name clip-json) cid) " (saved)") :cid cid diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index 2f2641b..18a201e 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -841,6 +841,7 @@ :width (first stage) :height (second stage) :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id + :root :main :subjects (into {} (map (fn [[id _]] [id {:id id :params {} :analysis (:id analysis) diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index c535fe4..8acd478 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -136,6 +136,25 @@ :<- [::palette] (fn [compiled _] (:ramp compiled))) +(rf/reg-sub + ::palettes-shown + :<- [::clip] + :<- [::open] + :<- [::store] + :<- [::open-frame] + :<- [::palette] + ;; The palettes the stage is drawing in at the playhead: the open symbol's own, + ;; BOTH ends while its palette lane blends from one to the next, and any an + ;; instance in it is set to. The palette strip asks it whether the palette it + ;; is editing is one of these. + (fn [[document sid store frame compiled] _] + (when-let [sym (and document (clip/symbol document sid))] + (let [at #(clip/palette-at document store (:default compiled) % (or frame 0) nil) + ends #(if (map? %) [(:from %) (:to %)] [%])] + (into (set (ends (at sym))) + (comp (filter :palette) (mapcat (comp ends at))) + (vals (:nodes sym))))))) + (rf/reg-sub ::store :<- [::clip-id] diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs index 4cf9c2c..027367c 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -21,6 +21,7 @@ half of this (see `@media` in app.css), and a drawer that is shut should not be holding video thumbnails live." (:require [arthur.domain.onion :as onion] + [arthur.ui.menu :as menu] [re-frame.core :as rf])) (def ^:private narrow? @@ -146,8 +147,9 @@ "− + for one zoomable surface. THE READOUT IS THE BUTTON BACK: it says what the zoom is, pressing it returns to normal, and it is accented while it has something to return from — so the quick way back is in the one place - you already look to find out where you are." - [k label] + you already look to find out where you are. `more` joins the group: the + stage's fit." + [k label & more] (let [z @(rf/subscribe [::zoom k]) [lo hi] (limits k) home (k normal)] @@ -162,10 +164,11 @@ (str (js/Math.round (* 100 z)) "%")] [:button {:disabled (>= z hi) :aria-label (str label " zoom in") :title (str "zoom " label " in") - :on-click #(rf/dispatch [::set k (stepped k z false)])} "+"]])) + :on-click #(rf/dispatch [::set k (stepped k z false)])} "+"] + (into [:<>] more)])) -(rf/reg-sub ::mat (fn [db _] (merge {:opacity 0.55} (get-in db [:ui :passepartout])))) +(rf/reg-sub ::mat (fn [db _] (merge {:on? true :opacity 0.55} (get-in db [:ui :passepartout])))) (rf/reg-event-db ::mat (fn [db [_ k v]] (assoc-in db [:ui :passepartout k] v))) (rf/reg-sub ::onion @@ -175,30 +178,42 @@ (fn [db [_ k v]] (assoc-in db [:ui :onion (:clip/current db) (get-in db [:ui :open]) k] v))) +(defn overlay + "Something drawn over the stage that is not the picture: a switch, and its + settings in a popover beside it. Tracing, onion and passepartout are all this + one shape, so they read as three of a kind." + [class label on? tip toggle! settings] + [:div.group.overlay {:class class} + [:button {:class (when on? "on") :aria-pressed (boolean on?) :title tip + :on-click toggle!} + label] + [menu/popover {:label "▾" :title (str label " settings")} settings]]) + +(defn- slider [label lo hi v on-change] + [:label label + [:input {:type "range" :min lo :max hi :step 0.05 :value v + :on-change #(on-change (js/parseFloat (.. % -target -value)))}]]) + (defn onion-controls [] (let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])] - [:div.group.onion-controls - [:button {:class (when on? "on") :aria-pressed (boolean on?) - :title "Onion skin — neighboring cels of the selected lane" - :on-click #(rf/dispatch [::onion :on? (not on?)])} "onion"] - [:details - [:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"] - [:div.view-popout - [:label "Scope" - [:select {:value (name scope) - :on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])} - [:option {:value "selected"} "Selected lane"] - [:option {:value "symbol"} "Whole symbol"]]] - (for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]] - ^{:key k} [:label label - [:input {:type "number" :min 0 :max 5 :value v - :on-change #(let [n (js/parseInt (.. % -target -value) 10)] - (when-not (js/isNaN n) - (rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) - [:label "Opacity" - [:input {:type "range" :min 0.05 :max 0.6 :step 0.05 :value opacity - :on-change #(rf/dispatch [::onion :opacity (js/parseFloat (.. % -target -value))])}]] - [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]]]])) + [overlay "onion-controls" "onion" on? + "Onion skin — neighboring cels of the selected lane" + #(rf/dispatch [::onion :on? (not on?)]) + (fn [_] + [:div.view-popout + [:label "Scope" + [:select {:value (name scope) + :on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])} + [:option {:value "selected"} "Selected lane"] + [:option {:value "symbol"} "Whole symbol"]]] + (for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]] + ^{:key k} [:label label + [:input {:type "number" :min 0 :max 5 :value v + :on-change #(let [n (js/parseInt (.. % -target -value) 10)] + (when-not (js/isNaN n) + (rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) + [slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])] + [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]])])) (defn fit-stage! [] (when-let [area (.querySelector js/document ".stage-area")] @@ -209,14 +224,19 @@ (/ (- (.-clientHeight area) 32) h))]) (js/requestAnimationFrame #(do (set! (.-scrollLeft area) 0) (set! (.-scrollTop area) 0))))))) -(defn stage-controls [] - (let [{:keys [opacity]} @(rf/subscribe [::mat])] +(defn stage-controls + "The stage's own view, after the tracing switch: what else is drawn over it, + then how big it is. Fit is in the zoom group because it IS a zoom." + [] + (let [{:keys [on? opacity]} @(rf/subscribe [::mat])] [:<> [onion-controls] - [:button {:on-click fit-stage! :title "Fit stage in view"} "fit"] - [:details.passepartout-controls - [:summary "passepartout"] - [:div.view-popout - [:label "Passepartout" - [:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity - :on-change #(rf/dispatch [::mat :opacity (js/parseFloat (.. % -target -value))])}]]]]])) + [overlay "passepartout-controls" "passepartout" on? + "Darken the desk around the stage" + #(rf/dispatch [::mat :on? (not on?)]) + (fn [_] + [:div.view-popout + [slider "Opacity" 0 1 opacity #(rf/dispatch [::mat :opacity %])]])] + [:span.sep] + [zoomer :stage "the stage" + [:button {:on-click fit-stage! :title "Fit stage in view"} "fit"]]])) diff --git a/frontend/src/arthur/ui/menu.cljs b/frontend/src/arthur/ui/menu.cljs index 93342fe..61ae8a2 100644 --- a/frontend/src/arthur/ui/menu.cljs +++ b/frontend/src/arthur/ui/menu.cljs @@ -79,3 +79,29 @@ :on-click (fn [] (close!) (on-click))} label (when sub [:span.sub sub])]))]])])))) + +(defn popover + "A button that opens a panel of settings rather than a list of commands: the + onion's counts, a slider, the palette's name. Placed and dismissed exactly as + `view` is — measured against its button, shut by a click anywhere else — so + every popover in the window closes the same way. `body` is a function of the + close function, for a panel with a control that should shut it." + [_ _] + (let [at (r/atom nil)] + (fn [{:keys [label title class]} body] + (let [close! #(reset! at nil)] + [:div.menu-wrap {:class class} + [:button {:class (str "pop" (when @at " on")) :title title :aria-label title + :aria-haspopup "dialog" :aria-expanded (boolean @at) + :on-click (fn [^js e] + (if @at (close!) (reset! at (placement (.-currentTarget e)))))} + label] + (when-let [{:keys [left top bottom max-height]} @at] + [:<> + [:div.menu-scrim {:on-click close!}] + [:div.menu.menu-drop.popout + {:role "dialog" :aria-label title + :style (cond-> {:left (str left "px") :max-height (str max-height "px")} + top (assoc :top (str top "px")) + bottom (assoc :bottom (str bottom "px")))} + (body close!)]])])))) diff --git a/frontend/src/arthur/ui/palette.cljs b/frontend/src/arthur/ui/palette.cljs index a95b038..e034ff6 100644 --- a/frontend/src/arthur/ui/palette.cljs +++ b/frontend/src/arthur/ui/palette.cljs @@ -15,6 +15,7 @@ The footage switch is NOT here. It is a viewing aid rather than something you set before you draw, so it is a section of the inspector. See `ui/params`." (:require [arthur.domain.channel :as channel] + [arthur.domain.clip :as clip] [arthur.domain.node :as node] [arthur.domain.palette :as pal] [arthur.domain.symbol :as symbol] @@ -22,6 +23,8 @@ [arthur.events.ui :as ui] [arthur.subs.render :as render] [arthur.subs.ui :as sub] + [arthur.ui.menu :as menu] + [clojure.string :as str] [re-frame.core :as rf] [reagent.core :as r])) @@ -174,30 +177,107 @@ :on-click #(pick! [:remap {}] placements)} "⇄"]]]])) +(defn- strip + "Every slot of a palette, side by side: what it looks like at a glance." + [{:keys [slots]}] + [:span.strip (for [[i {:keys [hex]}] (map-indexed vector slots)] + ^{:key i} [:i {:style {:background hex}}])]) + +(defn- rename + "The open palette's name, made editable. Committed on Enter or blur, so a + rename is one undo step and not one per letter; Esc leaves it as it was." + [pid palette done!] + [:input.palette-name + {:type "text" :default-value (:name palette) :auto-focus true + :aria-label "palette name" + :on-focus #(.select (.-target %)) + :on-blur (fn [^js e] + (rf/dispatch [::project/palette-name pid (.. e -target -value)]) + (done!)) + :on-key-down (fn [^js e] + (case (.-key e) + "Enter" (.blur (.-target e)) + "Escape" (do (set! (.. e -target -value) (:name palette)) + (.blur (.-target e))) + nil))}]) + +(defn- chooser + "Every palette in the project as a row — its strip and its name — so what you + would switch to is visible before you switch. The open one carries the edit + button that renames it; the ones the stage is drawing in at the playhead carry + a dot." + [clip pid] + (r/with-let [renaming? (r/atom false)] + (let [on-stage @(rf/subscribe [::render/palettes-shown])] + [:div.palette-list {:role "listbox" :aria-label "palettes"} + (doall + (for [[id p] (sort-by (comp str :name val) (pal/palettes clip)) + :let [open? (= id pid)]] + ^{:key (str id)} + [:div.palette-row {:class (when open? "on")} + (if (and open? @renaming?) + [:div.choose [strip p] [rename id p #(reset! renaming? false)]] + [:button.choose {:role "option" :aria-selected open? + :title (if open? (str "editing " (:name p)) (str "edit " (:name p))) + :on-click (fn [] + (when-not open? + (reset! renaming? false) + (rf/dispatch [::ui/set-tone 0]) + (rf/dispatch [::select id])))} + [strip p] + [:span.name (:name p)] + (when (contains? on-stage id) + [:span.on-stage {:title "on the stage at the playhead"}])]) + (when open? + [:button.rename {:title (str "rename " (:name p)) :aria-label "rename palette" + :on-click #(swap! renaming? not)} + "✎"])]))]))) + +(defn- elsewhere + "An info badge on the palette button when the palette being edited is not one + the stage draws in at the playhead — not an error: editing another palette is + how one is made ready for a palette lane. Hovering says where to change it. + The card is placed in the viewport, as `ui/menu` places its panels, because + the tool strip scrolls and clips its overflow." + [pid palette] + (r/with-let [at (r/atom nil)] + (let [on-stage @(rf/subscribe [::render/palettes-shown]) + clip @(rf/subscribe [::render/clip]) + sid @(rf/subscribe [::render/open])] + (when (and (seq on-stage) (not (contains? on-stage pid))) + [:span.palette-info + {:aria-label "palette not on the stage here" + :on-mouse-enter (fn [^js e] + (let [r (.getBoundingClientRect (.-currentTarget e))] + (reset! at [(+ (.-right r) 8) (- (.-top r) 6)]))) + :on-mouse-leave #(reset! at nil)} + "i" + (when-let [[x y] @at] + [:span.info-card {:role "tooltip" :style {:left (str x "px") :top (str y "px")}} + [:strong (str (:name palette) " isn't on the stage here")] + (str (or (clip/symbol-name clip sid) "This symbol") + " draws in " (str/join " and " (map #(:name (get (pal/palettes clip) %)) on-stage)) + " at this frame. To draw in " (:name palette) + ", set the authoring palette in the symbol inspector, or blend to it with a palette lane.")])])))) + (defn assets - "Which palette asset the grid shows, and a new or duplicated one, for the - tool strip, with a popout for the full asset controls." + "The palettes behind a button at the foot of the strip: switch, rename, add. + The name is NOT printed in the strip: 44px holds no palette's name, and a name + broken over three lines is worse than one shown when asked for." [] (let [[clip pid palette] (shown)] - [:details.palette-assets - {:on-toggle (fn [e] - (let [el (.-currentTarget e) - box (.getBoundingClientRect el) - pop (.querySelector el ".palette-popout")] - (set! (.. pop -style -left) (str (+ 4 (.-right box)) "px")) - (set! (.. pop -style -top) (str (max 8 (min (.-top box) (- (.-innerHeight js/window) 160))) "px"))))} - [:summary {:title (:name palette)} [:span (:name palette)]] - [:div.palette-popout - [:strong (:name palette)] - [:select {:value (str pid) - :title "palette asset" - :on-change (fn [e] - (let [v (.. e -target -value) - id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] - (rf/dispatch [::ui/set-tone 0]) - (rf/dispatch [::select id])))} - (for [[id p] (sort-by (comp str :name val) (pal/palettes clip))] - ^{:key (str id)} [:option {:value (str id)} (:name p)])] - [:button {:title "new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "+"] - [:button {:title (str "duplicate " (:name palette) " — a copy you can retone") - :on-click #(rf/dispatch [::project/duplicate-palette pid])} "clone"]]])) + [:div.palette-assets-wrap + [menu/popover {:class "palette-assets" :title (str "palette: " (:name palette)) + :label [:svg {:view-box "0 0 18 18" :width 18 :height 18} + [:path {:d "M9 2C4.6 2 1.5 5.2 1.5 9c0 4 3.4 7 7.3 7 1.4 0 1.9-.9 1.4-1.9-.5-1 0-2.1 1.4-2.1h1.9c1.9 0 3-1.2 3-3.2C16.5 5 13.2 2 9 2Z"}] + [:circle {:cx 5.5 :cy 8 :r 1.3}] + [:circle {:cx 8.5 :cy 5 :r 1.3}] + [:circle {:cx 12.5 :cy 6.5 :r 1.3}]]} + (fn [_] + [:div.palette-popout + [chooser clip pid] + [:div.row + [:button {:title "a new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "new"] + [:button {:title (str "a copy of " (:name palette) " you can retone") + :on-click #(rf/dispatch [::project/duplicate-palette pid])} "duplicate"]]])] + [elsewhere pid palette]])) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index d08a324..b57a665 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -755,7 +755,8 @@ destination-name (when-let [sid (:sid destination)] (clip/symbol-name clip sid)) zoom @(rf/subscribe [::layout/zoom :stage]) - {:keys [opacity]} @(rf/subscribe [::layout/mat])] + {:keys [on? opacity]} @(rf/subscribe [::layout/mat]) + opacity (if on? opacity 0)] [:div.stage-area (navigation) [:div.stage-wrap diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 091ad2e..654444a 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -1241,10 +1241,9 @@ (let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))] (for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels))))) :when (and (<= 0 f) (< f frames))] - ^{:key index} (if-let [items (and (= :channel kind) (seq (get by-frame f)))] - [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] - [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) + ^{:key index} [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] + ^{:key index} [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) (defn- cursor-hint "What the drag in flight would do, beside the pointer. diff --git a/frontend/src/arthur/ui/tools.cljs b/frontend/src/arthur/ui/tools.cljs index 44b8c89..a2a0aab 100644 --- a/frontend/src/arthur/ui/tools.cljs +++ b/frontend/src/arthur/ui/tools.cljs @@ -43,8 +43,8 @@ :title (str name " (" (.toUpperCase key) ") — " tip) :on-click #(rf/dispatch [::ui/set-tool t])} [:svg {:view-box "0 0 18 18" :width 18 :height 18} icon]]))] - [palette/assets] - [palette/grid]])) + [palette/grid] + [palette/assets]])) (defn- size-control [] (let [size @(rf/subscribe [::sub/brush])] @@ -87,17 +87,16 @@ [:span.dim.hint tip])) [:span.spacer] (let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])] - [:<> - [:button {:class (when on? "on") - :title "show tracing layers over the picture · never exported" - :on-click #(rf/dispatch [::ui/tracing-on (not on?)])} "tracing"] - [:input.trace-opacity - {:type "range" :min 0 :max 1 :step 0.05 :value opacity :disabled (not on?) - :title "how strongly tracing layers draw" :style {:flex "0 0 64px"} - :on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]]) - ;; The stage's zoom: a property of the view and not of the document, so it - ;; sits in the view's own chrome rather than in the inspector. - [layout/zoomer :stage "the stage"] + [layout/overlay "tracing-controls" "tracing" on? + "show tracing layers over the picture · never exported" + #(rf/dispatch [::ui/tracing-on (not on?)]) + (fn [_] + [:div.view-popout + [:label "Opacity" + [:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity + :on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]]])]) + ;; The stage's zoom and overlays: properties of the view and not of the + ;; document, so they sit in the view's own chrome rather than in the inspector. [layout/stage-controls]])) (defn adjust-last diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index 32b2f68..abe3d8f 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -216,10 +216,19 @@ (deftest no-symbol-is-special (let [c (nested)] - (testing "a document opens on the longest symbol nothing places" + (testing "a document opens on its root, however long its unplaced symbols are" (is (= [:loose :main :outer] (clip/unplaced c))) - (is (= :outer (clip/opens-on c))) - (is (= :main (clip/opens-on (clip/blank))))) + (is (= :main (clip/opens-on c))) + (is (= :main (clip/opens-on (clip/blank)))) + (is (= :loose (clip/opens-on (assoc c :root :loose)))) + (is (= c (leaf/clip "u" (leaf/leaves "u" c)))) + (is (seq (clip/problems (assoc c :root :gone))))) + (testing "without a root, the longest symbol nothing places" + (is (= :outer (clip/opens-on (dissoc c :root))))) + (testing "a document saved before roots opens on :main, not a longer take" + (is (= :main (:root (clip/pin-root (dissoc c :root))))) + (is (= :outer (:root (clip/pin-root (-> c (dissoc :root) (update :symbols dissoc :main)))))) + (is (= :loose (:root (clip/pin-root (assoc c :root :loose)))))) (testing "an instance can go into any symbol, and spans that symbol's frames" (let [[n] (vals (get-in c [:symbols :outer :nodes]))] (is (= #{:inner} (node/sources n))) @@ -441,6 +450,9 @@ extended (nest/resize-out document :palettes [:change] 1 false)] (resolve 2) (is (= {:from :day :to :night :t 0.5} (clip/active-palette resolve))) + (is (= (clip/active-palette resolve) + (clip/palette-at document nil :day (clip/symbol document :main) 2 nil)) + "the palette strip asks the same question the resolver answers: both ends of a blend") (is (= [128 128 128] (nth (pal/effective-ramp context (clip/active-palette resolve)) (pal/render-index context :day 1)))) diff --git a/frontend/test/browser/onion.mjs b/frontend/test/browser/onion.mjs index 9c8b81c..bacdbf3 100644 --- a/frontend/test/browser/onion.mjs +++ b/frontend/test/browser/onion.mjs @@ -72,14 +72,16 @@ try { })()`); await sleep(250); - assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none'); - assert.equal(await evaluate(`document.querySelector('.onion-controls summary').textContent`), '▾'); + assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾'); await evaluate(`document.querySelector('.onion-controls > button').click()`); await sleep(150); assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true'); - await evaluate(`document.querySelector('.onion-controls summary').click()`); + await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').click()`); await sleep(100); - assert.equal(await evaluate(`document.querySelector('.onion-controls details').open`), true); + assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), true); + await evaluate(`document.querySelector('.menu-scrim').click()`); + await sleep(100); + assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), false, 'a click outside shuts the onion settings'); await evaluate(`(() => { const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs); const db = c.deref(re_frame.db.app_db); diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 039c147..93d3721 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -523,32 +523,32 @@ async function main() { // same synchronous block arrives while the tool is still unset and is dropped. // A person cannot click twice inside one microtask; a test should not either. const armed = await page.eval(`(() => { - const button = [...document.querySelectorAll('.palette-bar button')] - .find(b => b.textContent === 'pen'); + const button = document.querySelector('.toolbox button.tool[title^="Pen"]'); if (!button) return false; button.click(); return true; })()`); await sleep(100); - const painted = armed && await page.eval(`(() => { - const svg = document.querySelector('.paint-overlay'); - const box = svg.getBoundingClientRect(); - for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { - svg.dispatchEvent(new PointerEvent('pointerdown', { - bubbles: true, clientX: box.left + x * box.width / 320, - clientY: box.top + y * box.height / 200, - })); - } + // The overlay reaches past the stage on every side, so a stage pixel is + // found through the SVG's own transform, as the stage finds it coming back. + // Each point is its own eval: a click is a re-frame event, and the next one + // reads the draft it added to. + const PEN_AT = (x, y) => `(() => { + const svg = document.querySelector('.paint-overlay.tool-pen'); + if (!svg) return false; + const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM()); + svg.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, clientX: p.x, clientY: p.y, + })); return true; - })()`); - await sleep(100); - const finished = await page.eval(`(() => { - const button = [...document.querySelectorAll('.palette-bar button')] - .find(b => b.textContent === 'finish'); - if (!button || button.disabled) return false; - button.click(); - return true; - })()`); + })()`; + let painted = armed; + for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { + painted = painted && await page.eval(PEN_AT(x, y)); + await sleep(60); + } + // Closed as a person closes it: on the first point. + const finished = painted && await page.eval(PEN_AT(10, 10)); await sleep(250); const afterPaint = await page.eval(`(() => { const c = document.querySelector('canvas.stage'); @@ -556,7 +556,8 @@ async function main() { return [...d]; })()`); check(painted && finished && beforePaint.join(',') !== afterPaint.join(','), - 'a polygon drawn with the paint controls appears on the canvas'); + 'a polygon drawn with the paint controls appears on the canvas', + `${beforePaint} -> ${afterPaint}`); for (const f of [8, 16]) { await page.eval(SEEK(f)); await sleep(100); @@ -597,7 +598,18 @@ async function main() { return [...c.getContext('2d').getImageData(25, 20, 1, 1).data]; })()`); check(afterPaint.join(',') === reopenedPaint.join(','), - 'the painted polygon survives the project round trip'); + 'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`); + // A reopened document has nothing selected, so the shape's keys are not in + // the inspector until it is selected again, as anybody would. + await page.eval(`(() => { + const c = cljs.core, k = c.keyword; + const db = c.deref(re_frame.db.app_db); + const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')), + c.vector(k('symbols'), k('main'), k('nodes'))); + const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes))); + re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'), + c.vector(k('node'), k('main'), id, c.vector(id)))); + })()`); await page.eval(SEEK(8)); await sleep(100); check(await page.eval(`(() => { diff --git a/static/arthur/app.css b/static/arthur/app.css index 8498c04..4ae29f5 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -1650,7 +1650,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } background-size: 8px 8px; background-position: 0 0, 4px 4px; } -.palette .cell.clear { grid-column: span 2; width: 36px; } /* -------------------------------------------------------------------------- on the stage: the tools' marks, in stage pixels (the SVG's viewBox), and their @@ -1697,7 +1696,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } background: linear-gradient(135deg, #3a3f52 0 50%, #e8d9a8 50% 100%); color: #fff; font-size: 11px; line-height: 1; text-shadow: 0 1px 1px rgba(0, 0, 0, .6); } -.palette .cell.map { grid-column: span 2; width: 36px; } .palette .cell { position: relative; } .palette .cell .badge { position: absolute; right: -2px; bottom: -2px; @@ -1726,32 +1724,87 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } border-radius: 2px; } -/* Palette assets stay in the existing narrow tool strip. */ -.toolbox .palette-assets { display: block; width: 44px; flex: 0 0 auto; } -.palette-assets summary { cursor: pointer; font-size: 10px; line-height: 1.25; overflow-wrap: anywhere; padding: 3px; } -.palette-popout, .view-popout { position: fixed; z-index: 100; background: var(--pane); color: var(--fg); border: 1px solid var(--line); padding: 10px; box-shadow: 0 4px 16px #0008; line-height: 1.4; } -.palette-popout { left: 52px; width: 230px; display: flex; flex-wrap: wrap; gap: 7px; } -.palette-popout strong { width: 100%; overflow-wrap: anywhere; } -.palette-popout select { width: 100%; } -.view-popout { right: 16px; width: 240px; } +/* The palette's own settings, at the foot of the strip: drawn as a tool button, + because it is one more thing in the strip you press. */ +.palette-assets > button { + width: 34px; height: 30px; padding: 0; + display: grid; place-items: center; + border: 1px solid transparent; border-radius: 4px; background: none; +} +.palette-assets > button:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); } +.palette-assets > button.on { background: var(--sel-bg); border-color: var(--sel); } +.palette-assets svg path { fill: var(--fg); } +.palette-assets svg circle { fill: var(--chrome); } +.palette-assets > button.on svg circle { fill: var(--sel-bg); } + +/* A popover's panel: settings, not commands, so narrower than a menu and laid + out as a form. Placed and dismissed by `ui/menu`. */ +.menu.popout { width: 240px; min-width: 0; padding: 10px; line-height: 1.4; } +.menu.popout:has(.palette-popout) { width: 280px; } +.palette-popout { display: grid; gap: 8px; } +.palette-popout .row { margin: 0; } + +/* Every palette as its strip and its name: what you would switch to, seen + before you switch. The open one is the selected row. */ +.palette-list { display: grid; gap: 1px; max-height: 50vh; overflow: auto; } +.palette-row { display: flex; align-items: center; gap: 2px; border-radius: 3px; } +.palette-row.on { background: var(--sel-bg); } +.palette-row .choose { + flex: 1; min-width: 0; + display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 3px 8px; + padding: 5px 6px; border: 1px solid transparent; border-radius: 3px; background: none; text-align: left; +} +.palette-row button.choose:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); } +.palette-row.on button.choose { border-color: var(--sel); } +.palette-row.on button.choose:hover { background: var(--sel-bg); } +.palette-row .strip { grid-column: 1 / -1; } +.palette-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palette-row.on .name { font-weight: 600; color: #14395f; } +.palette-row .palette-name { grid-column: 1 / -1; width: 100%; min-width: 0; } +.palette-row .rename { align-self: stretch; padding: 0 7px; border-color: transparent; background: none; } +.palette-row .rename:hover:not(:disabled) { border-color: var(--sel); background: #fff; } + +.strip { display: flex; height: 14px; border: 1px solid rgba(0, 0, 0, .35); border-radius: 2px; overflow: hidden; } +.strip i { flex: 1; } + +/* On the stage at the playhead. */ +.on-stage { width: 7px; height: 7px; border-radius: 50%; background: var(--live); } + +/* A palette being edited that the stage is not drawing in here. Information, + not alarm: the accent of a selection, not of a warning. */ +.palette-assets-wrap { position: relative; } +.palette-info { + position: absolute; top: -3px; right: -5px; + width: 13px; height: 13px; border-radius: 50%; + display: grid; place-items: center; + background: var(--sel); color: #fff; + font: italic 600 9px/1 Georgia, serif; + cursor: help; + box-shadow: 0 0 0 1.5px var(--chrome); +} +.info-card { + position: fixed; z-index: 60; + width: 240px; padding: 8px 10px; + display: grid; gap: 3px; + background: var(--pane); color: var(--fg); + font: 11px/1.45 "Lucida Grande", "Segoe UI", system-ui, sans-serif; + border: 1px solid var(--sel); border-left-width: 3px; border-radius: 3px; + box-shadow: 0 3px 10px rgba(0, 0, 0, .25); + cursor: default; +} +.info-card strong { color: #14395f; } .view-popout label { display: block; margin-bottom: 8px; } -.view-popout input { width: 100%; } -.passepartout-controls summary { cursor: pointer; } +.view-popout label:last-child { margin-bottom: 0; } +.view-popout input, .view-popout select { width: 100%; } + +/* An overlay switch and its settings: one object, two parts. */ +.overlay > .menu-wrap > button { margin-left: -1px; padding: 1px 5px; border-radius: 0 2px 2px 0; } +.overlay > button { border-radius: 2px 0 0 2px; } .stage-wrap { flex: 0 0 auto; } .onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; } -.onion-controls { display: inline-flex; align-items: center; } -.onion-controls > button { border-radius: 2px 0 0 2px; } -.onion-controls summary { - display: block; cursor: pointer; list-style: none; - color: var(--fg); background: var(--pane); border: 1px solid var(--line); - border-radius: 0 2px 2px 0; margin-left: -1px; padding: 1px 5px; -} -.onion-controls summary:hover, .onion-controls details[open] > summary { background: #fff; } -.onion-controls summary::-webkit-details-marker { display: none; } -.onion-controls .view-popout { line-height: 1.4; } -.onion-controls select { width: 100%; } + /* Expanded automation lanes expose editable keyframe dots. */ button.tl-key { pointer-events: auto; padding: 0; border: 0; width: 11px; height: 11px; margin: -5.5px; cursor: ew-resize; z-index: 4; opacity: 1; border-radius: 50%; }