Tabs: open any symbol, and the clock follows it

Double-clicking a symbol in the pool, or an instance's row in the timeline,
opens it as a tab above the stage; the stage, the rows, the transport and new
shapes follow the open tab. Each tab gets a clock as long as it is: its own
mixed tracks, the document's audio for the symbol it opens on, or silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:54:58 -04:00
parent 6b41c6db94
commit c81f91c442
8 changed files with 154 additions and 7 deletions

View file

@ -165,3 +165,23 @@
[document sid fallback-url store]
(-> (buffer! document sid store)
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url)))))
(defn clock!
"Promise of the URL the transport should play while symbol `sid` is open.
The frame is derived from the audio element and from nothing else, so every
open symbol needs a sound exactly as long as it is. In order: its own placed
tracks, mixed; the document's audio file, for the symbol the document opens on
and only that one; and otherwise SILENCE of the symbol's length — a ten-frame
symbol played against the whole take's soundtrack would run ten frames and then
keep the clock going for minutes."
[document sid fallback-url store]
(-> (buffer! document sid store)
(.then (fn [buffer]
(cond
buffer (wav-url buffer)
(and fallback-url (= sid (clip/opens-on document))) fallback-url
:else (let [rate 44100
n (max 1 (js/Math.ceil (* rate (/ (clip/frames document sid)
(:fps document)))))]
(wav-url (.createBuffer (js/OfflineAudioContext. 1 1 rate) 1 n rate))))))))

View file

@ -10,7 +10,8 @@
traversals a second, which is the one genuinely expensive thing you can do to
a small app-db. If global interceptors are added later they are added to a
chain these events are excluded from, not to `reg-global-interceptor`."
(:require [arthur.clock :as clock]
(:require [arthur.audio.mix :as mix]
[arthur.clock :as clock]
[arthur.domain.clip :as clip]
[arthur.footage.store :as footage]
[re-frame.core :as rf]))
@ -133,3 +134,64 @@
{:db db
::pause! nil
::seek! [(fps db) (frames db) 0]})))
;; ---------------------------------------------------------------------------
;; tabs
;;
;; A tab is an open symbol. `:open` is the one on screen and `:tabs` the ones
;; beside it, in the order they were opened. Switching is `:open` and a clock:
;; the stage, the timeline rows, the transport's length and a new shape's home
;; all follow `:open` through their subscriptions without being told.
(defonce ^:private clock-url (atom nil))
(rf/reg-fx
::clock!
(fn [{:keys [id sid]}]
(let [{:keys [clip audio store]} (footage/entry id)]
(-> (mix/clock! clip sid audio store)
(.then #(rf/dispatch [::clock-ready id sid %]))
(.catch #(js/console.error %))))))
(rf/reg-event-fx
::clock-ready
(fn [{:keys [db]} [_ id sid url]]
(if (and (= id (:clip/current db)) (= sid (get-in db [:ui :open])))
(do
;; A blob URL made for the last tab is released when the next one lands,
;; and never the document's own file.
(when-let [old @clock-url]
(when (not= old url) (js/URL.revokeObjectURL old)))
(reset! clock-url (when (and (not= url (:audio (footage/entry id)))
(.startsWith url "blob:"))
url))
{:db (assoc-in db [:clip :audio] url)})
{})))
(rf/reg-event-fx
::open-symbol
(fn [{:keys [db]} [_ sid]]
(let [clip (:clip (footage/entry (:clip/current db)))]
(if (or (nil? (clip/symbol clip sid)) (= sid (get-in db [:ui :open])))
{}
{:db (-> db
(update-in [:ui :tabs] #(if (some #{sid} %) % (conj (vec %) sid)))
(assoc-in [:ui :open] sid)
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
::pause! nil
::clock! {:id (:clip/current db) :sid sid}}))))
(rf/reg-event-fx
::close-tab
(fn [{:keys [db]} [_ sid]]
;; The last tab cannot be closed: something is always on screen, because the
;; transport and the stage have no meaning without a symbol.
(let [tabs (get-in db [:ui :tabs])
left (vec (remove #{sid} tabs))]
(cond
(empty? left) {}
(not= sid (get-in db [:ui :open])) {:db (assoc-in db [:ui :tabs] left)}
:else (let [i (.indexOf tabs sid)]
{:db (assoc-in db [:ui :tabs] left)
:dispatch [::open-symbol (get left (min i (dec (count left))))]})))))

View file

@ -11,6 +11,7 @@
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool])))
(rf/reg-sub ::draft (fn [db _] (get-in db [:ui :draft])))
(rf/reg-sub ::tabs (fn [db _] (get-in db [:ui :tabs])))
(rf/reg-sub ::expanded (fn [db _] (get-in db [:ui :expanded])))
(rf/reg-sub ::knobs (fn [db _] (get-in db [:ui :knobs])))

View file

@ -24,7 +24,9 @@
button. The uploaded bytes, the extraction job and the decoded footage stay
separate records on the server, so dropping the same file twice does not decode
it twice."
(:require [arthur.events.footage :as footage]
(:require [arthur.domain.clip :as clip]
[arthur.events.footage :as footage]
[arthur.events.playback :as pb]
[arthur.events.ui :as ui]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
@ -62,7 +64,7 @@
(for [sid library
:let [sym (get-in clip [:symbols sid])]]
^{:key sid}
[item {:label (name sid)
[item {:label (clip/symbol-name clip sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes")
:on? (= selection [:symbol sid])
:draggable true
@ -71,9 +73,10 @@
(.setData (.-dataTransfer event) "text/plain"
(str "symbol:" (subs (str sid) 1)))
(set! (.. event -dataTransfer -effectAllowed) "copy"))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])}])))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])
:on-double-click #(rf/dispatch [::pb/open-symbol sid])}])))
(when (seq library)
[:div.dim "drag onto the stage to place"]))))
[:div.dim "double-click to open · drag to place"]))))
(defn- footage []
(let [{:keys [loading? available chosen]} @(rf/subscribe [::playback/footage])]

View file

@ -12,6 +12,7 @@
[arthur.ui.params :as params]
[arthur.ui.pool :as pool]
[arthur.ui.stage :as stage]
[arthur.ui.tabs :as tabs]
[arthur.ui.timeline :as timeline]
[arthur.ui.topbar :as topbar]
[re-frame.core :as rf]))
@ -33,6 +34,7 @@
[topbar/view]
[pool/view]
[:section.view
[tabs/view]
[palette/bar]
[stage/view]]
[params/view]

View file

@ -0,0 +1,30 @@
(ns arthur.ui.tabs
"The open symbols, one tab each, above the stage.
A tab is a symbol and nothing else — there is no document tab and no special
first one, because no symbol is special. Double-clicking a symbol in the pool,
or an instance's row in the timeline, opens it here."
(:require [arthur.domain.clip :as clip]
[arthur.events.playback :as pb]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[re-frame.core :as rf]))
(defn view []
(let [document @(rf/subscribe [::render/clip])
tabs @(rf/subscribe [::sub/tabs])
open @(rf/subscribe [::render/open])]
[:div.tabs
(doall
(for [sid tabs :when (clip/symbol document sid)]
^{:key (str sid)}
[:div {:class (str "tab" (when (= sid open) " on"))
:title (str sid)
:on-click #(rf/dispatch [::pb/open-symbol sid])}
[:span.name (clip/symbol-name document sid)]
(when (< 1 (count tabs))
[:button.close {:title "close this tab"
:on-click (fn [^js e]
(.stopPropagation e)
(rf/dispatch [::pb/close-tab sid]))}
"×"])]))]))

View file

@ -119,6 +119,7 @@
:label (node-label id n)
:kind :node
:node-kind (:kind n)
:of (:of n)
:select [:node sid id rpath]
:expandable? true
:expanded? open?
@ -194,12 +195,14 @@
(str (.toFixed (or fps 0) 1) " paint/s"
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]]))
(defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded?]}
(defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded? of]}
selection]
[:div {:class (str "tl-label" (when (and select (= select selection)) " on"))
:style {:padding-left (str (+ 4 (* 11 depth)) "px")}
:title label
:on-click #(when select (rf/dispatch [::ui/select select]))}
:on-click #(when select (rf/dispatch [::ui/select select]))
;; An instance's row opens the symbol it places, as a tab.
:on-double-click #(when of (rf/dispatch [::pb/open-symbol of]))}
[:button.tl-twist
{:disabled (not expandable?)
:on-click (fn [^js e]