better toolbar
This commit is contained in:
parent
2f1c9b9c02
commit
5fb04f6a7d
6 changed files with 433 additions and 87 deletions
55
frontend/src/arthur/ui/icon.cljs
Normal file
55
frontend/src/arthur/ui/icon.cljs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
(ns arthur.ui.icon
|
||||||
|
"The handful of glyphs the chrome uses, drawn here rather than imported.
|
||||||
|
|
||||||
|
NOT an icon library, on purpose. Three reasons, in order of weight:
|
||||||
|
|
||||||
|
1. SIZE. These render at 11px against 11px type. Lucide, Feather and the rest
|
||||||
|
are drawn on a 24px grid with a 2px stroke and sub-pixel curves; scaled to
|
||||||
|
11 they go soft and land off the pixel grid, in a tool whose entire premise
|
||||||
|
is that you can see individual pixels. These are drawn on a 12px grid with
|
||||||
|
whole and half coordinates, so the bars and edges fall where the renderer
|
||||||
|
wants them.
|
||||||
|
2. REGISTER. `app.css` argues the chrome is Macromedia-era — flat, hairline,
|
||||||
|
no bevels, one accent. A modern rounded icon set is a second visual voice
|
||||||
|
in a strip 21px tall.
|
||||||
|
3. COST. Six glyphs is forty lines. The alternative is a dependency, a build
|
||||||
|
step's worth of tree-shaking, and a license file, for forty lines.
|
||||||
|
|
||||||
|
Icons are used ONLY where the word is worse than the picture: the transport,
|
||||||
|
where `|<` was ASCII pretending to be a glyph, and the two playback toggles,
|
||||||
|
which are state rather than actions. Everything else in the strip keeps its
|
||||||
|
word, because `insert` and `overwrite` have no pictures and inventing some
|
||||||
|
would be a puzzle rather than a shorthand. Every one carries an `aria-label`
|
||||||
|
at the call site; nothing here is the only statement of what a control does."
|
||||||
|
(:require [clojure.string :as str]))
|
||||||
|
|
||||||
|
;; A glyph is its paths on a 12x12 grid. Solid shapes are filled; the two
|
||||||
|
;; drawn with a line are stroked, which `kind` says.
|
||||||
|
(def ^:private glyphs
|
||||||
|
{:start {:fill ["M2 2.5h1.3v7H2z" ; the bar it stops against
|
||||||
|
"M10 2.5v7L4.2 6z"]} ; and the triangle into it
|
||||||
|
:play {:fill ["M3.4 2.3L10 6 3.4 9.7z"]}
|
||||||
|
:pause {:fill ["M3.3 2.4h1.9v7.2H3.3z"
|
||||||
|
"M6.8 2.4h1.9v7.2H6.8z"]}
|
||||||
|
;; Two arrows round a rectangle: the clip's end rejoining its start.
|
||||||
|
:loop {:stroke ["M3 5.4V4.6A1.2 1.2 0 0 1 4.2 3.4h5"
|
||||||
|
"M7.8 1.8 9.4 3.4 7.8 5"
|
||||||
|
"M9 6.6v.8A1.2 1.2 0 0 1 7.8 8.6h-5"
|
||||||
|
"M4.2 7 2.6 8.6 4.2 10.2"]}
|
||||||
|
;; One speaker, with or without the sound coming out of it.
|
||||||
|
:sound {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"]
|
||||||
|
:stroke ["M8 4.1a2.7 2.7 0 0 1 0 3.8"]}
|
||||||
|
:muted {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"]
|
||||||
|
:stroke ["M7.9 4.4 10.7 7.6" "M10.7 4.4 7.9 7.6"]}})
|
||||||
|
|
||||||
|
(defn view
|
||||||
|
"The glyph named `k`, sized by CSS and inked in `currentColor` — so a button's
|
||||||
|
`.on` state colours its icon without this knowing the palette exists."
|
||||||
|
[k]
|
||||||
|
(let [{:keys [fill stroke]} (glyphs k)]
|
||||||
|
[:svg {:viewBox "0 0 12 12" :aria-hidden true :focusable false}
|
||||||
|
(when (seq fill)
|
||||||
|
[:path {:d (str/join " " fill) :fill "currentColor"}])
|
||||||
|
(when (seq stroke)
|
||||||
|
[:path {:d (str/join " " stroke) :fill "none" :stroke "currentColor"
|
||||||
|
:stroke-width 1.2 :stroke-linecap "round" :stroke-linejoin "round"}])]))
|
||||||
81
frontend/src/arthur/ui/menu.cljs
Normal file
81
frontend/src/arthur/ui/menu.cljs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
(ns arthur.ui.menu
|
||||||
|
"A labelled drop of commands, for a pane head that has more commands than room.
|
||||||
|
|
||||||
|
THE POINT IS NOT SPACE, IT IS THE EXPLANATION. A command that applies only to
|
||||||
|
a selected cel spends most of its life disabled, and a disabled button is a
|
||||||
|
grey word with its reason hidden in a `title` nobody hovers. The same command
|
||||||
|
as a menu row gets a second line saying what it does, visible at the moment
|
||||||
|
somebody is looking for it, which is the moment the menu is open.
|
||||||
|
|
||||||
|
So a menu stays openable even when every row in it is disabled, and says what
|
||||||
|
is missing instead. Hiding the rows would answer \"where did split go\" with
|
||||||
|
silence.
|
||||||
|
|
||||||
|
IT IS PLACED IN THE VIEWPORT, NOT IN THE PANE. A pane clips its own overflow —
|
||||||
|
`.time` must, or the timeline would push the window taller — so a panel laid
|
||||||
|
out inside one is cut off at its edge. Measured once per opening against the
|
||||||
|
button it hangs under, which is also what lets it flip above the button when
|
||||||
|
there is more room there. The measurement is of the BUTTON and not of the
|
||||||
|
panel, so it does not need the panel to exist yet.
|
||||||
|
|
||||||
|
The scrim is `ui/openmenu`'s, for the reason given there: a catcher behind the
|
||||||
|
panel dismisses on any outside click without a document listener that has to
|
||||||
|
be added, removed, and told to ignore the click that opened the menu."
|
||||||
|
(:require [reagent.core :as r]))
|
||||||
|
|
||||||
|
(def ^:private gap 2)
|
||||||
|
(def ^:private margin 8)
|
||||||
|
|
||||||
|
(defn- placement
|
||||||
|
"Where to put the panel, given the button it belongs to. `:bottom` rather than
|
||||||
|
`:top` when it opens upward, so the panel grows away from the button in both
|
||||||
|
directions without anything measuring the panel."
|
||||||
|
[^js button]
|
||||||
|
(let [r (.getBoundingClientRect button)
|
||||||
|
vh (.-innerHeight js/window)
|
||||||
|
vw (.-innerWidth js/window)
|
||||||
|
below (- vh (.-bottom r) gap margin)
|
||||||
|
above (- (.-top r) gap margin)
|
||||||
|
up? (> above below)]
|
||||||
|
(merge {:left (max margin (min (.-left r) (- vw 280 margin)))
|
||||||
|
:max-height (max 120 (if up? above below))}
|
||||||
|
(if up?
|
||||||
|
{:bottom (+ (- vh (.-top r)) gap)}
|
||||||
|
{:top (+ (.-bottom r) gap)}))))
|
||||||
|
|
||||||
|
(defn view
|
||||||
|
"`{:label :title :note :items}`. An item is
|
||||||
|
`{:label :sub :disabled? :on-click}`; a nil item is dropped, so a caller can
|
||||||
|
put a row behind a `when`. `note` is shown when nothing in the menu applies."
|
||||||
|
[_]
|
||||||
|
(let [at (r/atom nil)]
|
||||||
|
(fn [{:keys [label title note items]}]
|
||||||
|
(let [items (remove nil? items)
|
||||||
|
dead? (every? :disabled? items)
|
||||||
|
close! #(reset! at nil)]
|
||||||
|
[:div.menu-wrap
|
||||||
|
[:button {:class (when @at "on") :title title
|
||||||
|
:aria-haspopup "menu" :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
|
||||||
|
{:role "menu"
|
||||||
|
:style (cond-> {:left (str left "px") :max-height (str max-height "px")}
|
||||||
|
top (assoc :top (str top "px"))
|
||||||
|
bottom (assoc :bottom (str bottom "px")))}
|
||||||
|
(when (and dead? note) [:div.dim.menu-note note])
|
||||||
|
(doall
|
||||||
|
(for [{:keys [label sub disabled? on-click]} items]
|
||||||
|
^{:key label}
|
||||||
|
;; The row's accessible name is the COMMAND, not the command
|
||||||
|
;; followed by its explanation — which is what `textContent` is
|
||||||
|
;; here, and what a screen reader would otherwise read out.
|
||||||
|
[:button.menu-item {:role "menuitem" :disabled (boolean disabled?)
|
||||||
|
:aria-label label :title sub
|
||||||
|
:on-click (fn [] (close!) (on-click))}
|
||||||
|
label
|
||||||
|
(when sub [:span.sub sub])]))]])]))))
|
||||||
|
|
@ -29,6 +29,8 @@
|
||||||
[arthur.events.playback :as pb]
|
[arthur.events.playback :as pb]
|
||||||
[arthur.events.ui :as ui]
|
[arthur.events.ui :as ui]
|
||||||
[arthur.footage.store :as store]
|
[arthur.footage.store :as store]
|
||||||
|
[arthur.ui.icon :as icon]
|
||||||
|
[arthur.ui.menu :as menu]
|
||||||
[arthur.subs.playback :as playback]
|
[arthur.subs.playback :as playback]
|
||||||
[arthur.subs.render :as render]
|
[arthur.subs.render :as render]
|
||||||
[arthur.subs.ui :as sub]
|
[arthur.subs.ui :as sub]
|
||||||
|
|
@ -223,6 +225,9 @@
|
||||||
(defn- transport []
|
(defn- transport []
|
||||||
(let [playing? @(rf/subscribe [::playback/playing?])
|
(let [playing? @(rf/subscribe [::playback/playing?])
|
||||||
rate @(rf/subscribe [::playback/rate])
|
rate @(rf/subscribe [::playback/rate])
|
||||||
|
loop? @(rf/subscribe [::playback/loop?])
|
||||||
|
muted? @(rf/subscribe [::playback/muted?])
|
||||||
|
view @(rf/subscribe [::sub/time-view])
|
||||||
frame @(rf/subscribe [::playback/frame])
|
frame @(rf/subscribe [::playback/frame])
|
||||||
frames @(rf/subscribe [::render/frames])
|
frames @(rf/subscribe [::render/frames])
|
||||||
{:keys [fps drop]} @player/meter
|
{:keys [fps drop]} @player/meter
|
||||||
|
|
@ -237,7 +242,7 @@
|
||||||
lane? (node/lane? lane)
|
lane? (node/lane? lane)
|
||||||
cel? (and lane? (= :instance (:kind n)) (some? (node/source n)))
|
cel? (and lane? (= :instance (:kind n)) (some? (node/source n)))
|
||||||
held? (and cel? (zero? (:speed (node/playback-of n))))
|
held? (and cel? (zero? (:speed (node/playback-of n))))
|
||||||
;; Shared use is shown rather than discovered: the button that decouples
|
;; Shared use is shown rather than discovered: the row that decouples
|
||||||
;; a cel is only offered where there is something to decouple from.
|
;; a cel is only offered where there is something to decouple from.
|
||||||
shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip)
|
shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip)
|
||||||
[_ other] (:nodes sym)
|
[_ other] (:nodes sym)
|
||||||
|
|
@ -250,82 +255,141 @@
|
||||||
(lane/lane-frame clip sid (:id lane) owner-frame))
|
(lane/lane-frame clip sid (:id lane) owner-frame))
|
||||||
insertable? (and lane? (integer? at))
|
insertable? (and lane? (integer? at))
|
||||||
splittable? (and cel? (integer? at)
|
splittable? (and cel? (integer? at)
|
||||||
(let [[lo hi] (node/placed-span n)] (< lo at hi)))]
|
(let [[lo hi] (node/placed-span n)] (< lo at hi)))
|
||||||
|
act (fn [event] #(rf/dispatch event))]
|
||||||
[:div.pane-head
|
[:div.pane-head
|
||||||
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
|
;; ----------------------------------------------------------------- time
|
||||||
[:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"]
|
;; Where the playhead is and how it moves. Nothing here depends on a
|
||||||
[:button {:on-click #(rf/dispatch [::pb/step -1])} "-1"]
|
;; selection, which is why it is the one group that is never greyed.
|
||||||
[:button {:on-click #(rf/dispatch [::pb/step 1])} "+1"]
|
[:div.group
|
||||||
[:button {:class (when @(rf/subscribe [::playback/loop?]) "on")
|
[:button.ico {:aria-label "go to start" :title "go to the first frame"
|
||||||
:on-click #(rf/dispatch [::pb/toggle-loop])} "loop"]
|
:on-click (act [::pb/seek 0])} [icon/view :start]]
|
||||||
[:button {:class (when @(rf/subscribe [::playback/muted?]) "on")
|
[:button {:aria-label "−1" :title "step back one frame"
|
||||||
:on-click #(rf/dispatch [::pb/toggle-mute])} "mute"]
|
:on-click (act [::pb/step -1])} "−1"]
|
||||||
[:button {:class (when (= :timeline @(rf/subscribe [::sub/time-view])) "on")
|
[:button.ico.ico-play {:aria-label (if playing? "pause" "play")
|
||||||
:on-click #(rf/dispatch [::ui/set-time-view :timeline])} "timeline"]
|
:title (if playing? "pause" "play")
|
||||||
[:button {:class (when (= :cel-sheet @(rf/subscribe [::sub/time-view])) "on")
|
:on-click (act [::pb/toggle])}
|
||||||
:on-click #(rf/dispatch [::ui/set-time-view :cel-sheet])} "cel sheet"]
|
[icon/view (if playing? :pause :play)]]
|
||||||
(doall
|
[:button {:aria-label "+1" :title "step forward one frame"
|
||||||
(for [r [0.25 0.5 1.0 2.0 4.0]]
|
:on-click (act [::pb/step 1])} "+1"]]
|
||||||
^{:key r}
|
[:div.group
|
||||||
|
[:button.ico {:class (when loop? "on") :aria-label "loop"
|
||||||
|
:title "loop playback" :on-click (act [::pb/toggle-loop])}
|
||||||
|
[icon/view :loop]]
|
||||||
|
[:button.ico {:class (when muted? "on") :aria-label (if muted? "unmute" "mute")
|
||||||
|
:title (if muted? "unmute" "mute") :on-click (act [::pb/toggle-mute])}
|
||||||
|
[icon/view (if muted? :muted :sound)]]]
|
||||||
|
;; Beside the transport rather than off at the right margin: it is what you
|
||||||
|
;; read WHILE scrubbing, and tabular figures keep it from twitching as it
|
||||||
|
;; counts.
|
||||||
|
[:span.readout.at-frame (str frame " / " frames)]
|
||||||
|
;; Five buttons for one mutually exclusive choice that is 1× almost always.
|
||||||
|
;; A select says "pick one of these" in the width of the answer.
|
||||||
;; playbackRate on the audio element and nothing else: the sound slows,
|
;; playbackRate on the audio element and nothing else: the sound slows,
|
||||||
;; currentTime advances proportionally, and the derived frame follows. Slow
|
;; currentTime advances proportionally, and the derived frame follows. Slow
|
||||||
;; motion cannot desync by construction.
|
;; motion cannot desync by construction.
|
||||||
[:button {:class (when (== r rate) "on")
|
[:span.pick
|
||||||
:on-click #(rf/dispatch [::pb/set-rate r])}
|
[:select {:value rate :aria-label "playback speed" :title "playback speed"
|
||||||
(case r 1.0 "1x" 0.5 "½" 0.25 "¼" 2.0 "2x" 4.0 "4x" (str r))]))
|
:on-change #(rf/dispatch [::pb/set-rate
|
||||||
[:button {:title "a new empty symbol inside the selected instance, or beside the selected node, or in the open symbol"
|
(js/parseFloat (.. % -target -value))])}
|
||||||
:on-click #(rf/dispatch [::ui/new-symbol])}
|
(doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]]
|
||||||
"+ symbol"]
|
^{:key r} [:option {:value r} label]))]]
|
||||||
[:button {:on-click #(rf/dispatch [::ui/new-lane])} "+ lane"]
|
[:span.sep]
|
||||||
[:button {:disabled (not lane?)
|
;; ----------------------------------------------------------------- view
|
||||||
:title "append a new independent drawing to the selected lane"
|
;; TWO VIEWS OF ONE THING, and you are always in exactly one. Two separate
|
||||||
:on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"]
|
;; toggles said neither half of that; joined, with the one in force filled,
|
||||||
[:button {:disabled (not insertable?)
|
;; the control is the statement.
|
||||||
:title "a new drawing at the playhead; later drawings ripple later"
|
[:div.seg {:role "radiogroup" :aria-label "time view"}
|
||||||
:on-click #(rf/dispatch [::ui/insert-drawing])} "insert"]
|
(doall
|
||||||
[:button {:disabled (not insertable?)
|
(for [[k label title] [[:timeline "timeline" "lanes across, frames left to right"]
|
||||||
:title "replace the drawing at the playhead; later drawings stay put"
|
[:cel-sheet "cel sheet" "frames down, one column per lane"]]]
|
||||||
:on-click #(rf/dispatch [::ui/overwrite-drawing])} "overwrite"]
|
^{:key k}
|
||||||
[:button {:disabled (not splittable?)
|
[:button {:class (when (= k view) "on") :role "radio"
|
||||||
:title "cut this cel in two at the playhead; the picture does not change"
|
:aria-checked (= k view) :title title
|
||||||
:on-click #(rf/dispatch [::ui/split-cel])} "split"]
|
:on-click (act [::ui/set-time-view k])}
|
||||||
[:button {:disabled (not cel?)
|
label]))]
|
||||||
:title "expose this same drawing again — one drawing, two cels"
|
[:span.sep]
|
||||||
:on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"]
|
;; ------------------------------------------------------------- commands
|
||||||
[:button {:disabled (not cel?)
|
;; Fourteen of these, and all but two mean nothing without a selected lane
|
||||||
:title "append a copy of this drawing, to draw the next one over it"
|
;; or cel — so as buttons they were a permanent grey hedge across the strip
|
||||||
:on-click #(rf/dispatch [::ui/duplicate-drawing])} "duplicate"]
|
;; with their explanations hidden in `title`. Grouped by what they change,
|
||||||
[:button {:disabled (not shared?)
|
;; with the explanation on the row, they cost three slots and read as a
|
||||||
:title "give this cel its own copy; other cels keep sharing"
|
;; vocabulary. There is also room here for the correction commands, which
|
||||||
:on-click #(rf/dispatch [::ui/make-unique])} "make unique"]
|
;; `docs/lane-handoff.md` says are next.
|
||||||
[:button {:disabled (not splittable?)
|
[menu/view
|
||||||
:title "start this cel at the playhead; nothing else moves"
|
{:label "new" :title "add to the document"
|
||||||
:on-click #(rf/dispatch [::ui/trim-cel :in])} "trim in"]
|
:items [{:label "symbol"
|
||||||
[:button {:disabled (not splittable?)
|
:sub "empty, inside the selected instance or beside the selected node"
|
||||||
:title "end this cel at the playhead; nothing else moves"
|
:on-click (act [::ui/new-symbol])}
|
||||||
:on-click #(rf/dispatch [::ui/trim-cel :out])} "trim out"]
|
{:label "lane"
|
||||||
[:button {:disabled (not (and cel? (integer? at)))
|
:sub "a row that holds one drawing after another"
|
||||||
:title "put this cel at the playhead; refused if something is there"
|
:on-click (act [::ui/new-lane])}]}]
|
||||||
:on-click #(rf/dispatch [::ui/move-cel])} "move here"]
|
[menu/view
|
||||||
[:button {:disabled (not cel?)
|
{:label "drawing" :title "what the lane exposes"
|
||||||
:title "clear this cel's frames, leaving a gap; later drawings stay put"
|
:note "select a lane, or a cel in one"
|
||||||
:on-click #(rf/dispatch [::ui/blank-cel])} "blank"]
|
:items [{:label "new drawing" :disabled? (not lane?)
|
||||||
[:button {:disabled (not held?)
|
:sub "append a new independent drawing to the selected lane"
|
||||||
|
:on-click (act [::ui/append-drawing])}
|
||||||
|
{:label "insert" :disabled? (not insertable?)
|
||||||
|
:sub "a new drawing at the playhead; later drawings ripple later"
|
||||||
|
:on-click (act [::ui/insert-drawing])}
|
||||||
|
{:label "overwrite" :disabled? (not insertable?)
|
||||||
|
:sub "replace the drawing at the playhead; later drawings stay put"
|
||||||
|
:on-click (act [::ui/overwrite-drawing])}
|
||||||
|
{:label "reuse" :disabled? (not cel?)
|
||||||
|
:sub "expose this same drawing again — one drawing, two cels"
|
||||||
|
:on-click (act [::ui/reuse-drawing])}
|
||||||
|
{:label "duplicate" :disabled? (not cel?)
|
||||||
|
:sub "append a copy of this drawing, to draw the next one over it"
|
||||||
|
:on-click (act [::ui/duplicate-drawing])}
|
||||||
|
{:label "make unique" :disabled? (not shared?)
|
||||||
|
:sub "give this cel its own copy; other cels keep sharing"
|
||||||
|
:on-click (act [::ui/make-unique])}]}]
|
||||||
|
[menu/view
|
||||||
|
{:label "cel" :title "where this cel sits and how long it lasts"
|
||||||
|
:note "select a cel in the timeline or the cel sheet"
|
||||||
|
:items [{:label "split" :disabled? (not splittable?)
|
||||||
|
:sub "cut this cel in two at the playhead; the picture does not change"
|
||||||
|
:on-click (act [::ui/split-cel])}
|
||||||
|
{:label "trim in" :disabled? (not splittable?)
|
||||||
|
:sub "start this cel at the playhead; nothing else moves"
|
||||||
|
:on-click (act [::ui/trim-cel :in])}
|
||||||
|
{:label "trim out" :disabled? (not splittable?)
|
||||||
|
:sub "end this cel at the playhead; nothing else moves"
|
||||||
|
:on-click (act [::ui/trim-cel :out])}
|
||||||
|
{:label "move here" :disabled? (not (and cel? (integer? at)))
|
||||||
|
:sub "put this cel at the playhead; refused if something is there"
|
||||||
|
:on-click (act [::ui/move-cel])}
|
||||||
|
{:label "blank" :disabled? (not cel?)
|
||||||
|
:sub "clear this cel's frames, leaving a gap; later drawings stay put"
|
||||||
|
:on-click (act [::ui/blank-cel])}]}]
|
||||||
|
;; THE ONE PAIR THAT STAYS A BUTTON. Deciding how many frames a drawing is
|
||||||
|
;; held for is shooting on ones or twos — the most repeated edit in the
|
||||||
|
;; list, done by eye, a frame at a time. Two clicks into a menu per frame
|
||||||
|
;; would be the one place this consolidation made the tool worse.
|
||||||
|
[:span.stepper
|
||||||
|
[:span.stepper-label "hold"]
|
||||||
|
[:span.group
|
||||||
|
[:button {:disabled (not held?) :aria-label "hold −"
|
||||||
:title "shorten this cel; ripple later drawings, keeping lane keys fixed"
|
:title "shorten this cel; ripple later drawings, keeping lane keys fixed"
|
||||||
:on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"]
|
:on-click (act [::ui/extend-hold -1])} "−"]
|
||||||
[:button {:disabled (not held?)
|
[:button {:disabled (not held?) :aria-label "hold +"
|
||||||
:title "extend this cel; ripple later drawings, keeping lane keys fixed"
|
:title "extend this cel; ripple later drawings, keeping lane keys fixed"
|
||||||
:on-click #(rf/dispatch [::ui/extend-hold 1])} "hold +"]
|
:on-click (act [::ui/extend-hold 1])} "+"]]]
|
||||||
(when @(rf/subscribe [::sub/lane-retry])
|
|
||||||
[:button {:on-click #(rf/dispatch [::ui/lane-retry])} "extend shot and apply"])
|
|
||||||
[:span.spacer]
|
[:span.spacer]
|
||||||
[:span.dim (str frame " / " frames)]
|
;; After the spacer, both of them: an offer that appears and a reading that
|
||||||
|
;; comes and goes must not shove the fixed controls sideways when they do.
|
||||||
|
(when @(rf/subscribe [::sub/lane-retry])
|
||||||
|
[:button.retry {:title "the command was refused because the shot is too short"
|
||||||
|
:on-click (act [::ui/lane-retry])} "extend shot and apply"])
|
||||||
;; Measured in the loop, not derived from the clock — the whole question
|
;; Measured in the loop, not derived from the clock — the whole question
|
||||||
;; while profiling is whether the painting keeps up with the clock, and a
|
;; while profiling is whether the painting keeps up with the clock, and a
|
||||||
;; number computed FROM the clock would answer itself.
|
;; number computed FROM the clock would answer itself. Shown only while it
|
||||||
[:span {:class (if (and drop (> drop 1.35)) "warn" "dim")}
|
;; has something to say: paused, it read "0.0 paint/s" for ever.
|
||||||
(str (.toFixed (or fps 0) 1) " paint/s"
|
(when (pos? (or fps 0))
|
||||||
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]]))
|
[:span.readout {:class (if (and drop (> drop 1.35)) "warn" "dim")}
|
||||||
|
(str (.toFixed fps 1) " paint/s"
|
||||||
|
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))])]))
|
||||||
|
|
||||||
(defn- takes?
|
(defn- takes?
|
||||||
"Whether the row at `target`, a `target-kind` node, can take the row being
|
"Whether the row at `target`, a `target-kind` node, can take the row being
|
||||||
|
|
|
||||||
|
|
@ -67,18 +67,53 @@ try {
|
||||||
return true;
|
return true;
|
||||||
})()`);
|
})()`);
|
||||||
await sleep(250);
|
await sleep(250);
|
||||||
|
// A command is named the same wherever it is drawn, and since the transport
|
||||||
|
// strip was consolidated it is drawn in one of two places: as a button in the
|
||||||
|
// strip, or as a row in one of the strip's menus. So the test asks for it by
|
||||||
|
// name and this finds it — opening each menu in turn to look — rather than the
|
||||||
|
// test knowing which menu anything ended up in. An icon button is matched on
|
||||||
|
// its `aria-label`, which is also what a screen reader is told it is.
|
||||||
|
const strip = '.pane.time .pane-head';
|
||||||
|
const named = label =>
|
||||||
|
`(b => b.textContent.trim() === ${JSON.stringify(label)}` +
|
||||||
|
` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`;
|
||||||
|
const shut = async () => {
|
||||||
|
await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(s => s.click()); return true })()`);
|
||||||
|
await sleep(120);
|
||||||
|
};
|
||||||
|
// Leaves the control on screen and returns what to select it with.
|
||||||
|
const reveal = async label => {
|
||||||
|
await shut();
|
||||||
|
if (await evaluate(`![...document.querySelectorAll('${strip} button')].find(${named(label)})`)) {
|
||||||
|
const menus = await evaluate(
|
||||||
|
`[...document.querySelectorAll('${strip} .menu-wrap > button')].map(b => b.textContent.trim())`);
|
||||||
|
let found = false;
|
||||||
|
for (const menu of menus) {
|
||||||
|
await evaluate(`(() => { [...document.querySelectorAll('${strip} .menu-wrap > button')]
|
||||||
|
.find(b => b.textContent.trim() === ${JSON.stringify(menu)}).click(); return true })()`);
|
||||||
|
await sleep(180);
|
||||||
|
if (await evaluate(`!![...document.querySelectorAll('.menu-item')].find(${named(label)})`)) { found = true; break; }
|
||||||
|
await shut();
|
||||||
|
}
|
||||||
|
assert(found, `a control named: ${label}`);
|
||||||
|
return '.menu-item';
|
||||||
|
}
|
||||||
|
return `${strip} button`;
|
||||||
|
};
|
||||||
const click = async label => {
|
const click = async label => {
|
||||||
|
const where = await reveal(label);
|
||||||
assert(await evaluate(`(() => {
|
assert(await evaluate(`(() => {
|
||||||
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
|
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
|
||||||
if (!b || b.disabled) return false;
|
if (!b || b.disabled) return false;
|
||||||
b.click(); return true;
|
b.click(); return true;
|
||||||
})()`), `enabled button: ${label}`);
|
})()`), `enabled control: ${label}`);
|
||||||
await sleep(180);
|
await sleep(180);
|
||||||
|
await shut();
|
||||||
};
|
};
|
||||||
const shot = async () => (await evaluate('laneSnapshot()'));
|
const shot = async () => (await evaluate('laneSnapshot()'));
|
||||||
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
|
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
|
||||||
.sort((a, b) => a.time.at - b.time.at);
|
.sort((a, b) => a.time.at - b.time.at);
|
||||||
await click('+ lane');
|
await click('lane');
|
||||||
await click('new drawing');
|
await click('new drawing');
|
||||||
await click('hold +');
|
await click('hold +');
|
||||||
await click('hold +');
|
await click('hold +');
|
||||||
|
|
@ -123,10 +158,15 @@ try {
|
||||||
document.querySelector('.tl-cel').click();
|
document.querySelector('.tl-cel').click();
|
||||||
})()`);
|
})()`);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
const enabled = async label => await evaluate(`(() => {
|
const enabled = async label => {
|
||||||
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
|
const where = await reveal(label);
|
||||||
|
const yes = await evaluate(`(() => {
|
||||||
|
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
|
||||||
return !!b && !b.disabled;
|
return !!b && !b.disabled;
|
||||||
})()`);
|
})()`);
|
||||||
|
await shut();
|
||||||
|
return yes;
|
||||||
|
};
|
||||||
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
|
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
|
||||||
await click('reuse');
|
await click('reuse');
|
||||||
s = await shot();
|
s = await shot();
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ const PROBE = `(() => {
|
||||||
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
|
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
|
||||||
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
|
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
|
||||||
hash: h >>> 0,
|
hash: h >>> 0,
|
||||||
frame: document.querySelector('.time .pane-head .dim')?.textContent ?? '',
|
frame: document.querySelector('.time .pane-head .at-frame')?.textContent ?? '',
|
||||||
// What the top bar says about the document. The media pool holds the OPEN
|
// What the top bar says about the document. The media pool holds the OPEN
|
||||||
// document's library now and no longer names documents at all, so the status
|
// document's library now and no longer names documents at all, so the status
|
||||||
// line is the page's own answer to "what am I looking at".
|
// line is the page's own answer to "what am I looking at".
|
||||||
|
|
@ -202,7 +202,7 @@ const PROBE = `(() => {
|
||||||
// knows which frame it wants, not how long the clip it is looking at happens to
|
// knows which frame it wants, not how long the clip it is looking at happens to
|
||||||
// be, and reading it here is one place instead of every call site.
|
// be, and reading it here is one place instead of every call site.
|
||||||
const SEEK = (f) => `(() => {
|
const SEEK = (f) => `(() => {
|
||||||
const read = document.querySelector('.time .pane-head .dim').textContent;
|
const read = document.querySelector('.time .pane-head .at-frame').textContent;
|
||||||
// Split, not a regex: this is inside a template literal, where an escaped
|
// Split, not a regex: this is inside a template literal, where an escaped
|
||||||
// slash collapses to a bare one and the two together open a line comment that
|
// slash collapses to a bare one and the two together open a line comment that
|
||||||
// eats the rest of the statement. The readout is "12 / 229" and nothing else.
|
// eats the rest of the statement. The readout is "12 / 229" and nothing else.
|
||||||
|
|
@ -216,7 +216,7 @@ const SEEK = (f) => `(() => {
|
||||||
clientY: box.top + box.height / 2,
|
clientY: box.top + box.height / 2,
|
||||||
}));
|
}));
|
||||||
ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
|
ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
|
||||||
return document.querySelector('.time .pane-head .dim').textContent;
|
return document.querySelector('.time .pane-head .at-frame').textContent;
|
||||||
})()`;
|
})()`;
|
||||||
|
|
||||||
// Everything the page has to say about loading, saving and opening. Read off the
|
// Everything the page has to say about loading, saving and opening. Read off the
|
||||||
|
|
@ -231,10 +231,15 @@ const STATUS = `[...document.querySelectorAll('.top .status, .pool .pane-body >
|
||||||
// pane each one lived in would be a second copy of the layout.
|
// pane each one lived in would be a second copy of the layout.
|
||||||
//
|
//
|
||||||
// `firstChild` is the label: a media-pool row has a second line in a child span,
|
// `firstChild` is the label: a media-pool row has a second line in a child span,
|
||||||
// so matching on textContent would never find "take".
|
// so matching on textContent would never find "take". `aria-label` is the same
|
||||||
|
// label for a control whose face is a glyph — the transport's play and pause —
|
||||||
|
// and it is the right fallback rather than a test hook, because it is already
|
||||||
|
// what that button is called for anybody not reading it with their eyes.
|
||||||
const CLICK = (label) => `(() => {
|
const CLICK = (label) => `(() => {
|
||||||
|
const want = ${JSON.stringify(label)};
|
||||||
const b = [...document.querySelectorAll('button')]
|
const b = [...document.querySelectorAll('button')]
|
||||||
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)});
|
.find((b) => (b.firstChild?.textContent ?? '').trim() === want
|
||||||
|
|| b.getAttribute('aria-label') === want);
|
||||||
if (!b || b.disabled) return false;
|
if (!b || b.disabled) return false;
|
||||||
b.click();
|
b.click();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -626,7 +631,7 @@ async function main() {
|
||||||
check(irisSlider !== null, 'the stage eye has an iris-size slider');
|
check(irisSlider !== null, 'the stage eye has an iris-size slider');
|
||||||
if (irisSlider) {
|
if (irisSlider) {
|
||||||
check(await page.eval(CLICK('play')), 'the stage starts playing');
|
check(await page.eval(CLICK('play')), 'the stage starts playing');
|
||||||
const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`);
|
const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`);
|
||||||
await page.send('Input.dispatchMouseEvent', {
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
||||||
});
|
});
|
||||||
|
|
@ -638,7 +643,7 @@ async function main() {
|
||||||
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
|
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
|
||||||
check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
|
check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
|
||||||
'the panel reports the affected feature and tier', debug);
|
'the panel reports the affected feature and tier', debug);
|
||||||
const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`);
|
const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`);
|
||||||
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
|
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
|
||||||
await page.eval(CLICK('pause'));
|
await page.eval(CLICK('pause'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,33 @@ audio { display: none; }
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
letter-spacing: .03em;
|
letter-spacing: .03em;
|
||||||
|
/* One line, always. A strip of controls that reflows to two lines resizes the
|
||||||
|
pane under it, and the pane under this one is the timeline. */
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-head .readout { color: var(--fg); }
|
||||||
|
|
||||||
|
/* A pick-one that looks like the other pick-ones. A native select in this strip
|
||||||
|
draws a tall white well and a chevron of the browser's choosing, which reads
|
||||||
|
as a form field among buttons; the app's own idiom for "choose one of these"
|
||||||
|
is `label ▾`, and this IS one of those. So: the select keeps the behaviour and
|
||||||
|
the keyboard, and borrows the look. The arrow is the wrapper's, because an
|
||||||
|
option list cannot carry one. */
|
||||||
|
.pick { position: relative; display: inline-flex; align-items: center; }
|
||||||
|
.pick > select {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--pane);
|
||||||
|
padding: 1px 15px 1px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pick > select:hover { background: #fff; }
|
||||||
|
.pick::after {
|
||||||
|
content: "▾";
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
color: var(--dim);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane-head .spacer { flex: 1; }
|
.pane-head .spacer { flex: 1; }
|
||||||
|
|
@ -173,6 +200,47 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
|
||||||
.dim { color: var(--dim); }
|
.dim { color: var(--dim); }
|
||||||
.warn { color: var(--warn); }
|
.warn { color: var(--warn); }
|
||||||
|
|
||||||
|
/* Buttons that are one control: a transport, a stepper, a mode picker. They
|
||||||
|
share their borders, so the group reads as a single object with parts rather
|
||||||
|
than as several things that happen to be adjacent — which is the whole claim
|
||||||
|
a segmented control makes, and the reason `timeline` and `cel sheet` are one
|
||||||
|
of these. `.seg` is `.group` with that meaning; they are drawn the same
|
||||||
|
because the difference is what the buttons do, not how they look.
|
||||||
|
|
||||||
|
The negative margin collapses the doubled border between two buttons into
|
||||||
|
one hairline. A pressed button then has to be raised, or the neighbour's
|
||||||
|
plain border covers half of its accent one. */
|
||||||
|
.group, .seg { display: flex; align-items: center; }
|
||||||
|
.group > button, .seg > button { border-radius: 0; margin-left: -1px; }
|
||||||
|
.group > button:first-child, .seg > button:first-child { margin-left: 0; border-radius: 2px 0 0 2px; }
|
||||||
|
.group > button:last-child, .seg > button:last-child { border-radius: 0 2px 2px 0; }
|
||||||
|
.group > button.on, .seg > button.on { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* The rule between two groups of unrelated controls. Shorter than the strip, so
|
||||||
|
it reads as a division of the row rather than as a wall across it. */
|
||||||
|
.sep { flex: 0 0 1px; width: 1px; height: 13px; background: var(--line); }
|
||||||
|
|
||||||
|
/* An icon button. The glyph is inked in `currentColor`, so `.on` colours it
|
||||||
|
with the same rule that colours a word. 11px to sit on 11px type. */
|
||||||
|
button.ico { padding: 2px 5px; line-height: 0; }
|
||||||
|
button.ico > svg { display: block; width: 11px; height: 11px; }
|
||||||
|
/* Play is the one control in the strip you aim at without looking. */
|
||||||
|
button.ico-play { padding-left: 8px; padding-right: 8px; }
|
||||||
|
|
||||||
|
/* `hold −` / `hold +`: one label over two steppers, because the word is shared
|
||||||
|
and repeating it in both buttons was most of their width. */
|
||||||
|
.stepper { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.stepper-label { color: var(--dim); }
|
||||||
|
.stepper button { padding: 1px 6px; }
|
||||||
|
|
||||||
|
/* A number that changes every frame. Tabular figures stop it twitching, and
|
||||||
|
stop the controls after it being nudged about as the count passes 9 and 99. */
|
||||||
|
.readout { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* The answer to a refusal rather than a standing command — `lane-retry` only
|
||||||
|
exists because something was declined and named this as the way through. */
|
||||||
|
button.retry { border-color: var(--sel); color: var(--sel); }
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
top bar */
|
top bar */
|
||||||
|
|
||||||
|
|
@ -296,6 +364,39 @@ a.brand { text-decoration: none; }
|
||||||
.undo > .undo-list { padding: 0 4px; margin-right: 4px; border-left: 0; border-radius: 0 2px 2px 0; }
|
.undo > .undo-list { padding: 0 4px; margin-right: 4px; border-left: 0; border-radius: 0 2px 2px 0; }
|
||||||
.menu.menu-left { left: 0; right: auto; }
|
.menu.menu-left { left: 0; right: auto; }
|
||||||
|
|
||||||
|
/* A menu dropping out of a PANE HEAD. Fixed and placed by `ui/menu` against the
|
||||||
|
button, because a pane clips its own overflow and an absolutely positioned
|
||||||
|
panel would be cut off at the pane's edge — invisibly, and more of it the
|
||||||
|
longer the menu gets. `top`/`bottom`/`left`/`max-height` all come from there.
|
||||||
|
|
||||||
|
Wide enough that a command's one-line explanation is one line. At 215px every
|
||||||
|
`.sub` in the cel menu wrapped, which doubled the panel's height for no gain:
|
||||||
|
the explanations are the reason these are menu rows and not buttons, so they
|
||||||
|
get the width to be read in. */
|
||||||
|
.menu.menu-drop {
|
||||||
|
position: fixed;
|
||||||
|
/* BOTH offsets on each axis are reset, and `ui/menu` sets exactly one of each
|
||||||
|
inline. Leaving `.menu`'s own `top` standing alongside an inline `bottom`
|
||||||
|
over-constrains the box, and a fixed box given both offsets takes its HEIGHT
|
||||||
|
from them — which, from a toolbar near the foot of the window, computes
|
||||||
|
negative and collapses the panel to its padding with the commands still
|
||||||
|
inside it. */
|
||||||
|
top: auto;
|
||||||
|
bottom: auto;
|
||||||
|
left: auto;
|
||||||
|
right: auto;
|
||||||
|
width: 272px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.menu-note { padding: 1px 6px 5px; }
|
||||||
|
|
||||||
|
/* A disabled row still says what the command is FOR. That is the whole trade:
|
||||||
|
a greyed button hides its reason in a `title`, a greyed row prints it. */
|
||||||
|
.menu-item:disabled { opacity: .5; cursor: default; }
|
||||||
|
.menu-item:disabled:hover { background: none; }
|
||||||
|
.menu-item { padding: 3px 6px; }
|
||||||
|
.menu-item .sub { font-size: 10px; line-height: 1.3; white-space: normal; }
|
||||||
|
|
||||||
/* The one way to hand the document to somebody else, so it looks like one. */
|
/* The one way to hand the document to somebody else, so it looks like one. */
|
||||||
button.share-button { background: var(--sel); color: #fff; border-color: var(--sel); font-weight: 600; padding: 1px 12px; }
|
button.share-button { background: var(--sel); color: #fff; border-color: var(--sel); font-weight: 600; padding: 1px 12px; }
|
||||||
button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue