diff --git a/frontend/src/arthur/ui/location.cljs b/frontend/src/arthur/ui/location.cljs new file mode 100644 index 0000000..2e5b874 --- /dev/null +++ b/frontend/src/arthur/ui/location.cljs @@ -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])}]}]])) diff --git a/frontend/src/arthur/ui/shell.cljs b/frontend/src/arthur/ui/shell.cljs index 70a0608..df0b605 100644 --- a/frontend/src/arthur/ui/shell.cljs +++ b/frontend/src/arthur/ui/shell.cljs @@ -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]]) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 04b9011..9c57e26 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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" diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs index f9c64f3..9cffb1e 100644 --- a/frontend/test/browser/lane.mjs +++ b/frontend/test/browser/lane.mjs @@ -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); diff --git a/static/arthur/app.css b/static/arthur/app.css index 6958fcd..38dca0b 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -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 */