bread crumbs

This commit is contained in:
Your Name 2026-10-01 00:27:01 -04:00
parent 5fb04f6a7d
commit 3dbbe285fc
5 changed files with 254 additions and 15 deletions

View 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])}]}]]))

View file

@ -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]])

View file

@ -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"

View file

@ -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);

View file

@ -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 */