From c81f91c44283087775c26bec1798d4e5e443c2ce Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Tue, 29 Sep 2026 12:54:58 -0400 Subject: [PATCH] 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 --- frontend/src/arthur/audio/mix.cljs | 20 ++++++++ frontend/src/arthur/events/playback.cljs | 64 +++++++++++++++++++++++- frontend/src/arthur/subs/ui.cljs | 1 + frontend/src/arthur/ui/pool.cljs | 11 ++-- frontend/src/arthur/ui/shell.cljs | 2 + frontend/src/arthur/ui/tabs.cljs | 30 +++++++++++ frontend/src/arthur/ui/timeline.cljs | 7 ++- static/arthur/app.css | 26 ++++++++++ 8 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 frontend/src/arthur/ui/tabs.cljs diff --git a/frontend/src/arthur/audio/mix.cljs b/frontend/src/arthur/audio/mix.cljs index a9e040e..29d4809 100644 --- a/frontend/src/arthur/audio/mix.cljs +++ b/frontend/src/arthur/audio/mix.cljs @@ -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)))))))) diff --git a/frontend/src/arthur/events/playback.cljs b/frontend/src/arthur/events/playback.cljs index 91b3125..9cc14b1 100644 --- a/frontend/src/arthur/events/playback.cljs +++ b/frontend/src/arthur/events/playback.cljs @@ -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))))]}))))) diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index b18a966..dded3b1 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -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]))) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index e609469..b86b06b 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -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])] diff --git a/frontend/src/arthur/ui/shell.cljs b/frontend/src/arthur/ui/shell.cljs index e19b8c8..fe176d0 100644 --- a/frontend/src/arthur/ui/shell.cljs +++ b/frontend/src/arthur/ui/shell.cljs @@ -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] diff --git a/frontend/src/arthur/ui/tabs.cljs b/frontend/src/arthur/ui/tabs.cljs new file mode 100644 index 0000000..780fa71 --- /dev/null +++ b/frontend/src/arthur/ui/tabs.cljs @@ -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]))} + "×"])]))])) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index d8c2f75..4a4fcdc 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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] diff --git a/static/arthur/app.css b/static/arthur/app.css index 90b8bb3..a61cd02 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -308,6 +308,32 @@ input[type="range"] { width: 100%; accent-color: var(--sel); } 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 { display: flex; align-items: center;