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.ui :as ui]
|
||||
[arthur.footage.store :as store]
|
||||
[arthur.ui.icon :as icon]
|
||||
[arthur.ui.menu :as menu]
|
||||
[arthur.subs.playback :as playback]
|
||||
[arthur.subs.render :as render]
|
||||
[arthur.subs.ui :as sub]
|
||||
|
|
@ -223,6 +225,9 @@
|
|||
(defn- transport []
|
||||
(let [playing? @(rf/subscribe [::playback/playing?])
|
||||
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])
|
||||
frames @(rf/subscribe [::render/frames])
|
||||
{:keys [fps drop]} @player/meter
|
||||
|
|
@ -237,7 +242,7 @@
|
|||
lane? (node/lane? lane)
|
||||
cel? (and lane? (= :instance (:kind n)) (some? (node/source 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.
|
||||
shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip)
|
||||
[_ other] (:nodes sym)
|
||||
|
|
@ -250,82 +255,141 @@
|
|||
(lane/lane-frame clip sid (:id lane) owner-frame))
|
||||
insertable? (and lane? (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
|
||||
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
|
||||
[:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"]
|
||||
[:button {:on-click #(rf/dispatch [::pb/step -1])} "-1"]
|
||||
[:button {:on-click #(rf/dispatch [::pb/step 1])} "+1"]
|
||||
[:button {:class (when @(rf/subscribe [::playback/loop?]) "on")
|
||||
:on-click #(rf/dispatch [::pb/toggle-loop])} "loop"]
|
||||
[:button {:class (when @(rf/subscribe [::playback/muted?]) "on")
|
||||
:on-click #(rf/dispatch [::pb/toggle-mute])} "mute"]
|
||||
[:button {:class (when (= :timeline @(rf/subscribe [::sub/time-view])) "on")
|
||||
:on-click #(rf/dispatch [::ui/set-time-view :timeline])} "timeline"]
|
||||
[:button {:class (when (= :cel-sheet @(rf/subscribe [::sub/time-view])) "on")
|
||||
:on-click #(rf/dispatch [::ui/set-time-view :cel-sheet])} "cel sheet"]
|
||||
(doall
|
||||
(for [r [0.25 0.5 1.0 2.0 4.0]]
|
||||
^{:key r}
|
||||
;; ----------------------------------------------------------------- time
|
||||
;; Where the playhead is and how it moves. Nothing here depends on a
|
||||
;; selection, which is why it is the one group that is never greyed.
|
||||
[:div.group
|
||||
[:button.ico {:aria-label "go to start" :title "go to the first frame"
|
||||
:on-click (act [::pb/seek 0])} [icon/view :start]]
|
||||
[:button {:aria-label "−1" :title "step back one frame"
|
||||
:on-click (act [::pb/step -1])} "−1"]
|
||||
[:button.ico.ico-play {:aria-label (if playing? "pause" "play")
|
||||
:title (if playing? "pause" "play")
|
||||
:on-click (act [::pb/toggle])}
|
||||
[icon/view (if playing? :pause :play)]]
|
||||
[:button {:aria-label "+1" :title "step forward one frame"
|
||||
:on-click (act [::pb/step 1])} "+1"]]
|
||||
[: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,
|
||||
;; currentTime advances proportionally, and the derived frame follows. Slow
|
||||
;; motion cannot desync by construction.
|
||||
[:button {:class (when (== r rate) "on")
|
||||
:on-click #(rf/dispatch [::pb/set-rate r])}
|
||||
(case r 1.0 "1x" 0.5 "½" 0.25 "¼" 2.0 "2x" 4.0 "4x" (str r))]))
|
||||
[:button {:title "a new empty symbol inside the selected instance, or beside the selected node, or in the open symbol"
|
||||
:on-click #(rf/dispatch [::ui/new-symbol])}
|
||||
"+ symbol"]
|
||||
[:button {:on-click #(rf/dispatch [::ui/new-lane])} "+ lane"]
|
||||
[:button {:disabled (not lane?)
|
||||
:title "append a new independent drawing to the selected lane"
|
||||
:on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"]
|
||||
[:button {:disabled (not insertable?)
|
||||
:title "a new drawing at the playhead; later drawings ripple later"
|
||||
:on-click #(rf/dispatch [::ui/insert-drawing])} "insert"]
|
||||
[:button {:disabled (not insertable?)
|
||||
:title "replace the drawing at the playhead; later drawings stay put"
|
||||
:on-click #(rf/dispatch [::ui/overwrite-drawing])} "overwrite"]
|
||||
[:button {:disabled (not splittable?)
|
||||
:title "cut this cel in two at the playhead; the picture does not change"
|
||||
:on-click #(rf/dispatch [::ui/split-cel])} "split"]
|
||||
[:button {:disabled (not cel?)
|
||||
:title "expose this same drawing again — one drawing, two cels"
|
||||
:on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"]
|
||||
[:button {:disabled (not cel?)
|
||||
:title "append a copy of this drawing, to draw the next one over it"
|
||||
:on-click #(rf/dispatch [::ui/duplicate-drawing])} "duplicate"]
|
||||
[:button {:disabled (not shared?)
|
||||
:title "give this cel its own copy; other cels keep sharing"
|
||||
:on-click #(rf/dispatch [::ui/make-unique])} "make unique"]
|
||||
[:button {:disabled (not splittable?)
|
||||
:title "start this cel at the playhead; nothing else moves"
|
||||
:on-click #(rf/dispatch [::ui/trim-cel :in])} "trim in"]
|
||||
[:button {:disabled (not splittable?)
|
||||
:title "end this cel at the playhead; nothing else moves"
|
||||
:on-click #(rf/dispatch [::ui/trim-cel :out])} "trim out"]
|
||||
[:button {:disabled (not (and cel? (integer? at)))
|
||||
:title "put this cel at the playhead; refused if something is there"
|
||||
:on-click #(rf/dispatch [::ui/move-cel])} "move here"]
|
||||
[:button {:disabled (not cel?)
|
||||
:title "clear this cel's frames, leaving a gap; later drawings stay put"
|
||||
:on-click #(rf/dispatch [::ui/blank-cel])} "blank"]
|
||||
[:button {:disabled (not held?)
|
||||
[:span.pick
|
||||
[:select {:value rate :aria-label "playback speed" :title "playback speed"
|
||||
:on-change #(rf/dispatch [::pb/set-rate
|
||||
(js/parseFloat (.. % -target -value))])}
|
||||
(doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]]
|
||||
^{:key r} [:option {:value r} label]))]]
|
||||
[:span.sep]
|
||||
;; ----------------------------------------------------------------- view
|
||||
;; TWO VIEWS OF ONE THING, and you are always in exactly one. Two separate
|
||||
;; toggles said neither half of that; joined, with the one in force filled,
|
||||
;; the control is the statement.
|
||||
[:div.seg {:role "radiogroup" :aria-label "time view"}
|
||||
(doall
|
||||
(for [[k label title] [[:timeline "timeline" "lanes across, frames left to right"]
|
||||
[:cel-sheet "cel sheet" "frames down, one column per lane"]]]
|
||||
^{:key k}
|
||||
[:button {:class (when (= k view) "on") :role "radio"
|
||||
:aria-checked (= k view) :title title
|
||||
:on-click (act [::ui/set-time-view k])}
|
||||
label]))]
|
||||
[:span.sep]
|
||||
;; ------------------------------------------------------------- commands
|
||||
;; Fourteen of these, and all but two mean nothing without a selected lane
|
||||
;; or cel — so as buttons they were a permanent grey hedge across the strip
|
||||
;; with their explanations hidden in `title`. Grouped by what they change,
|
||||
;; with the explanation on the row, they cost three slots and read as a
|
||||
;; vocabulary. There is also room here for the correction commands, which
|
||||
;; `docs/lane-handoff.md` says are next.
|
||||
[menu/view
|
||||
{:label "new" :title "add to the document"
|
||||
:items [{:label "symbol"
|
||||
:sub "empty, inside the selected instance or beside the selected node"
|
||||
:on-click (act [::ui/new-symbol])}
|
||||
{:label "lane"
|
||||
:sub "a row that holds one drawing after another"
|
||||
:on-click (act [::ui/new-lane])}]}]
|
||||
[menu/view
|
||||
{:label "drawing" :title "what the lane exposes"
|
||||
:note "select a lane, or a cel in one"
|
||||
:items [{:label "new drawing" :disabled? (not lane?)
|
||||
: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"
|
||||
:on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"]
|
||||
[:button {:disabled (not held?)
|
||||
:on-click (act [::ui/extend-hold -1])} "−"]
|
||||
[:button {:disabled (not held?) :aria-label "hold +"
|
||||
:title "extend this cel; ripple later drawings, keeping lane keys fixed"
|
||||
:on-click #(rf/dispatch [::ui/extend-hold 1])} "hold +"]
|
||||
(when @(rf/subscribe [::sub/lane-retry])
|
||||
[:button {:on-click #(rf/dispatch [::ui/lane-retry])} "extend shot and apply"])
|
||||
:on-click (act [::ui/extend-hold 1])} "+"]]]
|
||||
[: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
|
||||
;; while profiling is whether the painting keeps up with the clock, and a
|
||||
;; number computed FROM the clock would answer itself.
|
||||
[:span {:class (if (and drop (> drop 1.35)) "warn" "dim")}
|
||||
(str (.toFixed (or fps 0) 1) " paint/s"
|
||||
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]]))
|
||||
;; number computed FROM the clock would answer itself. Shown only while it
|
||||
;; has something to say: paused, it read "0.0 paint/s" for ever.
|
||||
(when (pos? (or fps 0))
|
||||
[: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?
|
||||
"Whether the row at `target`, a `target-kind` node, can take the row being
|
||||
|
|
|
|||
|
|
@ -67,18 +67,53 @@ try {
|
|||
return true;
|
||||
})()`);
|
||||
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 where = await reveal(label);
|
||||
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;
|
||||
b.click(); return true;
|
||||
})()`), `enabled button: ${label}`);
|
||||
})()`), `enabled control: ${label}`);
|
||||
await sleep(180);
|
||||
await shut();
|
||||
};
|
||||
const shot = async () => (await evaluate('laneSnapshot()'));
|
||||
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
|
||||
.sort((a, b) => a.time.at - b.time.at);
|
||||
await click('+ lane');
|
||||
await click('lane');
|
||||
await click('new drawing');
|
||||
await click('hold +');
|
||||
await click('hold +');
|
||||
|
|
@ -123,10 +158,15 @@ try {
|
|||
document.querySelector('.tl-cel').click();
|
||||
})()`);
|
||||
await sleep(200);
|
||||
const enabled = async label => await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
|
||||
const enabled = async label => {
|
||||
const where = await reveal(label);
|
||||
const yes = await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
|
||||
return !!b && !b.disabled;
|
||||
})()`);
|
||||
await shut();
|
||||
return yes;
|
||||
};
|
||||
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
|
||||
await click('reuse');
|
||||
s = await shot();
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ const PROBE = `(() => {
|
|||
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
|
||||
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
|
||||
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
|
||||
// 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".
|
||||
|
|
@ -202,7 +202,7 @@ const PROBE = `(() => {
|
|||
// 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.
|
||||
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
|
||||
// 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.
|
||||
|
|
@ -216,7 +216,7 @@ const SEEK = (f) => `(() => {
|
|||
clientY: box.top + box.height / 2,
|
||||
}));
|
||||
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
|
||||
|
|
@ -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.
|
||||
//
|
||||
// `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 want = ${JSON.stringify(label)};
|
||||
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;
|
||||
b.click();
|
||||
return true;
|
||||
|
|
@ -626,7 +631,7 @@ async function main() {
|
|||
check(irisSlider !== null, 'the stage eye has an iris-size slider');
|
||||
if (irisSlider) {
|
||||
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', {
|
||||
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(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
|
||||
'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}`);
|
||||
await page.eval(CLICK('pause'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,33 @@ audio { display: none; }
|
|||
border-bottom: 1px solid var(--line);
|
||||
color: var(--dim);
|
||||
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; }
|
||||
|
|
@ -173,6 +200,47 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
|
|||
.dim { color: var(--dim); }
|
||||
.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 */
|
||||
|
||||
|
|
@ -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; }
|
||||
.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. */
|
||||
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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue