diff --git a/.claude/worktrees/tracing-layers b/.claude/worktrees/tracing-layers deleted file mode 160000 index f4dd047..0000000 --- a/.claude/worktrees/tracing-layers +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f4dd04764204506fc275180364d0366d693036f4 diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index c61f6af..6bd341b 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -57,9 +57,8 @@ 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 — once - tracing itself is on, which it is not until asked for." - {:on? false :opacity 0.5 :hidden #{}}) + footage brought in shows without being found and switched on first." + {:on? true :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 04df444..7f83e45 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -25,14 +25,13 @@ 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 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. + 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. :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." @@ -53,7 +52,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 :root}) + :palettes :default-palette}) (defn symbol "One of the clip's symbols, by id." @@ -202,39 +201,18 @@ (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] (vec (sort-by str (remove placed (keys (:symbols clip))))))) -(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." +(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." [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. @@ -272,7 +250,6 @@ :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 {}}}}) @@ -374,58 +351,6 @@ (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 @@ -433,8 +358,51 @@ [clip sid store palette opts] (let [context? (and (map? palette) (:palettes palette) (:offsets palette)) active-palette-state (atom (:default palette))] - (letfn [(root-selection-at [owner frame inherited] - (palette-at clip store (:default palette) owner frame inherited)) + (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))) (selection-at [owner frame inherited] (let [selection (:palette owner) chosen (cond @@ -746,9 +714,6 @@ (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 02a28f9..822294d 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -12,7 +12,6 @@ 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 @@ -139,7 +138,6 @@ (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}) @@ -200,7 +198,6 @@ "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) @@ -242,7 +239,7 @@ false) (case (count p) ;; The clip's own facts carry no id. - 3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2)) + 3 (#{"name" "timing" "stage" "analyses" "palette-default"} (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 1b29bf4..e6f133f 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -113,9 +113,7 @@ ;; also normalizes documents saved by the earlier model, ;; where changing project FPS left the root on its old ;; editing grid. - built (-> (:clip loaded) - clip/pin-root - (clip/set-root-fps (:fps (:clip loaded))))] + built (clip/set-root-fps (:clip loaded) (: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 18a201e..2f2641b 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -841,7 +841,6 @@ :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 8acd478..c535fe4 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -136,25 +136,6 @@ :<- [::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 027367c..4cf9c2c 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -21,7 +21,6 @@ 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? @@ -147,9 +146,8 @@ "− + 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. `more` joins the group: the - stage's fit." - [k label & more] + you already look to find out where you are." + [k label] (let [z @(rf/subscribe [::zoom k]) [lo hi] (limits k) home (k normal)] @@ -164,11 +162,10 @@ (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)])} "+"] - (into [:<>] more)])) + :on-click #(rf/dispatch [::set k (stepped k z false)])} "+"]])) -(rf/reg-sub ::mat (fn [db _] (merge {:on? true :opacity 0.55} (get-in db [:ui :passepartout])))) +(rf/reg-sub ::mat (fn [db _] (merge {: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 @@ -178,42 +175,30 @@ (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])] - [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."]])])) + [: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."]]]])) (defn fit-stage! [] (when-let [area (.querySelector js/document ".stage-area")] @@ -224,19 +209,14 @@ (/ (- (.-clientHeight area) 32) h))]) (js/requestAnimationFrame #(do (set! (.-scrollLeft area) 0) (set! (.-scrollTop area) 0))))))) -(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])] +(defn stage-controls [] + (let [{:keys [opacity]} @(rf/subscribe [::mat])] [:<> [onion-controls] - [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"]]])) + [: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))])}]]]]])) diff --git a/frontend/src/arthur/ui/menu.cljs b/frontend/src/arthur/ui/menu.cljs index 61ae8a2..93342fe 100644 --- a/frontend/src/arthur/ui/menu.cljs +++ b/frontend/src/arthur/ui/menu.cljs @@ -79,29 +79,3 @@ :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 e034ff6..a95b038 100644 --- a/frontend/src/arthur/ui/palette.cljs +++ b/frontend/src/arthur/ui/palette.cljs @@ -15,7 +15,6 @@ 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] @@ -23,8 +22,6 @@ [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])) @@ -177,107 +174,30 @@ :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 - "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." + "Which palette asset the grid shows, and a new or duplicated one, for the + tool strip, with a popout for the full asset controls." [] (let [[clip pid palette] (shown)] - [: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]])) + [: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"]]])) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index b57a665..d08a324 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -755,8 +755,7 @@ destination-name (when-let [sid (:sid destination)] (clip/symbol-name clip sid)) zoom @(rf/subscribe [::layout/zoom :stage]) - {:keys [on? opacity]} @(rf/subscribe [::layout/mat]) - opacity (if on? opacity 0)] + {:keys [opacity]} @(rf/subscribe [::layout/mat])] [:div.stage-area (navigation) [:div.stage-wrap diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 654444a..091ad2e 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -1241,9 +1241,10 @@ (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 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)}}])))))])) + [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)}}])))))])) (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 a2a0aab..44b8c89 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/grid] - [palette/assets]])) + [palette/assets] + [palette/grid]])) (defn- size-control [] (let [size @(rf/subscribe [::sub/brush])] @@ -87,16 +87,17 @@ [:span.dim.hint tip])) [:span.spacer] (let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])] - [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. + [:<> + [: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/stage-controls]])) (defn adjust-last diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index abe3d8f..32b2f68 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -216,19 +216,10 @@ (deftest no-symbol-is-special (let [c (nested)] - (testing "a document opens on its root, however long its unplaced symbols are" + (testing "a document opens on the longest symbol nothing places" (is (= [:loose :main :outer] (clip/unplaced c))) - (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)))))) + (is (= :outer (clip/opens-on c))) + (is (= :main (clip/opens-on (clip/blank))))) (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))) @@ -450,9 +441,6 @@ 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 bacdbf3..9c8b81c 100644 --- a/frontend/test/browser/onion.mjs +++ b/frontend/test/browser/onion.mjs @@ -72,16 +72,14 @@ try { })()`); await sleep(250); - assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾'); + assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none'); + assert.equal(await evaluate(`document.querySelector('.onion-controls summary').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 .menu-wrap > button').click()`); + await evaluate(`document.querySelector('.onion-controls summary').click()`); await sleep(100); - 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'); + assert.equal(await evaluate(`document.querySelector('.onion-controls details').open`), true); 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 93d3721..039c147 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.querySelector('.toolbox button.tool[title^="Pen"]'); + const button = [...document.querySelectorAll('.palette-bar button')] + .find(b => b.textContent === 'pen'); if (!button) return false; button.click(); return true; })()`); await sleep(100); - // 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, - })); + 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, + })); + } 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(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; + })()`); await sleep(250); const afterPaint = await page.eval(`(() => { const c = document.querySelector('canvas.stage'); @@ -556,8 +556,7 @@ async function main() { return [...d]; })()`); check(painted && finished && beforePaint.join(',') !== afterPaint.join(','), - 'a polygon drawn with the paint controls appears on the canvas', - `${beforePaint} -> ${afterPaint}`); + 'a polygon drawn with the paint controls appears on the canvas'); for (const f of [8, 16]) { await page.eval(SEEK(f)); await sleep(100); @@ -598,18 +597,7 @@ 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', `${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)))); - })()`); + 'the painted polygon survives the project round trip'); 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 4ae29f5..8498c04 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -1650,6 +1650,7 @@ 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 @@ -1696,6 +1697,7 @@ 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; @@ -1724,87 +1726,32 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } border-radius: 2px; } -/* 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; } +/* 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; } .view-popout label { display: block; margin-bottom: 8px; } -.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; } +.view-popout input { width: 100%; } +.passepartout-controls summary { cursor: pointer; } .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%; }