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