bread crumbs
This commit is contained in:
parent
5fb04f6a7d
commit
3dbbe285fc
5 changed files with 254 additions and 15 deletions
182
frontend/src/arthur/ui/location.cljs
Normal file
182
frontend/src/arthur/ui/location.cljs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
(ns arthur.ui.location
|
||||
"The location bar: where you are, and therefore where an edit would land.
|
||||
|
||||
`lane-model.md`, under *UX: location, selection, and controls*, asks for a bar
|
||||
above the timeline carrying three things — the breadcrumb, the creation
|
||||
controls, and the shared-content context — and this is it. Until now the
|
||||
answer to \"which symbol am I editing, five levels down a nested take\" was to
|
||||
read the timeline's indentation and infer, which is exactly the inference the
|
||||
document can do for you.
|
||||
|
||||
THE TRAIL IS READ OFF THE DOCUMENT, NOT REMEMBERED. It is the selection's row
|
||||
path — the vector `nest/inside` reduces over for a frame and a matrix, and the
|
||||
one `ui/select` carries — with each symbol's own parent chain filled in; see
|
||||
`trail`. So the bar states a fact about where the selection IS rather than
|
||||
where somebody has clicked, and every crumb on it is a selection in its own
|
||||
right, which is what makes clicking one go back out to that level.
|
||||
|
||||
THE TIME IT PRINTS IS HONEST OR ABSENT. `lane-model.md` is explicit that holds
|
||||
and loops need a true description instead of a fictitious unique global frame,
|
||||
so the readout comes from `nest/inside` — which samples forward through holds
|
||||
and refuses to invent an invertible map through a loop — and where that has no
|
||||
answer this says so instead of computing one."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.subs.render :as render]
|
||||
[arthur.subs.ui :as sub]
|
||||
[arthur.ui.menu :as menu]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(defn- crumb-label
|
||||
"What to call a node in the trail.
|
||||
|
||||
An INSTANCE is named after the symbol it places, because crossing one is how
|
||||
you got further in and the symbol is what you are now inside — `main ▸ head ▸
|
||||
mouth` is the useful sentence, and `main ▸ 3f2a91c0 ▸ …` is not. Everything
|
||||
else is named as the timeline names it: `:name` when it has one, and a legible
|
||||
stand-in when it has not."
|
||||
[clip id n]
|
||||
(let [of (node/source n)]
|
||||
(or (when of (:name (clip/symbol clip of)))
|
||||
(:name n)
|
||||
(when of (name of))
|
||||
(if (keyword? id) (subs (str id) 1) (subs (str id) 0 8)))))
|
||||
|
||||
(defn trail
|
||||
"The crumbs from symbol `sid` down to the end of row path `path`, the symbol
|
||||
first. Each carries the selection that names it, so clicking one goes back out
|
||||
to that level.
|
||||
|
||||
TWO KINDS OF NESTING, and the trail has to show both. A row path crosses
|
||||
INSTANCES — each step is a symbol entered — but within one symbol it names
|
||||
only the node at the end, because `rows` presents a symbol's nodes flat: a cel
|
||||
is addressed `[cel]` and not `[lane cel]`, since a cel is not a row. So inside
|
||||
each symbol the walk takes the node's own ancestry as well, which is what puts
|
||||
the lane a cel sits in on the trail — the thing you are most obviously nested
|
||||
in, and the one the path alone never mentions.
|
||||
|
||||
A path that has gone stale — the node deleted under it — stops the walk where
|
||||
it stops being true rather than inventing the rest."
|
||||
[clip sid path]
|
||||
(loop [in sid, left (seq path), so-far [], out [{:kind :symbol :sid sid
|
||||
:label (clip/symbol-name clip sid)}]]
|
||||
(let [id (first left)
|
||||
n (when id (get-in clip [:symbols in :nodes id]))]
|
||||
(if-not n
|
||||
out
|
||||
(let [nodes (:nodes (clip/symbol clip in))
|
||||
;; Root first, the node itself last. Every one of them is a row of
|
||||
;; this symbol in its own right, so each addresses as the path so
|
||||
;; far with that id on the end.
|
||||
chain (rseq (symbol/lineage nodes id))]
|
||||
(recur (or (node/source n) in) (next left) (conj so-far id)
|
||||
(into out
|
||||
(map (fn [a]
|
||||
(let [m (get nodes a)]
|
||||
{:kind (:kind m)
|
||||
:lane? (node/lane? m)
|
||||
:sid in :id a :of (node/source m)
|
||||
:label (crumb-label clip a m)
|
||||
:select [:node in a (conj so-far a)]})))
|
||||
chain)))))))
|
||||
|
||||
(defn- whereabouts
|
||||
"The one line of context beside the trail: which frame of its own the selection
|
||||
is showing, and whether that mapping is honest. Nil where there is nothing
|
||||
true to say.
|
||||
|
||||
WORTH SAYING BECAUSE IT IS NOT THE PLAYHEAD. Every instance between the open
|
||||
symbol and the selection carries a time map, so a shape six levels down is
|
||||
showing its frame 6 while the transport reads 7 — a difference nobody can do
|
||||
in their head and the one the transport cannot report, because the transport
|
||||
belongs to the open symbol."
|
||||
[clip n inside]
|
||||
(let [{inner :sid f :frame t :time} inside
|
||||
held? (and n (= :instance (:kind n)) (zero? (:speed (node/playback-of n))))
|
||||
len (when inner (clip/frames clip inner))]
|
||||
(cond
|
||||
(nil? n) nil
|
||||
(nil? inside) "not on screen on this frame"
|
||||
(number? f)
|
||||
(str (if held? "held on frame " "frame ") f
|
||||
;; A node that places a symbol has that symbol's length to be a frame
|
||||
;; OF; a shape has only its own frame, and inventing a denominator for
|
||||
;; it would be inventing a fact.
|
||||
(when len (str " of " len))
|
||||
;; `nest/inside` gives `:time` only where the map back out is
|
||||
;; invertible, and a HOLD is one of the things that makes it not —
|
||||
;; but "held" has already said that, and repeating it as a caveat
|
||||
;; would put the warning on the ordinary case. What is left to warn
|
||||
;; about is a cel whose frames come round again, where the frame
|
||||
;; above is being drawn more than once and names no single frame of
|
||||
;; the open symbol.
|
||||
(when (and (nil? t) (not held?)) " · repeats; no single frame above"))
|
||||
:else nil)))
|
||||
|
||||
(defn- shared-with
|
||||
"How many places in the document use the same drawing as `n`, or nil where it
|
||||
places none. Counted rather than flagged, because \"used in 4 places\" is the
|
||||
fact somebody needs before deciding to decouple one — and counted over every
|
||||
node that places it, not only over cels, because a drawing reused as a plain
|
||||
instance somewhere else is just as shared and `make unique` is just as much
|
||||
the answer."
|
||||
[clip n]
|
||||
(when-let [of (and n (node/source n))]
|
||||
(count (for [[_ sym] (:symbols clip)
|
||||
[_ other] (:nodes sym)
|
||||
:when (= of (node/source other))]
|
||||
other))))
|
||||
|
||||
(defn view []
|
||||
(let [clip @(rf/subscribe [::render/clip])
|
||||
open @(rf/subscribe [::render/open])
|
||||
selection @(rf/subscribe [::sub/selection])
|
||||
inside @(rf/subscribe [::sub/selected-local])
|
||||
[kind sid id path] selection
|
||||
n (when (= :node kind) (get-in clip [:symbols sid :nodes id]))
|
||||
crumbs (trail clip open (if (and n (seq path)) path (when n [id])))
|
||||
last-i (dec (count crumbs))
|
||||
shared (shared-with clip n)
|
||||
says (whereabouts clip n inside)]
|
||||
[:section.loc
|
||||
[:nav.crumbs {:aria-label "editing location"}
|
||||
(doall
|
||||
(for [[i {:keys [label select lane?] crumb-kind :kind}] (map-indexed vector crumbs)]
|
||||
^{:key i}
|
||||
[:<>
|
||||
(when (pos? i) [:span.crumb-sep "▸"])
|
||||
[:button.crumb
|
||||
{:class (str (when (= i last-i) "on") (when lane? " lane"))
|
||||
;; The root crumb is the open symbol, and going out to it is having
|
||||
;; nothing selected — which is a real state, not an absence of one.
|
||||
:title (if (zero? i)
|
||||
"the open symbol — clear the selection"
|
||||
(str label " · " (if lane? "lane" (name crumb-kind))))
|
||||
:on-click #(rf/dispatch [::ui/select (when (pos? i) select)])}
|
||||
label]]))]
|
||||
(when says
|
||||
[:span.loc-fact {:title "the frame this selection is showing, in its own time"}
|
||||
says])
|
||||
;; Offered where it means something and nowhere else, which is also what
|
||||
;; makes it an indicator: the row only appears when the drawing IS shared.
|
||||
(when (and shared (< 1 shared))
|
||||
[:span.loc-shared
|
||||
(str "used in " shared " places")
|
||||
[:button.link {:title "give this cel its own copy; other cels keep sharing"
|
||||
:on-click #(rf/dispatch [::ui/make-unique])}
|
||||
"make unique"]])
|
||||
[:span.spacer]
|
||||
;; CREATION LIVES HERE because this bar is what says where it would land.
|
||||
;; `lane-model.md`: "Creation controls next to the breadcrumb act in that
|
||||
;; explicit location." Both commands read the selection, and the trail to
|
||||
;; the left of them is that selection written out.
|
||||
[menu/view
|
||||
{:label "new" :title "add to the document, at the location named on the left"
|
||||
:items [{:label "symbol"
|
||||
:sub "empty, inside the selected instance or beside the selected node"
|
||||
:on-click #(rf/dispatch [::ui/new-symbol])}
|
||||
{:label "lane"
|
||||
:sub "a row that holds one drawing after another"
|
||||
:on-click #(rf/dispatch [::ui/new-lane])}]}]]))
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
(ns arthur.ui.shell
|
||||
"The window: one grid, five panes, and the audio element.
|
||||
"The window: one grid, five panes, a location bar, and the audio element.
|
||||
|
||||
Nothing else. Each pane owns its own subscriptions, so this component re-renders
|
||||
only when the grid itself would change — which is never. The picture is put on
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
[arthur.events.playback :as pb]
|
||||
[arthur.subs.playback :as playback]
|
||||
[arthur.subs.render :as render]
|
||||
[arthur.ui.location :as location]
|
||||
[arthur.ui.palette :as palette]
|
||||
[arthur.ui.params :as params]
|
||||
[arthur.ui.pool :as pool]
|
||||
|
|
@ -52,6 +53,10 @@
|
|||
[palette/bar]
|
||||
[stage/view]]
|
||||
[params/view]
|
||||
;; Above the timeline rather than inside it: the bar says where an edit would
|
||||
;; land, which is a fact about the SELECTION and not about either temporal
|
||||
;; view, and both views are drawn below it unchanged.
|
||||
[location/view]
|
||||
[timeline/view]
|
||||
[convert/view]
|
||||
[audio]])
|
||||
|
|
|
|||
|
|
@ -316,14 +316,9 @@
|
|||
;; 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])}]}]
|
||||
;; `new` is NOT here. Creating a symbol or a lane acts at the location the
|
||||
;; breadcrumb names, so it lives on the location bar beside it rather than
|
||||
;; among the commands that act on a cel. `ui/location`.
|
||||
[menu/view
|
||||
{:label "drawing" :title "what the lane exposes"
|
||||
:note "select a lane, or a cel in one"
|
||||
|
|
|
|||
|
|
@ -73,7 +73,10 @@ try {
|
|||
// 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';
|
||||
// Two bars carry commands: the location bar says where an edit lands and holds
|
||||
// what creates things there, the transport strip holds what acts on a cel.
|
||||
const bars = ['.loc', '.pane.time .pane-head'];
|
||||
const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', ');
|
||||
const named = label =>
|
||||
`(b => b.textContent.trim() === ${JSON.stringify(label)}` +
|
||||
` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`;
|
||||
|
|
@ -84,12 +87,12 @@ try {
|
|||
// 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)})`)) {
|
||||
if (await evaluate(`![...document.querySelectorAll('${within('button')}')].find(${named(label)})`)) {
|
||||
const menus = await evaluate(
|
||||
`[...document.querySelectorAll('${strip} .menu-wrap > button')].map(b => b.textContent.trim())`);
|
||||
`[...document.querySelectorAll('${within('.menu-wrap > button')}')].map(b => b.textContent.trim())`);
|
||||
let found = false;
|
||||
for (const menu of menus) {
|
||||
await evaluate(`(() => { [...document.querySelectorAll('${strip} .menu-wrap > button')]
|
||||
await evaluate(`(() => { [...document.querySelectorAll('${within('.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; }
|
||||
|
|
@ -98,7 +101,7 @@ try {
|
|||
assert(found, `a control named: ${label}`);
|
||||
return '.menu-item';
|
||||
}
|
||||
return `${strip} button`;
|
||||
return within('button');
|
||||
};
|
||||
const click = async label => {
|
||||
const where = await reveal(label);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue