diff --git a/clips/migrations/0012_sound_label.py b/clips/migrations/0012_sound_label.py
new file mode 100644
index 0000000..7baee07
--- /dev/null
+++ b/clips/migrations/0012_sound_label.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.17 on 2026-10-01 04:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clips', '0011_occurrence_schema'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='sound',
+ name='label',
+ field=models.CharField(blank=True, help_text='what a person called it; the filename when empty. Separate from `filename` because the name on disk is a fact about the upload and renaming must not rewrite it', max_length=200),
+ ),
+ ]
diff --git a/clips/models.py b/clips/models.py
index 6933782..7b56a99 100644
--- a/clips/models.py
+++ b/clips/models.py
@@ -60,6 +60,12 @@ class Sound(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for")
filename = models.CharField(max_length=255)
+ label = models.CharField(
+ max_length=200, blank=True,
+ help_text="what a person called it; the filename when empty. Separate "
+ "from `filename` because the name on disk is a fact about the "
+ "upload and renaming must not rewrite it",
+ )
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
created = models.DateTimeField(auto_now_add=True)
diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py
index 6925190..2d58317 100644
--- a/clips/tests/test_api.py
+++ b/clips/tests/test_api.py
@@ -657,6 +657,64 @@ class FootageTests(TestCase):
with self.assertRaisesMessage(CommandError, "refusing an inaccurate footage"):
call_command("ingest_bundle", str(root), stdout=StringIO())
+ def test_footage_can_be_renamed_and_falls_back_when_cleared(self):
+ """A LABEL IS THE ONE FIELD A CLIENT MAY WRITE ON FOOTAGE. The rest is a
+ description of bytes that are content-addressed and immutable, so a
+ rename that could reach `frames` or `digest` would let the pool's name
+ for a clip contradict the clip."""
+ footage = self.ingest(self.bundle())
+ self.assertEqual("IMG_8608.MOV", self.client.get(
+ f"/api/footage/{footage.id}").json()["label"])
+
+ renamed = self.client.patch(f"/api/footage/{footage.id}",
+ json.dumps({"label": " the long take "}),
+ content_type="application/json")
+ self.assertEqual(200, renamed.status_code, renamed.content)
+ self.assertEqual("the long take", renamed.json()["label"])
+ # On the row, so every project listing this footage sees the new name.
+ footage.refresh_from_db()
+ self.assertEqual("the long take", footage.label)
+ self.assertEqual("the long take",
+ self.client.get("/api/footage").json()["footage"][0]["label"])
+
+ # Cleared gives back the name it was ingested under rather than nothing.
+ cleared = self.client.patch(f"/api/footage/{footage.id}",
+ json.dumps({"label": ""}),
+ content_type="application/json")
+ self.assertEqual("IMG_8608.MOV", cleared.json()["label"])
+ self.assertEqual(3, Footage.objects.get().frames)
+
+ def test_a_rename_that_names_no_label_is_refused(self):
+ footage = self.ingest(self.bundle())
+ refused = self.client.patch(f"/api/footage/{footage.id}",
+ json.dumps({"frames": 900}),
+ content_type="application/json")
+ self.assertEqual(400, refused.status_code)
+ self.assertEqual("a rename needs a label", refused.json()["error"])
+ self.assertEqual(3, Footage.objects.get().frames)
+
+ def test_a_sound_is_renamed_without_losing_the_name_it_arrived_as(self):
+ """`filename` is a fact about the upload and `label` is what a person
+ called it, which is why renaming does not write over the first one."""
+ digest, size = blobs.write_stream([b"RIFF....WAVEfmt "])
+ blob = Blob.objects.create(digest=digest, size=size, media_type="audio/wav")
+ sound = Sound.objects.create(blob=blob, filename="rec0012.wav", duration=2.5)
+
+ renamed = self.client.patch(f"/api/sounds/{sound.id}",
+ json.dumps({"label": "arthur, line 4"}),
+ content_type="application/json")
+ self.assertEqual(200, renamed.status_code, renamed.content)
+ self.assertEqual("arthur, line 4", renamed.json()["label"])
+ self.assertEqual("rec0012.wav", renamed.json()["filename"])
+ sound.refresh_from_db()
+ self.assertEqual("rec0012.wav", sound.filename)
+ self.assertEqual("arthur, line 4", sound.label)
+
+ self.client.patch(f"/api/sounds/{sound.id}", json.dumps({"label": " "}),
+ content_type="application/json")
+ self.assertEqual("rec0012.wav",
+ self.client.get(f"/api/sounds/{sound.id}").json()["label"])
+
def test_the_footage_list_does_not_carry_every_url(self):
# A list of takes should not be a list of six hundred URLs each.
self.ingest(self.bundle())
diff --git a/clips/views.py b/clips/views.py
index 44edd2d..11c785e 100644
--- a/clips/views.py
+++ b/clips/views.py
@@ -226,10 +226,31 @@ def sources(request):
def _sound_json(row):
- return {"id": str(row.id), "label": row.filename, "duration": row.duration,
+ return {"id": str(row.id), "label": row.label or row.filename,
+ "filename": row.filename, "duration": row.duration,
"audio": f"/blob/{row.blob_id}"}
+def _relabel(request, row):
+ """PATCH one asset's display name.
+
+ A LABEL IS THE ONLY FIELD EITHER ROW LETS A CLIENT WRITE, and the body is
+ read for that key alone. Footage is content-addressed and its frame count,
+ rate and digest are facts about the bytes; an endpoint that merged whatever
+ it was sent would let a rename quietly contradict them. Blank clears it,
+ which puts the row back to the name it was uploaded under rather than
+ leaving it nameless.
+ """
+ data = _body(request)
+ if "label" not in data:
+ raise Bad("a rename needs a label")
+ label = str(data["label"] or "").strip()[:200]
+ if label != row.label:
+ row.label = label
+ row.save(update_fields=["label"])
+ return row
+
+
@require_http_methods(["GET", "POST"])
def sounds(request):
if request.method == "GET":
@@ -252,12 +273,18 @@ def sounds(request):
return JsonResponse({"error": str(exc)}, status=400)
-@require_http_methods(["GET"])
+@require_http_methods(["GET", "PATCH"])
def sound_detail(request, sound_id):
try:
- return JsonResponse(_sound_json(Sound.objects.get(id=sound_id)))
+ row = Sound.objects.get(id=sound_id)
except Sound.DoesNotExist:
return JsonResponse({"error": "no such sound"}, status=404)
+ try:
+ if request.method == "PATCH":
+ row = _relabel(request, row)
+ except Bad as exc:
+ return _error(exc)
+ return JsonResponse(_sound_json(row))
def _extraction_json(row):
@@ -367,12 +394,17 @@ def symbols(request):
return JsonResponse({"symbols": rows})
-@require_http_methods(["GET"])
+@require_http_methods(["GET", "PATCH"])
def footage_detail(request, footage_id):
try:
footage = Footage.objects.select_related("audio", "video", "stream").get(id=footage_id)
except Footage.DoesNotExist:
return JsonResponse({"error": "no such footage"}, status=404)
+ try:
+ if request.method == "PATCH":
+ footage = _relabel(request, footage)
+ except Bad as exc:
+ return _error(exc)
return JsonResponse(_footage_json(footage))
diff --git a/frontend/src/arthur/events/footage.cljs b/frontend/src/arthur/events/footage.cljs
index d353d29..32ff694 100644
--- a/frontend/src/arthur/events/footage.cljs
+++ b/frontend/src/arthur/events/footage.cljs
@@ -394,6 +394,57 @@
::choose
(fn [db [_ id]] (assoc-in db [:footage :chosen] id)))
+;; ---------------------------------------------------------------------------
+;; renaming an asset
+;;
+;; A SERVER WRITE, NOT A DOCUMENT EDIT, and so not on the undo list. Footage and
+;; sounds live beside projects rather than inside one — the pool's ALL ASSETS
+;; folder is exactly that — so a label is shared by every project that uses the
+;; row, and undoing an edit to this document must not reach out and rename
+;; something another one is showing.
+;;
+;; Written through optimistically. The lists in app-db are what the pool draws
+;; from; waiting for the round trip would leave the old name under the cursor for
+;; as long as the request takes, and the failure is visible and recoverable —
+;; `::failed` says so, and `::refresh` puts back whatever the server actually
+;; holds.
+
+(defn- relabelled
+ "Replace one row's `:label` in a list held by id."
+ [rows id label]
+ (mapv #(cond-> % (= id (:id %)) (assoc :label label)) rows))
+
+(rf/reg-fx
+ ::relabel!
+ (fn [{:keys [url label]}]
+ (-> (http/PATCH url #js {:label label})
+ (.then (fn [_]
+ ;; Only a CLEARED label needs the answer. The server's fallback
+ ;; is the name the file was uploaded under, which this client
+ ;; cannot reconstruct — footage falls back to its source and a
+ ;; sound to its filename — so the one case the optimistic write
+ ;; cannot guess is the one case that re-lists.
+ (when (empty? label) (rf/dispatch [::refresh]))))
+ (.catch (fn [error]
+ (rf/dispatch [::failed (or (ex-message error) (str error))])
+ (rf/dispatch [::refresh]))))))
+
+(rf/reg-event-fx
+ ::relabel
+ (fn [{:keys [db]} [_ kind id value]]
+ ;; `kind` is `:footage` or `:sound`: two resources with one field between
+ ;; them, and one event rather than two that differ by a path and a URL.
+ (let [label (string/trim (str value))
+ [key url] (case kind
+ :footage [:available (str "/api/footage/" id)]
+ :sound [:sounds (str "/api/sounds/" id)]
+ [nil nil])]
+ (if (or (nil? id) (nil? key))
+ {}
+ {:db (cond-> db
+ (seq label) (update-in [:footage key] relabelled id label))
+ ::relabel! {:url url :label label}}))))
+
(rf/reg-event-db
::progress
(fn [db [_ message]] (assoc-in db [:footage :status] message)))
diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs
index fe3f87b..18a76bb 100644
--- a/frontend/src/arthur/events/project.cljs
+++ b/frontend/src/arthur/events/project.cljs
@@ -601,6 +601,23 @@
(cond-> {:db db'}
(= key :fps) (assoc ::pb/seek! [value (pb/frames db') frame]))))))
+(rf/reg-event-db
+ ::rename-symbol
+ (fn [db [_ sid value]]
+ ;; A transaction, so one rename is one undo step: `edit/edit` alone would let
+ ;; a rename coalesce with whatever edit happened next.
+ ;;
+ ;; BLANK REMOVES THE NAME rather than storing an empty one. `clip/symbol-name`
+ ;; falls back to the id, so a symbol cleared of its name reads as `main`
+ ;; again instead of as a row with nothing on it — and the document carries no
+ ;; field it did not need.
+ (let [value (not-empty (str/trim (str value)))]
+ (if-not (clip/symbol (:clip (store/entry (:clip/current db))) sid)
+ db
+ (edit/transaction db #(if value
+ (assoc-in % [:symbols sid :name] value)
+ (update-in % [:symbols sid] dissoc :name)))))))
+
(rf/reg-event-db
::symbol-setting
(fn [db [_ sid key value]]
diff --git a/frontend/src/arthur/fx/http.cljs b/frontend/src/arthur/fx/http.cljs
index 1e5ac43..f4e3650 100644
--- a/frontend/src/arthur/fx/http.cljs
+++ b/frontend/src/arthur/fx/http.cljs
@@ -57,3 +57,4 @@
(defn POST [url body] (request! "POST" url body))
(defn POST-form [url body] (request! "POST" url body))
(defn PUT [url body] (request! "PUT" url body))
+(defn PATCH [url body] (request! "PATCH" url body))
diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs
index 4b97d3d..76a35f2 100644
--- a/frontend/src/arthur/subs/ui.cljs
+++ b/frontend/src/arthur/subs/ui.cljs
@@ -70,6 +70,22 @@
(when-let [pl (nest/placement clip st open (or path [id]) f)]
(assoc pl :node n :bounds ((pick/bounds-of clip st n) (:frame pl))))))))
+(rf/reg-sub
+ ::settled-clip
+ :<- [::render/clip-id]
+ :<- [::render/paint-revision]
+ (fn [[id _] _]
+ ;; The document AS WRITTEN, not as a drag currently has it — the same choice
+ ;; `::render/sounds` makes, for a sharper version of the same reason.
+ ;;
+ ;; `::render/clip` yields a fresh document on every pointer move of a timeline
+ ;; slide so the stage can follow it. The media pool draws its symbols as
+ ;; RASTERISED PICTURES, so subscribing to that would re-resolve and re-encode
+ ;; every thumbnail in the pool thirty times a second, to show a change no
+ ;; thumbnail of a symbol has any way to show. Nothing the pool lists —
+ ;; a symbol's name, its length, what places it — moves during a drag.
+ (:clip (store/entry id))))
+
(rf/reg-sub
::project-footage
:<- [::render/clip-id]
diff --git a/frontend/src/arthur/ui/canvas.cljs b/frontend/src/arthur/ui/canvas.cljs
index 681c1b2..91fc948 100644
--- a/frontend/src/arthur/ui/canvas.cljs
+++ b/frontend/src/arthur/ui/canvas.cljs
@@ -43,3 +43,34 @@
img (image-data-for ctx el w h)]
(raster/->rgba r palette-rgb 1 (.-data img))
(.putImageData ctx img 0 0)))))
+
+(defn ->png
+ "An indexed raster as a PNG data URL, expanded through `palette-rgb`.
+
+ For a THUMBNAIL, which is the one picture in this app that is not the preview.
+ Two consequences, and both are departures from the rule at the top of this
+ namespace:
+
+ An `
` rather than the canvas itself, because a pool of them is a list that
+ rebuilds whenever anything in the document changes, and a data URL is a value
+ the caller can cache against the document it was drawn from — a canvas is an
+ element that has to be found and repainted.
+
+ And therefore SMOOTHLY downscaled, because `image-rendering: pixelated` is a
+ rule about canvases. At a tenth of the stage's size nearest neighbour samples
+ one pixel in a hundred, and a drawing made of flat shapes a few pixels across
+ reduces to speckle; the silhouette is what makes a thumbnail recognisable, and
+ smoothing is what keeps it. The pixelated rule is load-bearing for the preview
+ that JUDGES the output. This is a picture to tell one name from another by."
+ [{:keys [w h] :as r} palette-rgb]
+ (let [el (js/document.createElement "canvas")]
+ (set! (.-width el) w)
+ (set! (.-height el) h)
+ ;; Deliberately not through `image-data-for`: that cache is keyed by element
+ ;; and this element is thrown away, so every thumbnail would leave a quarter
+ ;; of a megabyte in it that nothing can ever find again.
+ (let [ctx (.getContext el "2d")
+ img (.createImageData ctx w h)]
+ (raster/->rgba r palette-rgb 1 (.-data img))
+ (.putImageData ctx img 0 0))
+ (.toDataURL el "image/png")))
diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs
index d9fc04e..852bed2 100644
--- a/frontend/src/arthur/ui/pool.cljs
+++ b/frontend/src/arthur/ui/pool.cljs
@@ -1,5 +1,5 @@
(ns arthur.ui.pool
- "The media pool: what can be put into the open symbol, in two folders.
+ "The media pool: what can be put into the open symbol, in two scopes.
THIS PROJECT is the open document's own: every symbol in it — the open one
included, because none is special — and the video it uses or was given this
@@ -8,6 +8,21 @@
the split is the point: opening a project REPLACES what is on screen, where
everything in here is a thing to put INTO it.
+ TABS RATHER THAN TWO OPEN FOLDERS. Both scopes expanded cost eight lines of
+ heading before the first row, in a pane 210px wide; you are either looking at
+ what the document has or shopping what the server has, and the one case where
+ that costs you is covered — a search counts its hits in the scope you are not
+ looking at and puts the number on its tab.
+
+ THE MAIN TIMELINE LEADS, and it is `clip/opens-on`'s answer rather than a
+ name: the longest symbol nothing else places is the one the project plays and
+ the one the work happens in, so it gets the top of the pane and a row drawn at
+ a size you can read a pose off. Every other symbol is the list under it. That
+ is one list and not two — every symbol here IS a timeline, and heading a
+ section TIMELINES and the next one SYMBOLS would name the same thing twice.
+ None of it is `:main` being special either: rename it, place it inside
+ something else, and the pool follows the document.
+
Every row is a drag source, and what it carries says what it is:
`symbol:` for a symbol of this document, `import:||` for
one of another's, and `footage:` for video. The stage and the timeline are
@@ -16,6 +31,13 @@
A SOUND — mp3, wav — is `sound:`, and is dropped on the timeline, where it
becomes an audio node in the open symbol from the frame it lands on.
+ RENAMING IS TWO DIFFERENT OPERATIONS behind one affordance. A symbol's name is
+ a field of this document: it is an undoable edit, and blank gives it back its
+ id. Footage and sounds live beside projects rather than inside one, so their
+ label is a server write shared by every project that uses the row — see
+ `events.footage/relabel`. A symbol of ANOTHER project is renameable where it
+ lives and not from here.
+
THE WHOLE PANE IS THE DROP TARGET for a file. A video dropped anywhere in it
uploads and lands in THIS PROJECT's media, and goes no further: which frames of
it become a symbol, and what that symbol is called, is asked when it is dropped
@@ -23,6 +45,9 @@
footage stay separate records on the server, so dropping the same file twice
does not decode it twice."
(:require [arthur.domain.clip :as clip]
+ [arthur.domain.node :as node]
+ [arthur.domain.raster :as raster]
+ [arthur.export :as export]
[arthur.events.footage :as footage]
[arthur.events.playback :as pb]
[arthur.events.project :as project]
@@ -30,21 +55,117 @@
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
+ [arthur.ui.canvas :as canvas]
[arthur.ui.drag :as drag]
[clojure.string :as str]
[re-frame.core :as rf]
[reagent.core :as r]))
-(defn- item
- "One row. `opts` is merged last so a caller can add its handlers without this
- function growing a parameter per affordance."
- [{:keys [label sub on? disabled? thumb] :as opts}]
- [:button (merge {:class (str "pool-item" (when on? " on") (when thumb " media"))
- :disabled (boolean disabled?)
- :title label}
- (dissoc opts :label :sub :on? :disabled? :thumb))
- thumb
- [:span.text label (when sub [:span.sub sub])]])
+;; ---------------------------------------------------------------------------
+;; pictures
+
+(defonce ^:private thumbs
+ ;; `{:clip :urls {sid url-or-nil}}`,
+ ;; compared by IDENTITY. A document is an immutable value, so the same object
+ ;; means the same pictures and a different one means an edit landed — at which
+ ;; point the whole map goes rather than being diffed, because the cheap test is
+ ;; the wrong one: a symbol unchanged in itself changes picture when any symbol
+ ;; it places changes, and following that is `clip/resolver`'s job, not a
+ ;; cache's.
+ (atom {:clip nil :urls {}}))
+
+(defn- placed-in
+ "Where `sid` is placed, as `[host-symbol node-id]`, or nil for one nothing
+ places. Lowest ids first, so a symbol placed several times gets one picture and
+ the same one every time."
+ [document sid]
+ (first (for [h (sort-by str (keys (:symbols document)))
+ n (sort-by str (keys (get-in document [:symbols h :nodes])))
+ :when (contains? (node/sources (get-in document [:symbols h :nodes n])) sid)]
+ [h n])))
+
+(defn- draw-symbol
+ "`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser
+ the stage uses — so the picture in the pool is the picture, not a sketch of it.
+
+ THROUGH THE PLACEMENT, WHERE THERE IS ONE, and this is the whole subtlety.
+ Rooting at a symbol renders its DRAWING, in its own local space; a rotoscoped
+ face is stored head-local in units of one image height, so its numbers are
+ around 0..1 and the source-to-stage scale that turns them into stage pixels —
+ several hundred — lives on the `:face` group of the symbol that PLACES it. See
+ `freeze/face-placement` and \"What space geometry is in\" in
+ docs/animation-model.md. Rendered rooted at itself, a face is therefore correct
+ and under a pixel across, which is a true picture of nothing anybody wants to
+ look at.
+
+ So a placed symbol is drawn where it is placed, with everything else in that
+ host isolated away — `export/isolate`, whose docstring draws the same
+ distinction for the same reason. A symbol nothing places has no placement to
+ borrow and is rendered rooted at itself, which for the main timeline is exactly
+ right because the stage is its own.
+
+ nil rather than a throw when the frame will not resolve — a cycle, a missing
+ block, an instance naming a symbol that has gone. A pool is a list of names,
+ and a symbol that cannot be drawn today is still one you want listed, named
+ and draggable.
+
+ THE FIRST FRAME, unlike `video-thumb` below, which seeks past the black leader
+ most phone footage opens on. A symbol's frame 0 is authored: what is on it is
+ there because somebody put it there, and the frame a person names a symbol by
+ is the one it starts on."
+ [document sid store palette ramp]
+ (try
+ (when (clip/symbol document sid)
+ (let [[host id] (placed-in document sid)
+ document (cond-> document
+ host (update-in [:symbols host] export/isolate id))
+ root (or host sid)
+ resolve (clip/resolver document root store palette {})
+ [w h] (clip/stage document root)]
+ (-> (raster/make w h)
+ (raster/clear! (get palette :bg 0))
+ (raster/draw-ops! (resolve 0))
+ (canvas/->png ramp))))
+ (catch :default _ nil)))
+
+(defn- symbol-thumb
+ "`draw-symbol`, once per symbol per version of the document. Rasterising and
+ PNG-encoding the stage is milliseconds, and the pool redraws on every selection
+ click."
+ [document sid store palette ramp]
+ (when-not (identical? document (:clip @thumbs))
+ (reset! thumbs {:clip document :urls {}}))
+ (let [urls (:urls @thumbs)]
+ (if (contains? urls sid)
+ (get urls sid)
+ (let [url (draw-symbol document sid store palette ramp)]
+ (swap! thumbs assoc-in [:urls sid] url)
+ url))))
+
+(defn- picture
+ "A row's leading picture. One box at the stage's own proportions for all three
+ kinds of row, so the names line up down the list whatever is beside them, and
+ a row with no picture to show still holds the column open."
+ [url]
+ (if url
+ [:img.thumb {:src url :alt "" :draggable false}]
+ [:span.thumb]))
+
+(defn- video-thumb
+ "One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a
+ frame that is not the black leader most phone footage opens on; nothing is
+ played and nothing is decoded past it."
+ [{:keys [video]}]
+ (if video
+ ;; Sized here as well as in the stylesheet: a video element with no size
+ ;; is as big as its footage, and a portrait phone clip is 1440×1920.
+ [:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata"
+ :plays-inline true :tab-index -1
+ :style {:width 32 :height 20 :max-width 32 :max-height 20}}]
+ [:span.thumb]))
+
+;; ---------------------------------------------------------------------------
+;; the row
(defn- carrying
"The drag handlers for a row. `text` is what makes it a drag at all and what
@@ -58,130 +179,341 @@
(start!))
:on-drag-end (fn [_] (drag/done!))})
-(defn- thumbnail
- "One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a
- frame that is not the black leader most phone footage opens on; nothing is
- played and nothing is decoded past it."
- [{:keys [video]}]
- (if video
- ;; Sized here as well as in the stylesheet: a video element with no size
- ;; is as big as its footage, and a portrait phone clip is 1440×1920.
- [:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata"
- :plays-inline true :tab-index -1
- :style {:width 40 :height 30 :max-width 40 :max-height 30}}]
- [:span.thumb]))
+(defn- row
+ "One row of the pool: a picture, a name, and one number.
-(defn- footage-row [{:keys [id label frames fps video] :as f} chosen]
- [item (merge {:label label
- :sub (str frames "f @ " fps)
- :thumb [thumbnail f]
- :on? (= id chosen)
- :on-click #(rf/dispatch [::footage/choose id])}
- (carrying (str "footage:" id)
- #(drag/other! {:kind :footage :id id :label label
- :frames frames :fps fps :video video})))])
+ ONE LINE, because the pane is 210px wide and the pane under it is the timeline
+ — vertical space spent here is spent on something. The picture carries what the
+ row is, the name carries which one, and the single number is the one a person
+ needs before dragging it somewhere: how long it is. Everything else the row
+ knows goes in `:title`, where a pointer can ask for it.
+
+ THE WHOLE ROW IS THE BUTTON. Clicking selects and double-clicking opens, and
+ the target for both is the row — not the name, which is a word a few characters
+ wide that you would have to aim at.
+
+ WHICH IS WHY THE PENCIL IS A SPAN. It belongs beside the name, where it reads
+ as acting on that word rather than on the row; inside the button is the only
+ place that can be, and a button inside a button is not a thing HTML has. A span
+ with a click handler is, and it costs one thing: the pencil is not a tab stop.
+ F2 on the row is the keyboard path, which is the conventional one anyway, and
+ `aria-keyshortcuts` is what announces it. Stopping propagation is what keeps a
+ click on the pencil from also selecting the row underneath it.
+
+ `opts` beyond the keys destructured here is merged onto that drag surface, so a
+ caller adds its handlers without this growing a parameter per affordance.
+
+ THE MAIN TIMELINE is the one exception, and the only one. Border, fill and size
+ each say \"separate thing\", so spending them on every row flattens the list
+ into wallpaper; spent on the one row where the work happens, they say so. It
+ gets a picture big enough to read a pose off, and both its facts on a line of
+ its own under the name — `sub2` — rather than one of them out at the edge.
+
+ `rename` is `{:key :value :begin! :commit!}` and is what makes a row
+ renameable at all: a row without one has no pencil and does not answer F2."
+ [{:keys [label sub sub2 title thumb on? open? main? disabled? rename] :as opts}]
+ (let [{:keys [key value begin! commit!]} rename
+ editing? (boolean (and key (= key (:editing rename))))]
+ [:div.pool-row {:class (str (when main? "main ") (when on? "on ")
+ (when open? "open ") (when disabled? "disabled ")
+ (when editing? "editing"))}
+ (if editing?
+ ;; The input REPLACES the row rather than floating over it, so the list
+ ;; does not change height while you type in it.
+ [:input.pool-name
+ {:auto-focus true
+ :default-value value
+ :aria-label (str "rename " label)
+ :on-focus (fn [^js e] (.select (.-target e)))
+ ;; Blur commits, as the project title in `ui/topbar` does: clicking away
+ ;; from a half-finished rename means the name you typed, not nothing.
+ :on-blur (fn [^js e] (commit! (.. e -target -value)))
+ :on-key-down (fn [^js e]
+ (case (.-key e)
+ "Enter" (do (.preventDefault e) (commit! (.. e -target -value)))
+ ;; Escape has to stop editing WITHOUT the blur that
+ ;; follows it committing the draft.
+ "Escape" (do (.preventDefault e) (begin! nil) (.blur (.-target e)))
+ nil))}]
+ [:<>
+ [:button.pool-item
+ (merge {:disabled (boolean disabled?)
+ :title (or title label)
+ :aria-keyshortcuts (when begin! "F2")
+ :on-key-down (when begin!
+ (fn [^js e]
+ (when (= "F2" (.-key e))
+ (.preventDefault e)
+ (begin! key))))}
+ (dissoc opts :label :sub :sub2 :title :on? :open? :main?
+ :disabled? :thumb :rename))
+ thumb
+ [:span.text
+ ;; The label is `.text`'s first child and stays that way: it is how
+ ;; the browser test finds a row by the name a person reads.
+ [:span.name label]
+ (when (and main? sub2) [:span.detail sub2])]
+ (when begin!
+ [:span.pool-rename
+ {:title (str "rename " label " (F2)")
+ ;; Mouse-only by design — see the docstring. A `tabindex` here would
+ ;; make it interactive content inside a button, which is the thing
+ ;; being avoided, and F2 already reaches it from the keyboard.
+ :on-click (fn [^js e] (.stopPropagation e) (begin! key))}
+ "\u270E"])
+ ;; The main row says both its facts under the name instead.
+ (when (and sub (not main?)) [:span.sub sub])]])]))
+
+;; ---------------------------------------------------------------------------
+;; the rows of each kind
+
+(defn- symbol-row [document sid {:keys [clip-id store palette ramp selection open main rename]}]
+ (let [sym (clip/symbol document sid)
+ label (clip/symbol-name document sid)
+ nodes (count (:nodes sym))]
+ ^{:key (str sid)}
+ [row (merge {:label label
+ :sub (str (:frames sym) "f")
+ :title (str label " · " (:frames sym) " frames · " nodes
+ (if (= 1 nodes) " node" " nodes")
+ (when (= sid open) " · open")
+ " — double-click to open, drag to place")
+ :thumb [picture (symbol-thumb document sid store palette ramp)]
+ :sub2 (str (:frames sym) "f · " nodes (if (= 1 nodes) " node" " nodes"))
+ :on? (= selection [:symbol sid])
+ :open? (= sid open)
+ :main? (= sid main)
+ :rename (assoc rename
+ :key [:symbol sid]
+ :value label
+ :commit! (fn [value]
+ ((:begin! rename) nil)
+ (rf/dispatch [::project/rename-symbol sid value])))
+ :on-click #(rf/dispatch [::ui/select [:symbol sid]])
+ :on-double-click #(rf/dispatch [::pb/open-symbol sid])}
+ (carrying (str "symbol:" (subs (str sid) 1))
+ #(drag/symbol! clip-id sid open)))]))
+
+(defn- footage-row [{:keys [id label frames fps video] :as f} chosen rename]
+ ^{:key id}
+ [row (merge {:label label
+ :sub (str frames "f")
+ :title (str label " · " frames " frames @ " fps "fps"
+ " — drag onto the stage to make a symbol of it")
+ :thumb [video-thumb f]
+ :on? (= id chosen)
+ :rename (assoc rename
+ :key [:footage id]
+ :value label
+ :commit! (fn [value]
+ ((:begin! rename) nil)
+ (rf/dispatch [::footage/relabel :footage id value])))
+ :on-click #(rf/dispatch [::footage/choose id])}
+ (carrying (str "footage:" id)
+ #(drag/other! {:kind :footage :id id :label label
+ :frames frames :fps fps :video video})))])
(defn- sound-row
"An uploaded sound, or with `:footage?` a video's own — which is how a take's
sound goes back into its symbol after it was deleted there."
- [{:keys [id label duration footage? fps]} project-fps]
+ [{:keys [id label duration footage? fps]} project-fps rename]
(let [frames (js/Math.ceil (* duration project-fps))
[source length rate] (if footage?
[{:footage id} (js/Math.round (* duration fps)) (/ fps project-fps)]
[{:sound id} frames 1])]
- [item (merge {:label label
- :sub (str (.toFixed duration 1) "s · " frames "f")
- :thumb [:span.thumb.sound "♪"]}
- (carrying (str "sound:" id)
- #(drag/other! {:kind :sound :source source :label label
- :length length :rate rate :frames frames})))]))
+ ^{:key (str (when footage? "f") id)}
+ [row (merge {:label label
+ :sub (str (.toFixed duration 1) "s")
+ :title (str label " · " (.toFixed duration 1) "s · " frames " frames"
+ (when footage? " · this video's own sound")
+ " — drag onto the timeline")
+ :thumb [:span.thumb.sound "♪"]
+ ;; A video's own sound is named by the video. Renaming it here
+ ;; would rename the footage row two sections up, which is not
+ ;; what the pencil on a sound looks like it does.
+ :rename (when-not footage?
+ (assoc rename
+ :key [:sound id]
+ :value label
+ :commit! (fn [value]
+ ((:begin! rename) nil)
+ (rf/dispatch [::footage/relabel :sound id value]))))}
+ (carrying (str "sound:" id)
+ #(drag/other! {:kind :sound :source source :label label
+ :length length :rate rate :frames frames})))]))
-(defn- folder [title & children]
- (into [:details.pool-folder {:open true} [:summary title]] children))
+(defn- import-row [{:keys [cid symbol name frames]} pid]
+ ^{:key (str cid symbol)}
+ [row (merge {:label name
+ :sub (str frames "f")
+ :title (str name " · " frames " frames — drag to copy it into this project")
+ :thumb [picture nil]}
+ (carrying (str "import:" (str/join "|" [pid cid symbol]))
+ #(drag/other! {:kind :import :label name :frames frames
+ :project pid :cid cid :symbol symbol})))])
-(defn- group [title & children]
- (into [:div.pool-group {:class title} [:h2 title]] children))
+;; ---------------------------------------------------------------------------
+;; sections
-(defn- this-project []
- (let [document @(rf/subscribe [::render/clip])
- clip-id @(rf/subscribe [::render/clip-id])
- selection @(rf/subscribe [::sub/selection])
- open @(rf/subscribe [::render/open])
- media @(rf/subscribe [::sub/project-footage])
- ;; The project's videos' sounds first, then its uploaded ones.
- sounds (into (mapv (fn [{:keys [id label frames fps]}]
- {:id id :label label :footage? true :fps fps
- :duration (/ frames fps)})
- media)
- @(rf/subscribe [::sub/project-sounds]))
- {:keys [chosen]} @(rf/subscribe [::playback/footage])]
- (folder "this project"
- (group "symbols"
- (doall
- (for [sid (sort-by str (keys (:symbols document)))
- :let [sym (clip/symbol document sid)]]
- ^{:key (str sid)}
- [item (merge {:label (clip/symbol-name document sid)
- :sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes"
- (when (= sid open) " · open"))
- :on? (= selection [:symbol sid])
- :on-click #(rf/dispatch [::ui/select [:symbol sid]])
- :on-double-click #(rf/dispatch [::pb/open-symbol sid])}
- (carrying (str "symbol:" (subs (str sid) 1))
- #(drag/symbol! clip-id sid open)))]))
- [:div.dim "double-click to open · drag to place"])
- (group "media"
- (if (empty? media)
- [:div.dim "drop a video here"]
- (doall (for [f media] ^{:key (:id f)} [footage-row f chosen]))))
- (group "sounds"
- (if (empty? sounds)
- [:div.dim "drop an mp3 or wav here"]
- (doall (for [s sounds] ^{:key (:id s)} [sound-row s (:fps document)])))))))
+(defn- hit?
+ "Is `label` a hit for `query`? A case-folded substring, which is the whole of
+ what a list of a few dozen names needs."
+ [query label]
+ (or (str/blank? query)
+ (str/includes? (str/lower-case (str label)) query)))
-(defn- all-assets []
- (let [{:keys [available chosen sounds]} @(rf/subscribe [::playback/footage])
- fps (:fps @(rf/subscribe [::render/clip]))
- {:keys [symbols]} @(rf/subscribe [::project/assets])
- {:keys [id]} @(rf/subscribe [::playback/project])]
- (folder "all assets"
- (group "media"
- (if (empty? available)
- [:div.dim "nothing uploaded yet"]
- (doall (for [f available] ^{:key (:id f)} [footage-row f chosen]))))
- (group "sounds"
- (if (empty? sounds)
- [:div.dim "no sounds uploaded yet"]
- (doall (for [s sounds] ^{:key (:id s)} [sound-row s fps]))))
- (group "symbols"
- (let [others (remove #(= id (:project %)) symbols)]
- (if (empty? others)
- [:div.dim "no other saved projects"]
- (doall
- (for [[[pid pname] rows] (group-by (juxt :project :project-name) others)]
- ^{:key pid}
- ;; Closed: a server holds many projects, and a wall of
- ;; every symbol in every one buries the one you want.
- [:details.pool-project
- [:summary (str pname " · " (count rows)
- (if (= 1 (count rows)) " symbol" " symbols"))]
- (doall
- (for [{:keys [cid symbol name frames]} rows]
- ^{:key (str cid symbol)}
- [item (merge {:label name :sub (str frames "f")}
- (carrying (str "import:" (str/join "|" [pid cid symbol]))
- #(drag/other! {:kind :import :label name
- :frames frames
- :project pid :cid cid
- :symbol symbol})))]))]))))))))
+(defn- section
+ "A heading and its rows.
+
+ NOTHING AT ALL while a search is running and this section has no hit. A column
+ of headings over emptiness is the worst thing a filter can show you: the answer
+ to \"where is it\" should be the one section still holding rows."
+ [{:keys [title rows blank searching?]}]
+ (cond
+ (seq rows) [:div.pool-section [:h2 title] (into [:div.pool-rows] rows)]
+ searching? nil
+ :else [:div.pool-section [:h2 title] [:div.dim blank]]))
+
+(defn- sections
+ "Draw a scope's sections, or the one line that says a search found nothing in
+ it. `rows` is the scope's total so the empty answer can be given once rather
+ than per section."
+ [searching? rows children]
+ (if (and searching? (zero? rows))
+ ;; Its own class rather than `.dim`: a `.dim` directly inside `.pane-body`
+ ;; is how the page says what loading, saving and opening are doing — see
+ ;; the status line at the foot of `view` — and a search result is not that.
+ [:div.pool-empty "no match in this scope"]
+ (into [:<>] (map section) children)))
+
+(defn- this-project
+ "The open document's own, with the main timeline at the top and every other
+ symbol under it.
+
+ NOT \"TIMELINES AND SYMBOLS\". Every symbol in this model IS a timeline — it
+ has frames and nodes and you open it and work in it — so a pair of headings
+ naming those two things names one thing twice, and invites a reader to go
+ looking for the difference. There is one list of symbols. What is true is that
+ exactly one of them is where the work happens: `clip/opens-on`'s answer, the
+ longest symbol nothing else places. That gets the top of the pane and a row
+ drawn like the thing it is, and the rest of the library is the list below it.
+
+ Still not `:main` being special. The document says which symbol that is by its
+ structure; rename it, place it inside something else, and the pool follows."
+ [{:keys [document query searching? media sounds chosen rename main] :as ctx}]
+ (let [named? #(hit? query (clip/symbol-name document %))
+ top (when (and main (named? main)) main)
+ rest (filterv #(and (named? %) (not= main %))
+ (sort-by str (keys (:symbols document))))
+ media (filterv #(hit? query (:label %)) media)
+ sounds (filterv #(hit? query (:label %)) sounds)]
+ [sections searching?
+ (+ (if top 1 0) (count rest) (count media) (count sounds))
+ [{:title "project" :searching? searching?
+ :blank "nothing to open yet"
+ :rows (when top [(symbol-row document top ctx)])}
+ {:title "symbols" :searching? searching?
+ :blank "nothing else in the library"
+ :rows (mapv #(symbol-row document % ctx) rest)}
+ {:title "media" :searching? searching?
+ :blank "drop a video here"
+ :rows (mapv #(footage-row % chosen rename) media)}
+ {:title "sounds" :searching? searching?
+ :blank "drop an mp3 or wav here"
+ :rows (mapv #(sound-row % (:fps document) rename) sounds)}]]))
+
+(defn- all-assets
+ "Everything the server holds. Other projects' symbols stay grouped by project
+ and closed: a server holds many, and a wall of every symbol in every one buries
+ the one you want."
+ [{:keys [document query searching? rename chosen available all-sounds symbols
+ project-id]}]
+ (let [media (filterv #(hit? query (:label %)) available)
+ sounds (filterv #(hit? query (:label %)) all-sounds)
+ others (filterv #(and (not= project-id (:project %)) (hit? query (:name %)))
+ symbols)
+ grouped (sort-by (comp str second key) (group-by (juxt :project :project-name) others))]
+ [sections searching?
+ (+ (count media) (count sounds) (count others))
+ [{:title "media" :searching? searching?
+ :blank "nothing uploaded yet"
+ :rows (mapv #(footage-row % chosen rename) media)}
+ {:title "sounds" :searching? searching?
+ :blank "no sounds uploaded yet"
+ :rows (mapv #(sound-row % (:fps document) rename) sounds)}
+ {:title "symbols" :searching? searching?
+ :blank "no other saved projects"
+ :rows (for [[[pid pname] rows] grouped]
+ ^{:key (str pid)}
+ ;; Open while a search is running: you asked for these by name,
+ ;; and a hit behind a closed twisty is a hit you cannot see.
+ [:details.pool-project {:open (boolean searching?)}
+ [:summary (str pname " · " (count rows)
+ (if (= 1 (count rows)) " symbol" " symbols"))]
+ (into [:div.pool-rows] (map #(import-row % pid)) rows)])}]]))
+
+;; ---------------------------------------------------------------------------
+
+(defn- counts
+ "How many rows each scope holds for `query`.
+
+ This is what keeps the tabs from hiding anything. A tab shows its tally only
+ while a search is running and only on the scope you are NOT looking at, so the
+ one cost of collapsing two folders into two tabs — that a hit could be behind
+ the tab you did not pick — is paid off by a number, counted over the same
+ labels the rows are filtered by."
+ [{:keys [document query media sounds available all-sounds symbols project-id]}]
+ (let [n (fn [labels] (count (filter #(hit? query %) labels)))]
+ {:project (+ (n (map #(clip/symbol-name document %) (keys (:symbols document))))
+ (n (map :label media))
+ (n (map :label sounds)))
+ :assets (+ (n (map :label available))
+ (n (map :label all-sounds))
+ (n (map :name (remove #(= project-id (:project %)) symbols))))}))
(defn view []
(r/with-let [;; Counted, not a boolean. `dragenter`/`dragleave` fire for every
;; child element the pointer crosses, so a flag set on enter and
;; cleared on leave flickers off the moment the drag passes over a
;; row — the depth counter is what makes the outline steady.
- depth (r/atom 0)]
- (let [{:keys [loading? status]} @(rf/subscribe [::playback/footage])
+ depth (r/atom 0)
+ query (r/atom "")
+ scope (r/atom :project)
+ ;; Which row is being renamed, as `[kind id]`. One atom rather
+ ;; than a flag per row: exactly one name is ever being edited, and
+ ;; opening a second input has to close the first.
+ editing (r/atom nil)]
+ (let [{:keys [loading? status available sounds chosen]} @(rf/subscribe [::playback/footage])
+ document @(rf/subscribe [::sub/settled-clip])
+ clip-id @(rf/subscribe [::render/clip-id])
+ store @(rf/subscribe [::render/store])
+ palette @(rf/subscribe [::render/palette])
+ ramp @(rf/subscribe [::render/ramp])
+ selection @(rf/subscribe [::sub/selection])
+ open @(rf/subscribe [::render/open])
+ media @(rf/subscribe [::sub/project-footage])
+ {:keys [symbols]} @(rf/subscribe [::project/assets])
+ {project-id :id} @(rf/subscribe [::playback/project])
+ ;; The project's videos' sounds first, then its uploaded ones.
+ own-sounds (into (mapv (fn [{:keys [id label frames fps]}]
+ {:id id :label label :footage? true :fps fps
+ :duration (/ frames fps)})
+ media)
+ @(rf/subscribe [::sub/project-sounds]))
+ needle (str/lower-case (str/trim @query))
+ searching? (boolean (seq needle))
+ ctx {:document document :clip-id clip-id :store store :palette palette
+ :ramp ramp :selection selection :open open
+ :media media :sounds own-sounds :chosen chosen
+ :available (vec available) :all-sounds (vec sounds)
+ :symbols symbols :project-id project-id
+ :query needle :searching? searching?
+ ;; `clip/opens-on` and not `:main`: the longest symbol nothing
+ ;; else places is the timeline the work happens in, and it is the
+ ;; one row in this pane drawn big enough to read a pose off.
+ :main (clip/opens-on document)
+ :rename {:editing @editing :begin! #(reset! editing %)}}
+ tallies (counts ctx)
files? (fn [^js e] (some #{"Files"} (array-seq (.. e -dataTransfer -types))))]
[:section.pane.pool
{:class (when (pos? @depth) "dropping")
@@ -208,7 +540,27 @@
(when-let [file (aget (.. event -target -files) 0)]
(rf/dispatch [::footage/upload file])
(set! (.. event -target -value) "")))}]
+ [:div.pool-find
+ [:input.pool-search
+ {:id "pool-search" :type "search" :value @query
+ :placeholder "search" :aria-label "search the media pool"
+ :on-change #(reset! query (.. % -target -value))
+ :on-key-down (fn [^js e] (when (= "Escape" (.-key e)) (reset! query "")))}]
+ (when searching?
+ [:button.pool-clear {:title "clear the search" :aria-label "clear the search"
+ :on-click #(reset! query "")}
+ "×"])]
+ [:div.tabs
+ (doall
+ (for [[k label] [[:project "this project"] [:assets "all assets"]]]
+ ^{:key (str k)}
+ [:button.tab {:class (when (= k @scope) "on")
+ :on-click #(reset! scope k)}
+ label
+ ;; Only while searching, and only on the tab you are not looking at:
+ ;; it exists to say "the thing you asked for is over here".
+ (when (and searching? (not= k @scope))
+ [:span.count (get tallies k)])]))]
[:div.pane-body
- [this-project]
- [all-assets]
+ (if (= :project @scope) [this-project ctx] [all-assets ctx])
(when status [:div.dim status])]])))
diff --git a/static/arthur/app.css b/static/arthur/app.css
index 975b420..2dcc35f 100644
--- a/static/arthur/app.css
+++ b/static/arthur/app.css
@@ -416,67 +416,249 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.peer.guest { background: var(--dim); }
/* --------------------------------------------------------------------------
- media pool */
+ media pool
-/* Two folders, THIS PROJECT and ALL ASSETS, each holding groups. */
-.pool-folder { margin-bottom: 8px; }
-.pool-folder > summary {
- cursor: pointer;
- padding: 2px 0 4px;
- font-weight: 600;
- color: var(--fg);
+ A FILE LIST, AND IT IS 210px WIDE. Every row is one line — a picture at the
+ stage's own proportions, a name, one number — because the pane under this one
+ is the timeline and a row that costs two lines costs timeline rows. The row is
+ --row tall, which is a timeline row, so a pool of symbols and the rows they
+ end up on are the same size object.
+
+ One exception, deliberately: the main timeline. See `.pool-row.main`. */
+
+/* The search strip, between the pane head and the tabs. Its own band rather
+ than crowded into the head, which is one line and already holds the + . */
+.pool-find {
+ position: sticky;
+ top: 21px;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 5px;
+ background: var(--chrome);
+ border-bottom: 1px solid var(--hair);
}
-.pool-folder > .pool-group { padding-left: 8px; }
-.pool-project { margin-bottom: 2px; }
-.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
-.pool-project > .pool-item { margin-left: 10px; width: calc(100% - 10px); }
-.pool-group { margin-bottom: 9px; }
+.pool-search {
+ flex: 1;
+ min-width: 0;
+ height: 17px;
+ padding: 0 5px;
+ font: inherit;
+ color: var(--fg);
+ background: var(--pane);
+ border: 1px solid var(--line);
+ border-radius: 2px;
+}
-/* The one-line gloss under a group heading. Small and quiet: it answers "what
- is this list" once, for someone who has not read the model. */
-.pool-group > h2 + .dim { margin-bottom: 4px; }
+/* Safari draws its own clear button inside a search input, in its own idiom and
+ at its own size. The one next to it is this file's. */
+.pool-search::-webkit-search-decoration,
+.pool-search::-webkit-search-cancel-button { -webkit-appearance: none; }
-.pool-group > h2 {
- margin: 0 0 3px;
+.pool-clear {
+ flex: 0 0 auto;
+ width: 17px;
+ height: 17px;
+ padding: 0;
+ border: 0;
+ border-radius: 2px;
+ background: none;
+ color: var(--dim);
+ cursor: pointer;
+}
+.pool-clear:hover { background: var(--hair); color: var(--fg); }
+
+/* The scope tabs, in the vocabulary `ui/tabs` already established for symbols —
+ the same object, so two rows of tabs on one screen read as one idea. */
+.pool > .tabs { top: 45px; position: sticky; z-index: 2; }
+
+/* A tally, shown only on the tab you are NOT looking at and only while a search
+ is running: it says "what you asked for is over here". */
+.tab .count {
+ padding: 0 4px;
+ border-radius: 7px;
+ background: var(--sel-bg);
+ color: var(--sel);
+ font-variant-numeric: tabular-nums;
+}
+
+/* MEDIA, SOUNDS, TIMELINES. A heading is one line and it sticks, so scrolling a
+ long list never leaves you looking at rows with no idea what kind they are. */
+.pool-section { margin-bottom: 7px; }
+
+.pool-section > h2 {
+ position: sticky;
+ top: 69px;
+ z-index: 1;
+ margin: 0 0 2px;
+ padding: 1px 5px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
+ background: var(--pane);
}
-.pool-item {
- display: block;
- width: 100%;
- text-align: left;
- padding: 2px 6px;
+.pool-section > .dim { padding: 0 5px 2px; }
+
+.pool-rows { display: flex; flex-direction: column; }
+
+/* What a search found nothing to say. */
+.pool-empty { padding: 6px 5px; color: var(--dim); }
+
+.pool-project { margin: 0 0 1px 5px; }
+.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
+.pool-project > .pool-rows { margin-left: 8px; }
+
+/* THE ROW IS THE BUTTON. Click selects, double-click opens, and drag starts
+ anywhere across it — the whole strip, not the few characters of the name. The
+ wrapper carries the state classes and the frame; the button inside it fills
+ that frame edge to edge, so there is no dead strip anywhere along the row. */
+.pool-row {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ min-width: 0;
+ height: var(--row);
+ padding: 0 4px;
border: 1px solid transparent;
border-radius: 2px;
+}
+
+.pool-row:hover:not(.disabled) { background: #fff; }
+.pool-row.on { background: var(--sel-bg); border-color: var(--sel); }
+
+/* WHICH ONE IS OPEN, as a mark and not as the word "open" — a list this narrow
+ cannot spend twenty pixels of every name on a word that is true of one row.
+ The accent, because selection is the only thing it is ever used for and the
+ open symbol is what the rest of the screen is showing. */
+.pool-row.open { box-shadow: inset 2px 0 0 var(--sel); }
+
+.pool-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ text-align: left;
+ border: 0;
background: none;
+ color: inherit;
+ font: inherit;
cursor: grab;
+}
+
+/* Shrinks to the name, so the pencil sits against the end of the word rather
+ than out at the edge of the pane — and ellipses rather than pushing the
+ number off the row. */
+.pool-item .text { display: flex; flex-direction: column; flex: 0 1 auto; min-width: 0; }
+.pool-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* The number, at the far edge and never moving: `auto` on the left is what
+ pushes it there, and tabular figures are what stop it shuffling as the rows
+ scroll past. */
+.pool-item > .sub {
+ flex: 0 0 auto;
+ margin-left: auto;
+ padding-left: 4px;
+ color: var(--dim);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Every row leads with a picture, and they are all this box, so the names line
+ up down the list whatever is beside them. 16:10 is the stage's own ratio at
+ 320x200 — a thumbnail that is not the picture's shape is a thumbnail you have
+ to think about. */
+.pool-item .thumb {
+ flex: 0 0 32px;
+ width: 32px;
+ height: 20px;
+ object-fit: cover;
+ background: var(--stage);
+ border-radius: 1px;
+ pointer-events: none;
+}
+
+.pool-item .thumb.sound {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #d9d9d9;
+}
+
+/* THE MAIN TIMELINE, under the PROJECT heading and at the top of the pane.
+ `clip/opens-on`'s answer: the longest symbol nothing else places, which is
+ where the work happens. The one row in the pane given size, a second line and
+ a surface of its own — spent here rather than spread over every row, which is
+ what keeps the list below it a list. */
+.pool-row.main {
+ height: auto;
+ margin-bottom: 5px;
+ padding: 4px;
+ background: var(--sunk);
+ border-color: var(--hair);
+}
+
+.pool-row.main .thumb {
+ flex: 0 0 64px;
+ width: 64px;
+ height: 40px;
+}
+
+.pool-row.main .name { font-weight: 600; }
+
+/* Both facts on one line under the name. Beside a 64px picture there is room,
+ and this is the one row where a person wants more than the length before
+ they click. */
+.pool-row.main .detail {
+ color: var(--dim);
+ font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.pool-item:hover:not(:disabled) { background: #fff; }
-.pool-item.on { background: var(--sel-bg); border-color: var(--sel); }
-.pool-item .sub { display: block; color: var(--dim); }
-.pool-item .text { display: block; overflow: hidden; text-overflow: ellipsis; }
+/* The pencil. A span and not a button — see `ui/pool`'s `row` — so it needs its
+ own centring and cursor rather than inheriting a control's.
-/* A media row leads with one frame of the video. */
-.pool-item.media { display: flex; align-items: center; gap: 6px; }
-.pool-item .thumb {
- flex: 0 0 40px;
- width: 40px;
- height: 30px;
- max-width: 40px;
- max-height: 30px;
- object-fit: cover;
- background: var(--stage);
+ Hidden until the row is under the pointer or holds focus, because a column of
+ twenty pencils is a column of twenty pencils; `visibility` rather than
+ `display`, so it keeps its width and the name beside it does not reflow as the
+ pointer crosses the list. */
+.pool-rename {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 15px;
+ width: 15px;
+ height: 15px;
+ border-radius: 2px;
+ color: var(--dim);
+ line-height: 1;
+ cursor: pointer;
+ visibility: hidden;
+}
+
+.pool-row:hover .pool-rename,
+.pool-row:focus-within .pool-rename { visibility: visible; }
+.pool-rename:hover { background: var(--hair); color: var(--fg); }
+
+/* Renaming. The input REPLACES the row at the same height, so a list does not
+ jump while you type in it. */
+.pool-name {
+ flex: 1;
+ min-width: 0;
+ height: 17px;
+ padding: 0 4px;
+ font: inherit;
+ color: var(--fg);
+ background: #fff;
+ border: 1px solid var(--sel);
border-radius: 2px;
- pointer-events: none;
}
/* The whole pane is the drop target, so the cue has to be the pane and not a