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] [document sid fallback-url store]
(-> (buffer! document sid store) (-> (buffer! document sid store)
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url))))) (.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 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 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`." 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.domain.clip :as clip]
[arthur.footage.store :as footage] [arthur.footage.store :as footage]
[re-frame.core :as rf])) [re-frame.core :as rf]))
@ -133,3 +134,64 @@
{:db db {:db db
::pause! nil ::pause! nil
::seek! [(fps db) (frames db) 0]}))) ::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 ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool]))) (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 ::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 ::expanded (fn [db _] (get-in db [:ui :expanded])))
(rf/reg-sub ::knobs (fn [db _] (get-in db [:ui :knobs]))) (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 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 separate records on the server, so dropping the same file twice does not decode
it twice." 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.events.ui :as ui]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.render :as render] [arthur.subs.render :as render]
@ -62,7 +64,7 @@
(for [sid library (for [sid library
:let [sym (get-in clip [:symbols sid])]] :let [sym (get-in clip [:symbols sid])]]
^{:key sid} ^{:key sid}
[item {:label (name sid) [item {:label (clip/symbol-name clip sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes") :sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes")
:on? (= selection [:symbol sid]) :on? (= selection [:symbol sid])
:draggable true :draggable true
@ -71,9 +73,10 @@
(.setData (.-dataTransfer event) "text/plain" (.setData (.-dataTransfer event) "text/plain"
(str "symbol:" (subs (str sid) 1))) (str "symbol:" (subs (str sid) 1)))
(set! (.. event -dataTransfer -effectAllowed) "copy")) (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) (when (seq library)
[:div.dim "drag onto the stage to place"])))) [:div.dim "double-click to open · drag to place"]))))
(defn- footage [] (defn- footage []
(let [{:keys [loading? available chosen]} @(rf/subscribe [::playback/footage])] (let [{:keys [loading? available chosen]} @(rf/subscribe [::playback/footage])]

View file

@ -12,6 +12,7 @@
[arthur.ui.params :as params] [arthur.ui.params :as params]
[arthur.ui.pool :as pool] [arthur.ui.pool :as pool]
[arthur.ui.stage :as stage] [arthur.ui.stage :as stage]
[arthur.ui.tabs :as tabs]
[arthur.ui.timeline :as timeline] [arthur.ui.timeline :as timeline]
[arthur.ui.topbar :as topbar] [arthur.ui.topbar :as topbar]
[re-frame.core :as rf])) [re-frame.core :as rf]))
@ -33,6 +34,7 @@
[topbar/view] [topbar/view]
[pool/view] [pool/view]
[:section.view [:section.view
[tabs/view]
[palette/bar] [palette/bar]
[stage/view]] [stage/view]]
[params/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) :label (node-label id n)
:kind :node :kind :node
:node-kind (:kind n) :node-kind (:kind n)
:of (:of n)
:select [:node sid id rpath] :select [:node sid id rpath]
:expandable? true :expandable? true
:expanded? open? :expanded? open?
@ -194,12 +195,14 @@
(str (.toFixed (or fps 0) 1) " paint/s" (str (.toFixed (or fps 0) 1) " paint/s"
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]])) (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] selection]
[:div {:class (str "tl-label" (when (and select (= select selection)) " on")) [:div {:class (str "tl-label" (when (and select (= select selection)) " on"))
:style {:padding-left (str (+ 4 (* 11 depth)) "px")} :style {:padding-left (str (+ 4 (* 11 depth)) "px")}
:title label :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 [:button.tl-twist
{:disabled (not expandable?) {:disabled (not expandable?)
:on-click (fn [^js e] :on-click (fn [^js e]

View file

@ -308,6 +308,32 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
overflow: hidden; overflow: hidden;
} }
/* One tab per open symbol. Flat, like the pane heads, with the open one lifted
to the palette bar's colour so it reads as the thing the bar below belongs to. */
.tabs {
display: flex;
gap: 1px;
padding: 3px 6px 0;
background: var(--line);
overflow-x: auto;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: var(--sunk);
color: var(--dim);
border-radius: 3px 3px 0 0;
cursor: pointer;
white-space: nowrap;
}
.tab.on { background: var(--chrome); color: var(--fg); }
.tab .close { padding: 0 3px; border: 0; background: none; color: var(--dim); line-height: 1; }
.tab .close:hover { color: var(--fg); }
.palette-bar { .palette-bar {
display: flex; display: flex;
align-items: center; align-items: center;