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);
|
||||
|
|
|
|||
|
|
@ -81,10 +81,14 @@ audio { display: none; }
|
|||
display: grid;
|
||||
height: 100%;
|
||||
grid-template-columns: var(--label) minmax(0, 1fr) 250px;
|
||||
grid-template-rows: 30px minmax(0, 1fr) 232px;
|
||||
/* The location bar is its own row and takes its height from the stage, not
|
||||
from the timeline: it exists to explain what the timeline is showing, so
|
||||
paying for it in timeline rows would be the wrong trade. */
|
||||
grid-template-rows: 30px minmax(0, 1fr) 21px 232px;
|
||||
grid-template-areas:
|
||||
"top top top"
|
||||
"pool view params"
|
||||
"loc loc loc"
|
||||
"time time time";
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
|
|
@ -94,6 +98,7 @@ audio { display: none; }
|
|||
.pool { grid-area: pool; }
|
||||
.view { grid-area: view; }
|
||||
.params { grid-area: params; }
|
||||
.loc { grid-area: loc; }
|
||||
.time { grid-area: time; }
|
||||
|
||||
/* Every pane is its own scroll container. `min-height: 0` is what lets a grid
|
||||
|
|
@ -659,6 +664,55 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
|||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
the location bar
|
||||
|
||||
Where you are, above the timeline that draws it. Chrome-coloured like a pane
|
||||
head, because that is what it is to the two temporal views below it — but it
|
||||
is not one of their heads, since it says the same thing whichever is showing. */
|
||||
|
||||
.loc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 7px;
|
||||
min-width: 0;
|
||||
background: var(--chrome);
|
||||
color: var(--dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crumbs { display: flex; align-items: center; gap: 1px; min-width: 0; overflow: hidden; }
|
||||
|
||||
/* A crumb is a place, not a command: no border and no fill until it is pointed
|
||||
at. The trail has to read as one sentence, and five outlined buttons in a row
|
||||
read as five things to press. */
|
||||
.crumb {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
color: var(--dim);
|
||||
}
|
||||
.crumb:hover:not(:disabled) { background: var(--sel-bg); color: var(--fg); }
|
||||
/* The last crumb is the selection itself. Weight and full contrast say so; the
|
||||
fill and accent border `button.on` would otherwise give it are undone here,
|
||||
because a filled crumb reads as a pressed control and the thing it marks is
|
||||
where you ARE, not something switched on. */
|
||||
.crumb.on { color: var(--fg); font-weight: 600; background: none; border-color: transparent; }
|
||||
.crumb.lane::before { content: "≡ "; color: var(--dim); font-weight: 400; }
|
||||
.crumb-sep { flex: 0 0 auto; color: var(--line); }
|
||||
|
||||
.loc-fact { flex: 0 0 auto; }
|
||||
.loc-fact::before, .loc-shared::before { content: "· "; color: var(--line); }
|
||||
.loc-shared { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; }
|
||||
.loc .spacer { flex: 1; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
timeline */
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue