From 5bcf22e4581a6ca930f943e12422a12c78405434 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 3 Oct 2026 00:39:07 -0400 Subject: [PATCH] perfect target area thing --- clips/extraction.py | 8 +- docs/clipboard-plan.md | 42 +-- docs/creating-in.md | 149 ++++++++ docs/lane-handoff.md | 21 +- docs/lane-is-a-view-notes.md | 5 +- docs/lane-model.md | 7 +- frontend/src/arthur/domain/creation.cljs | 43 +++ frontend/src/arthur/events/footage.cljs | 9 +- frontend/src/arthur/events/playback.cljs | 19 +- frontend/src/arthur/events/project.cljs | 9 +- frontend/src/arthur/events/ui.cljs | 330 ++++++------------ frontend/src/arthur/flow/ingest.cljs | 1 - frontend/src/arthur/subs/ui.cljs | 48 +-- frontend/src/arthur/ui/drag.cljs | 2 +- frontend/src/arthur/ui/location.cljs | 28 +- frontend/src/arthur/ui/stage.cljs | 35 +- frontend/src/arthur/ui/timeline.cljs | 55 +-- frontend/src/arthur/ui/topbar.cljs | 4 +- .../test/arthur/domain/clipboard_test.cljs | 46 +++ frontend/test/arthur/events/lane_test.cljs | 164 +++++++-- frontend/test/browser/lane.mjs | 14 +- frontend/test/browser/take.mjs | 13 +- static/arthur/app.css | 48 +-- 23 files changed, 668 insertions(+), 432 deletions(-) create mode 100644 docs/creating-in.md create mode 100644 frontend/src/arthur/domain/creation.cljs diff --git a/clips/extraction.py b/clips/extraction.py index 21d07a6..d83df64 100644 --- a/clips/extraction.py +++ b/clips/extraction.py @@ -211,8 +211,8 @@ def probe(path): raise ValueError(f"the video reports a frame rate of {float(rate):g}, which is " "not a rate footage can be measured at") duration = float(data.get("format", {}).get("duration") or 0) - if duration > 0 and duration * float(rate) > 901: - raise ValueError("video is longer than the 900-frame footage limit") + # if duration > 0 and duration * float(rate) > 901: + # raise ValueError("video is longer than the 900-frame footage limit") frames = video.get("nb_frames") return {"fps": float(rate), # The exact rate, for ffmpeg. 30000/1001 is not a float, and handing @@ -338,8 +338,8 @@ def run(key): proxy_facts = probe(proxy_path) _refuse_a_shifted_timeline(proxy_path) frames = count_frames(proxy_path) - if not 1 <= frames <= 900: - raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900") + #if not 1 <= frames <= 900: + # raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900") # CHECKED AS A DURATION, not as a frame count. The page's clock is # `frame = floor(audio.currentTime * fps)`, so what must not drift is # how long the picture lasts against how long the audio lasts — and diff --git a/docs/clipboard-plan.md b/docs/clipboard-plan.md index f91e986..5b0b616 100644 --- a/docs/clipboard-plan.md +++ b/docs/clipboard-plan.md @@ -30,10 +30,11 @@ stage and timeline. A stage marquee replaces, or with Shift adds to, the same set. Timeline rows, bars, and cel blocks render membership from that same set; the primary member gets the inspector/focus treatment. -Targeting remains separate. Clicking a timeline label both selects it and aims -it. Shift-click changes the selection set and makes the clicked row the one -target. Clicking a bar/cel or the stage changes selection without silently -changing the target. A target is singular even when selection is plural. +Creation targeting is derived rather than stored. The primary (last-selected) +occurrence is the preferred row; the playhead validates it and, when necessary, +walks outward to the nearest valid containing occurrence. A target is singular +even when selection is plural. See `docs/creating-in.md` for the resolver +contract. ## Clipboard value @@ -50,19 +51,13 @@ cross-project identity collision policy hidden inside it. ## Paste -Paste always uses the explicit insertion target and the playhead: - -- no target: paste at the top of the open symbol; -- aimed ordinary node: paste beside it in the symbol that owns it; -- aimed instance, including an instance whose source is drawn as a lane: paste - inside the symbol it places; -- the open lane's own row: paste directly into that lane. - -The destination is resolved structurally from the target. It does not require -the target instance to be visible at the playhead. Being outside the target -symbol's authored window is legal: content can exist there and be cropped by -the window. Lane paste may grow the lane/window because a sequence cannot store -an overlapping or unreachable tail and pretend the edit succeeded. +Paste resolves one destination from the primary selection and playhead. An +ordinary occurrence is usable only while the playhead maps through every +enclosing occurrence and lies within its extent. Otherwise resolution walks +outward, with the open symbol as the total fallback. A selected lane is an +insertion surface only while all occurrences enclosing its parent symbol are +valid. Multi-selection supplies one ordered payload, not several destinations; +only its primary member anchors this resolution. The earliest finite start among the copied roots is aligned with the playhead in the destination. All other root starts retain their offset from it. Roots @@ -118,13 +113,14 @@ After either duplicate command, the new roots replace the selection. Cut, paste, duplicate, and duplicate unique each call one domain command and commit through one `edit/transaction`; each is exactly one undo/redo step no -matter how many nodes or symbols it touches. Copy, aiming, and selection do not +matter how many nodes or symbols it touches. Copy and selection do not touch history. A refusal changes no document leaves and creates no history step. Undo/redo filters the complete selection set against the restored document and repairs the primary selection. Clipboard payloads remain snapshots. Paste -validates the destination and all remapped references at commit time, so a stale -target or a newly impossible symbol cycle refuses rather than partially editing. +validates the resolved destination and all remapped references at commit time, +so a stale selection or a newly impossible symbol cycle refuses rather than +partially editing. ## Required tests @@ -133,7 +129,7 @@ normal shared references, deep unique graph remaps, multi-root relative timing, composition overlap, lane overwrite, lane forward duplication and ripple, mixed-owner duplication, cycle refusal, stale targets, and all-or-nothing failure. Event tests cover copy without history, atomic cut/paste/duplicates, -resulting multi-selection, target resolution, pasting beyond a symbol window, +resulting multi-selection, target fallback at the playhead, and one undo plus redo of each mutation. Browser tests cover mirrored stage and -timeline selection, Shift-toggle on labels/bars/cels, singular aiming, and the -keyboard commands. +timeline selection, Shift-toggle on labels/bars/cels, singular derived creation +targeting, and the keyboard commands. diff --git a/docs/creating-in.md b/docs/creating-in.md new file mode 100644 index 0000000..ad03dca --- /dev/null +++ b/docs/creating-in.md @@ -0,0 +1,149 @@ +# Creating in + +Revised 2026-10-02. + +Arthur's creation model is a layer list plus a playhead. The primary active row +is the preferred place for a new thing. The playhead decides whether that row is +present in the current occurrence and supplies the frame at which the thing is +created. + +This is editor behavior, not document structure. A saved clip has symbols, +instances and spans; it does not have timeline rows or a creation target. + +## Selection and the active row + +The selection set names the objects affected by copy, delete and transform. Its +primary member is also the active row, analogous to the active layer in a paint +program. There is no second targeting gesture for a user to maintain. + +- Clicking a row label, its timeline body, or the same occurrence on the stage + makes that occurrence primary. +- Shift/marquee selection may retain several objects, but only the primary row + anchors creation. +- Clicking blank stage or timeline space returns the active row to the symbol in + the current tab. +- Row disclosure is independent. Selecting or creating never implicitly expands + a row. + +The selection does not change merely because the playhead moves. An off-frame +object remains available for copying, deletion and inspection. + +## Resolving the active row at the playhead + +Resolution starts with the structural destination implied by the primary row: + +- A lane row means the lane symbol itself. +- An ordinary symbol-instance row means the symbol placed by that occurrence. +- A non-instance row means the symbol containing that node. +- No row means the symbol in the current tab. + +For an ordinary symbol occurrence, the playhead must be within that occurrence's +extent in the current nested context. Extents are tested after walking all parent +time maps and use the half-open interval `[in, out)`. If the preferred occurrence +is not present, resolution walks outward to the nearest parent whose occurrence +is present. A containing lane is therefore the natural fallback from an inactive +cel. If no nested occurrence is present, the current tab is the destination. + +The preferred row is retained during fallback. Scrubbing back into its extent +makes it the effective destination again. + +A lane differs only in what its row means. It is an insertion surface across its +containing timeline and does not require an existing cel under the playhead. A +cel within the lane is still an ordinary symbol occurrence with an extent. + +For `A -> B -> lane C -> cel D`: + +- over D, with D primary, creation happens inside D; +- past D but while C is available, creation inserts a new cel in C; +- outside C's containing occurrence but inside B, creation happens inside B; +- outside every nested occurrence, creation happens in A, the current tab. + +## What creation does + +Once resolved, every creation command follows the destination kind. + +### Lane destination + +- A new or dropped symbol is instantiated directly in the lane at the playhead. +- Its interval claims that time. Existing cels under the interval are removed or + trimmed by the lane's ordinary claim-time command. +- Beginning a drawing creates a new one-frame drawing symbol in the lane and the + finished shape is a child of that drawing. +- Selecting an existing cel changes the destination from the lane to the symbol + placed by that cel; subsequent symbols and shapes become children there. + +Thus no separate "new cel" versus "add inside" mode is needed. Selecting the +lane header says new cel; selecting a cel says add inside. + +### Ordinary symbol destination + +- A new symbol is instantiated as a child at the mapped playhead frame. +- A new shape is authored directly in the symbol at that frame. +- Stage coordinates are transformed through the occurrence into the destination + symbol's local coordinates. + +### Explicit timeline drop + +A timeline drop uses the row and frame under the pointer, not the stored active +row and playhead. Dropping onto a lane therefore always instantiates in that lane +and claims the pointer's interval. A stage drop uses the resolved active row and +the playhead. + +Paste, imported symbols and converted footage obey the same resolver as direct +creation. They must not each reconstruct nesting or extent fallback separately. + +## Multi-selection and paste + +Multi-selection does not create multiple insertion targets. Copy and cut take +the canonical forest of selected roots as one ordered payload; the primary +(last-selected) occurrence alone supplies the preferred row for a later paste. +At paste time that row and the current playhead resolve one effective +destination, and every root in the payload is inserted there in one transaction. + +The earliest finite root start is aligned to the destination frame. Other roots +keep their timing offsets, hierarchy, and clipboard order. In an ordinary symbol +the roots may overlap. In a lane every root must have a finite span and the +payload's root spans must not overlap one another; valid spans claim their times +and trim or remove existing cels as a batch. An invalid member refuses the whole +paste rather than inserting a partial payload. + +After paste, all new roots form the selection and the final root is primary, so +it becomes the preferred row for the next creation. Pasting one payload into +several selected destinations is intentionally not implicit: that would be a +separate distribute command. Duplicate is also distinct from paste—it stays +beside each source in its original owner and does not consult the playhead or +creation target. + +## Palette lanes + +Palette lanes use the same row-and-playhead resolution. Their content filter and +transition command remain palette-specific: only palettes and palette +transitions can be inserted there. This is a type restriction, not a second +targeting model. + +## Implementation boundary + +One pure resolver returns the effective destination: + +```clojure +{:kind :lane | :symbol + :sid destination-symbol + :path effective-occurrence-path + :frame destination-local-frame + :matrix destination-to-current-tab-transform} +``` + +Callers may add the unchanged document as `:clip` or rename `:frame` to `:at`, +but they must not reinterpret the active row. Polygon creation, symbol creation, +stage drops, paste, import and footage conversion all consume this answer. +Explicit timeline drops use the same structural row rule with the row and frame +under the pointer; unlike playhead resolution, an invalid pointer destination is +refused rather than allowed to fall outward. + +The invariants are: + +1. The primary row is the preferred structural destination. +2. The playhead validates occurrences and supplies creation time. +3. Inactive targets fall outward; selection does not follow them. +4. A lane row inserts a cel, while a cel row enters its symbol. +5. Stage creation uses the playhead; timeline drops use pointer time. diff --git a/docs/lane-handoff.md b/docs/lane-handoff.md index b164ed0..ed14944 100644 --- a/docs/lane-handoff.md +++ b/docs/lane-handoff.md @@ -94,12 +94,9 @@ decision, not a cleanup. existing root instance all produce the same child instance shape. The only difference is playback policy: a new empty drawing holds source frame zero; a dropped library symbol plays at speed one. -- **Every symbol is born with a lane,** `clip/lane-node`, id `:lane`. A symbol - with none had nowhere to drop a thing, which made the first drop into any - symbol a special case. An unaimed drop fills an EMPTY lane that is already - there and otherwise makes a new one; it never takes an occupied lane nobody - pointed at, because placement claims time and would trim or delete what was - in it. +- **Lanes are explicit.** A symbol may contain ordinary overlapping children + without a lane. Selecting a lane row opts creation into its claim-time + behavior; selecting a cel enters that cel's source symbol instead. - **Placement claims time.** Lanes never store overlaps. A new or extended clip trims, removes, or splits whatever previously owned the claimed interval. Real compositing overlap uses another lane, where ordering remains explicit. @@ -122,12 +119,12 @@ that is mostly refused today. ## Current timeline interaction -- Creating a symbol inside an aimed lane creates a one-frame held clip at the - playhead. With no aimed lane it first creates a lane. Drawing a polygon uses - the existing clip there or creates the same one-frame clip when the frame is - empty. -- Dropping any library symbol into a lane creates a natural-duration playing - clip. Dropping it on unclaimed timeline or stage space first creates a lane. +- Creation follows the primary active row and the playhead; the complete rule is + in `docs/creating-in.md`. A lane row creates a new cel and claims its interval, + while selecting a cel creates inside the symbol that cel places. +- Drawing with a lane row active creates a new one-frame drawing cel. Dropping a + library symbol there creates a natural-duration playing cel. Explicit timeline + drops use the row and frame under the pointer. - Dragging a clip body moves it. A linked audio node follows a picture move; moving or trimming the audio itself remains independent. - Dragging a right edge changes its endpoint. Growth consumes adjacent spans diff --git a/docs/lane-is-a-view-notes.md b/docs/lane-is-a-view-notes.md index 989d02a..92e2b2c 100644 --- a/docs/lane-is-a-view-notes.md +++ b/docs/lane-is-a-view-notes.md @@ -65,8 +65,7 @@ code. `npm test` is green at each step either way. fixtures and risks nothing. 7. **Lane creation stays explicit.** A blank document and `new symbol` create ordinary symbols. The separate `new → lane` command creates and places a - symbol with `:display :lane`, aims it for immediate drawing or dropping, and - always adds it at the top of the open symbol rather than nesting it in the - previously aimed lane. + symbol with `:display :lane` in the effective creation target derived from + selection and playhead; the new lane then becomes the primary selection. ## Notes diff --git a/docs/lane-model.md b/docs/lane-model.md index 84da5d9..0fc11de 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -384,9 +384,10 @@ content identity, and cel context when applicable. Show local time and its project context where a useful mapping exists. Holds and loops need an honest description instead of a fictitious unique global frame. -Creation controls next to the breadcrumb act in that explicit location. Selection -does not secretly change where a new symbol goes. A shared drawing indicates its -reuse and offers Make this cel unique. Names help identify content; +Creation follows the primary active row, resolved at the playhead as specified in +[`creating-in.md`](creating-in.md). The wider selection set still names what copy, +delete and transform affect; it is not a second list of creation destinations. A +shared drawing indicates its reuse and offers Make this cel unique. Names help identify content; linked-use indicators must rely on IDs, because different drawings can share names. | Surface | Primary scope and controls | diff --git a/frontend/src/arthur/domain/creation.cljs b/frontend/src/arthur/domain/creation.cljs new file mode 100644 index 0000000..ec1f3ca --- /dev/null +++ b/frontend/src/arthur/domain/creation.cljs @@ -0,0 +1,43 @@ +(ns arthur.domain.creation + "Resolve where a new thing goes from the primary selection and playhead. + + This namespace owns no editor state. A row address is a preference; walking + the occurrence path at one frame answers which preferred or enclosing symbol + is actually available. See `docs/creating-in.md`." + (:require [arthur.domain.clip :as clip] + [arthur.domain.nest :as nest] + [arthur.domain.symbol :as symbol])) + +(defn preferred-path + "The container path structurally implied by `selection`. + + Selecting an instance means inside it. Selecting any other node means its + containing symbol. An empty or non-node selection means the open symbol." + [document selection] + (let [[kind sid id selected-path] selection + path (when (= :node kind) + (vec (or (seq selected-path) (when id [id]))))] + (cond + (empty? path) [] + (= :instance (get-in document [:symbols sid :nodes id :kind])) path + :else (vec (butlast path))))) + +(defn target + "The nearest creation context available at `frame` of `open`. + + Every non-empty candidate is validated by `nest/inside`, so every enclosing + symbol occurrence must be under the playhead in the current context. Walking + outward stops at the first valid symbol. A lane needs no cel at that frame, + but the occurrence chain that reaches the lane must still be valid. The empty + path always resolves to the open symbol. + + Returns `{:kind :lane|:symbol :sid :path :frame :matrix :time}`." + [document store open selection frame] + (let [preferred (preferred-path document selection)] + (some (fn [path] + (when-let [inside (nest/inside document store open path frame)] + (when-let [sid (:sid inside)] + (assoc inside :path path + :kind (if (symbol/lane? (clip/symbol document sid)) + :lane :symbol))))) + (take (inc (count preferred)) (iterate pop preferred))))) diff --git a/frontend/src/arthur/events/footage.cljs b/frontend/src/arthur/events/footage.cljs index f1d2e39..bc2e80b 100644 --- a/frontend/src/arthur/events/footage.cljs +++ b/frontend/src/arthur/events/footage.cljs @@ -509,7 +509,10 @@ st (merge (:store entry) (:store built)) imported-frames (clip/output-frames clip sid) source-fps (get-in built [:clip :fps]) - where (ui/drop-destination db clip st frame target) + where (if point + (ui/creation-destination db clip st frame) + (ui/drop-destination db clip st frame target)) + point (ui/destination-point where point) result (if (:refused where) where (span/place-symbol (:clip where) st (:sid where) @@ -527,8 +530,8 @@ :source-inputs]))))] {:db (-> db (update :ui dissoc :convert) - (assoc-in [:ui :selection] - [:node (:sid where) uuid (conj (vec (:path where)) uuid)]) + (ui/selected + [:node (:sid where) uuid (conj (vec (:path where)) uuid)]) (update :footage merge {:loading? false :status (str "made " name " · " imported-frames " frames at " fps " fps" diff --git a/frontend/src/arthur/events/playback.cljs b/frontend/src/arthur/events/playback.cljs index ce74ca5..2144df8 100644 --- a/frontend/src/arthur/events/playback.cljs +++ b/frontend/src/arthur/events/playback.cljs @@ -44,6 +44,10 @@ :trace {:faces (trace/showing-for (:clip entry) sid #{}) :opacity (or (get-in db [:ui :trace :opacity]) trace/opacity-default)}}) + ;; Occurrence addresses belong to the document being left. Creation is + ;; derived from the primary selection, so carrying one across documents + ;; could otherwise make a coincidentally equal id a nested destination. + (update :ui dissoc :selection :selections :points) (assoc-in [:playback :frame] 0) (assoc-in [:playback :playing?] false)))) @@ -190,18 +194,13 @@ {:db (-> db (update-in [:ui :tabs] #(if (some #{sid} %) % (conj (vec %) sid))) (assoc-in [:ui :open] sid) - ;; WHAT WAS SELECTED IS NOT IN HERE. A node selection and an - ;; aimed target are paths in the symbol being left, and every - ;; bar that reads one — the breadcrumb, the inspector, where a - ;; new symbol would land — reads it against the open one. So - ;; opening a symbol arrives with nothing selected, which is the - ;; state the root crumb already means. A `[:symbol _]` - ;; selection names a symbol rather than a place inside one and - ;; survives. + ;; A node address is relative to the tab being left. Opening a + ;; symbol therefore arrives at its root; a pool `[:symbol _]` + ;; selection is not an occurrence address and may survive. (update :ui (fn [ui] - (cond-> (dissoc ui :target) + (cond-> ui (= :node (first (:selection ui))) - (dissoc :selection)))) + (dissoc :selection :selections)))) (update-in [:ui :trace :faces] #(trace/showing-for clip sid %)) (assoc-in [:playback :frame] 0) (assoc-in [:playback :playing?] false)) diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index ec2168b..295ccfe 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -207,7 +207,10 @@ st (merge (:store entry) (:store other)) ;; A symbol from another project arrives as an ordinary clip, the same ;; as one from this project's pool. - where (ui/drop-destination db clip st frame target) + where (if point + (ui/creation-destination db clip st frame) + (ui/drop-destination db clip st frame target)) + point (ui/destination-point where point) result (if (:refused where) where (span/place-symbol (:clip where) st (:sid where) @@ -217,8 +220,8 @@ (if-let [why (or (:refused where) (:refused result))] {:db (update db :project merge {:status why})} {:db (-> (edit/edit-entry db #(assoc % :clip (:clip result) :store st)) - (assoc-in [:ui :selection] - [:node (:sid where) uuid (conj (vec (:path where)) uuid)]) + (ui/selected + [:node (:sid where) uuid (conj (vec (:path where)) uuid)]) (update :project merge {:status (str "brought in " label)})) :dispatch [::pb/refresh-clock]})))) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 5499c8c..724c56b 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -1,19 +1,16 @@ (ns arthur.events.ui - "Selection, the TARGET, the active tone, the polygon being drawn, and which - timeline rows are open. + "Selection, the active tone, the polygon being drawn, and which timeline rows + are open. - TWO PIECES OF STATE AND NOT ONE. `[:ui :selection]` is what you are LOOKING - at — what the inspector shows, what the stage puts handles on. `[:ui :target]` - is where a new thing would GO. They used to be one, so clicking a shape on the - stage silently re-aimed the next polygon at whatever held it, which - `lane-model.md` forbids in so many words: \"Selection does not secretly change - where a new symbol goes.\" + THE CREATION TARGET IS A FUNCTION, NOT EDITOR STATE. The primary selection is + the preferred row, like the active layer in a paint program. The playhead + resolves that row to the nearest occurrence creation can happen in: an active + ordinary symbol, a lane insertion surface, an active ancestor, or the open + symbol. `docs/creating-in.md` is the contract. - THE TARGET MOVES ONLY WHEN SOMEBODY AIMS IT. A click on a timeline row label - or a breadcrumb is aiming — each names a place in the - document and nothing else. A click on the stage, or on a timeline bar, is not: - it says look at this. So `::select` never writes the target, and `::aim` - always writes both. + `[:ui :selections]` remains the set copied, deleted or transformed together; + `[:ui :selection]` is its primary member and therefore the creation anchor. + Moving the playhead never rewrites either one. All of it is `assoc-in` under `:ui`. There is no effect in this namespace and there should not be one: an editor's own state is the cheapest thing in the @@ -22,11 +19,11 @@ [arthur.domain.clip :as clip] [arthur.domain.clipboard :as clipboard] [arthur.domain.correction :as correction] + [arthur.domain.creation :as creation] [arthur.domain.gesture :as gesture] [arthur.domain.nest :as nest] [arthur.domain.node :as node] [arthur.domain.span :as span] - [arthur.domain.symbol :as symbol] [arthur.events.edit :as edit] [arthur.domain.paint :as paint] [arthur.events.playback :as playback] @@ -61,37 +58,17 @@ (update-in % [:symbols sid :nodes id] dissoc :name))))))) (defn selected - "`db` with `selection` selected, and nothing aimed. + "`db` with `selection` as its primary active row. Selection and row disclosure are independent. A click or trim can select a nested item, but only the row's twist button opens or closes it." [db selection] ;; A captured trim gesture also selects on pointer-up. It must not acquire the ;; unrelated side effect of opening every row above the edited clip. - (cond-> (-> db - (assoc-in [:ui :selection] selection) - (assoc-in [:ui :selections] (if selection [selection] [])) - (update :ui dissoc :points :retry)) - (nil? selection) (assoc-in [:ui :target] nil))) - -(defn target-of - "The target `selection` names: `{:sid :id :path}`, or nil for the open symbol - itself. - - All three parts, because a path alone cannot be looked up — one symbol placed - twice is two rows with the same node ids in them, so the row path says WHICH - and the sid says in which symbol's node map to find it. A selection made on - the stage carries no path; it names a node in the open symbol directly, and - the node's own id standing alone is that one-step path." - [selection] - (let [[kind sid id path] selection] - (when (and (= :node kind) id) - {:sid sid :id id :path (vec (or (seq path) [id]))}))) - -(defn aimed - "`db` with `selection` selected AND aimed: the target follows it." - [db selection] - (assoc-in (selected db selection) [:ui :target] (target-of selection))) + (-> db + (assoc-in [:ui :selection] selection) + (assoc-in [:ui :selections] (if selection [selection] [])) + (update :ui dissoc :points :retry))) (rf/reg-event-db ::select (fn [db [_ selection]] (selected db selection))) @@ -118,34 +95,6 @@ (-> (selected db primary) (assoc-in [:ui :selections] xs)) (selected db nil))))) -(rf/reg-event-db - ::toggle-and-aim - ;; A modified timeline-label click has two independent meanings: membership in - ;; the shared selection set, and the one place subsequent creation/paste uses. - (fn [db [_ selection]] - (let [primary (get-in db [:ui :selection]) - many (vec (get-in db [:ui :selections])) - old (if (some #{primary} many) many (if primary [primary] [])) - xs (if (some #{selection} old) - (vec (remove #{selection} old)) - (conj old selection)) - db (if-let [primary (peek xs)] - (-> (selected db primary) (assoc-in [:ui :selections] xs)) - (selected db nil))] - (assoc-in db [:ui :target] (target-of selection))))) - -(rf/reg-event-db - ::aim - ;; The gestures that name a PLACE in the document rather than a thing on - ;; screen: a timeline row label or a breadcrumb. - (fn [db [_ selection]] (aimed db selection))) - -(rf/reg-event-db - ::aim-at - ;; Aiming without selecting: the lane becomes where drawings go while the - ;; inspector selection remains independent. - (fn [db [_ selection]] (assoc-in db [:ui :target] (target-of selection)))) - (rf/reg-event-db ::set-tone (fn [db [_ tone]] (assoc-in db [:ui :tone] tone))) @@ -450,42 +399,6 @@ (update-in db [:ui :smart] #(let [on? (boolean on)] ((if on? conj disj) (set %) face))))) -(defn where-new-goes - "The row path, from the open symbol down, of the symbol a new thing goes into: - INSIDE the aimed instance, or BESIDE an aimed node of any other kind, or at - the top of the open symbol when nothing is aimed. - - READ OFF THE TARGET, NEVER THE SELECTION. The two were one thing, and the - result was that clicking a shape on the stage to look at it re-aimed the next - polygon at whatever happened to hold that shape. `[:ui :target]` is a row - path, because one symbol placed twice is two rows and only the path says which - one was aimed at." - [clip db] - (let [{:keys [sid id path]} (get-in db [:ui :target])] - (cond - (empty? path) [] - (= :instance (get-in clip [:symbols sid :nodes id :kind])) path - :else (vec (butlast path))))) - -(defn aimed-symbol - "The symbol a command acts in and the path of rows down to it: `{:sid :path}`. - - INSIDE the aimed instance, beside an aimed node of any other kind, the open - symbol when nothing is aimed — which is `where-new-goes`, resolved. There is - no lane to aim at any more: a lane is how a symbol is DRAWN, so what a gesture - names is a symbol, and whether that symbol is drawn as a lane is a separate - question `symbol/lane?` answers." - [clip st db frame] - (let [open (get-in db [:ui :open]) - down (where-new-goes clip db) - inside (nest/inside clip st open down frame)] - ;; A stale or presently invisible target is no target. Creation must remain - ;; a total operation, so it falls back to the document's main symbol. - (if (:sid inside) - (assoc inside :path down) - {:sid (if (get-in clip [:symbols :main]) :main open) - :path [] :frame frame}))) - ;; --------------------------------------------------------------------------- ;; clipboard and local duplication @@ -537,47 +450,11 @@ (edit/transaction (constantly (:clip r))) (select-results [])))))))) -(defn- apply-time [{:keys [at rate]} f] - (* rate (- f at))) - (defn paste-destination - "The aimed symbol, its row-path prefix, and the playhead in its frames. - - Resolve the destination structurally. For an aimed instance, try the ordinary - visible walk first, then continue its authored clock beyond its span/window; - content outside the window is legal and is simply cropped by that window." + "The effective creation context at the playhead, in the destination's frames." [db clip st] - (let [open (get-in db [:ui :open]) - frame (editing-frame db clip) - {:keys [sid id path]} (get-in db [:ui :target]) - n (when id (get-in clip [:symbols sid :nodes id])) - selection [:node sid id path] - owner-frame (when id (selection-frame clip st open selection frame))] - (cond - (nil? id) - {:sid open :path [] :frame frame} - - (nil? n) - {:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame} - - (= :instance (:kind n)) - (let [visible (nest/inside clip st open path frame) - local (when (number? owner-frame) (node/local-frame n owner-frame)) - shown (when (number? local) (clip/placed-frame clip sid n local)) - mapped (when-let [t (and (number? owner-frame) - (clip/source-time clip sid n))] - (apply-time (node/then-time (node/time-of n) t) owner-frame)) - held (when (zero? (:speed (node/playback-of n))) - (:in (node/playback-of n))) - at (or (:frame visible) (:frame shown) mapped held)] - (if (number? at) - {:sid (node/source n) :path (vec path) :frame (js/Math.floor at)} - {:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame})) - - :else - (if (number? owner-frame) - {:sid sid :path (vec (butlast path)) :frame (js/Math.floor owner-frame)} - {:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame})))) + (creation/target clip st (get-in db [:ui :open]) + (get-in db [:ui :selection]) (editing-frame db clip))) (rf/reg-event-db ::paste @@ -633,59 +510,46 @@ (update-in db [:ui :draft] into [x y]) db))) -(defn into-the-sequence - "Where a polygon drawn into a symbol DRAWN AS A LANE goes: the path of the held - clip that symbol exposes at the playhead, and the document containing it. - `{:clip :path}`, or `{:refused why}`. +(defn new-lane-drawing + "Create the one-frame drawing cel implied by drawing with a lane row active. - A GAP IS NOT A REFUSAL, IT IS A NEW DRAWING. The frame under the playhead is - where the drawing belongs, so drawing on an empty frame makes the one-frame - held clip that was missing. The gesture already says what the removed drawing - controls used to say. + A lane is an insertion surface, so this always makes a new cel at the + playhead. Existing content at that frame is claimed and trimmed by + `overwrite-drawing`. Selecting an existing cel resolves to that cel's ordinary + source symbol before this function is reached, and therefore draws inside it. `overwrite-drawing` rather than `append-drawing`, because a gap already has the room. Appending RIPPLES everything after it later by the new clip's duration — that is what `insert` means, and it is a different intention. - Placed into a gap, overwrite clears nothing and moves nobody. - THE CLIP'S PATH IS THE SYMBOL'S PATH PLUS ONE STEP, because a clip of a lane is an ordinary row of the symbol holding it — which is the addressing `rows` hands the timeline and `trail` reads back." - [clip db {:keys [sid frame path]}] - (let [exposed (when (integer? frame) - (some (fn [c] (let [[lo hi] (node/placed-span c)] - (when (and (<= lo frame) (< frame hi)) c))) - (symbol/children (get-in clip [:symbols sid :nodes]))))] - (cond - (not (integer? frame)) - {:refused "the playhead is not on one frame of this symbol"} - exposed {:clip clip :sid sid :path (conj (vec path) (:id exposed))} - :else - (let [cel-id (random-uuid) - made (span/overwrite-drawing clip sid cel-id (clip/fresh-id clip) frame - {:extent :grow-symbol - :remainder-id (random-uuid)})] - (if (:refused made) - made - {:clip (:clip made) :sid sid :path (conj (vec path) cel-id)}))))) + [clip {:keys [sid frame path]}] + (if-not (integer? frame) + {:refused "the playhead is not on one frame of this symbol"} + (let [cel-id (random-uuid) + made (span/overwrite-drawing clip sid cel-id (clip/fresh-id clip) frame + {:extent :grow-symbol + :remainder-id (random-uuid)})] + (if (:refused made) + made + {:clip (:clip made) :sid sid :path (conj (vec path) cel-id)})))) (defn polygon-landing "Choose the document and row path a finished polygon is drawn into. - WHERE IT GOES IS A SYMBOL, and what happens there follows how that symbol is - DRAWN: in one drawn as a lane an occupied frame lands in its clip and a gap - first becomes a one-frame drawing, because the sequence is what says which - drawing the playhead is on. In an ordinary composition the polygon goes - straight into the symbol, as it always did." + A resolved lane always gets a new drawing cel. A selected cel resolves to its + source symbol instead, so drawing there adds a child to that existing cel." [clip st db] - (let [into (aimed-symbol clip st db (editing-frame db clip))] - (if (and (:sid into) (symbol/lane? (clip/symbol clip (:sid into)))) - (assoc (into-the-sequence clip db into) :lane? true) + (let [into (creation/target clip st (get-in db [:ui :open]) + (get-in db [:ui :selection]) (editing-frame db clip))] + (if (= :lane (:kind into)) + (assoc (new-lane-drawing clip into) :lane? true) {:clip clip :sid (:sid into) :path (:path into) :lane? false}))) (defn beginning-polygon - "Enter polygon mode, first materializing a drawing at the playhead when the - symbol it lands in is drawn as a lane and that frame is empty. + "Enter polygon mode, first materializing a new drawing at the playhead when + the effective target is a lane. Creating here, rather than when the polygon is finished, means the drawing is already the current cel while points are being placed. Cancelling the polygon @@ -708,8 +572,7 @@ ;; left a selection that looked up to nothing, so the ;; inspector, the breadcrumb and every span command went ;; blank on a drawing that had just been created. - (assoc-in [:ui :selection] - [:node (:sid landing) + (selected [:node (:sid landing) (peek (:path landing)) (:path landing)])) :else db)] @@ -761,8 +624,8 @@ (-> db (edit/transaction (fn [_] (paint/new-shape source sid id frame pts (get-in db [:ui :tone])))) - (update :ui merge {:tool nil :draft [] - :selection [:node sid id (conj down id)]}))))))) + (update :ui merge {:tool nil :draft []}) + (selected [:node sid id (conj down id)]))))))) (rf/reg-event-db ::set-knob @@ -783,21 +646,22 @@ "Create and place one empty symbol. Lane creation is explicit: `lane?` marks the new symbol for linear timeline display; ordinary symbol creation never does. Both operations place the new symbol through the same span command, so - an explicitly aimed lane still applies its claim-time rule." + a selected lane still applies its claim-time rule." [db where lane?] (let [{document :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - frame (editing-frame db document) + playhead (editing-frame db document) + into (if (= :top where) + (creation/target document st open nil playhead) + (creation/target document st open (get-in db [:ui :selection]) playhead)) ;; A lane is the open symbol's timeline, not an insert that happens to ;; begin where the playhead was when it was made. Giving that wrapper - ;; the whole open-symbol window makes the row's promise true: it is a - ;; destination at every frame. Ordinary symbols remain clips created at - ;; the playhead. - frame (if lane? 0 frame) - into (if (= :top where) - (assoc (nest/inside document st open [] frame) :path []) - (aimed-symbol document st db frame)) - {host :sid at :frame down :path} into + ;; its whole parent window makes the row's promise true: it is a + ;; destination at every frame. Resolve the parent at the ACTUAL playhead + ;; first; using frame zero for resolution would incorrectly fall out of + ;; every nested symbol that begins later in the open context. + {host :sid resolved-at :frame down :path destination-kind :kind} into + at (if lane? 0 resolved-at) sid (clip/fresh-id document) instance-id (random-uuid) end (clip/frames document host) @@ -811,7 +675,13 @@ :else (let [new-symbol (cond-> {:id sid :name (name sid) :fps (clip/fps document host) - :frames (- end at) :nodes {}} + ;; A fresh cel is one frame for + ;; frame-by-frame work. In an + ;; ordinary composition the new + ;; symbol keeps the available window. + :frames (if (= :lane destination-kind) + 1 (- end at)) + :nodes {}} lane? (assoc :display :lane)) seeded (assoc-in document [:symbols sid] new-symbol)] (span/place-symbol seeded st host instance-id sid at @@ -820,12 +690,11 @@ (if-let [why (:refused result)] (update db :project merge {:status why}) (let [selection [:node host instance-id (conj (vec down) instance-id)]] - (cond-> (-> db - (edit/transaction (constantly (:clip result))) - (assoc-in [:ui :selection] selection) - (update-in [:ui :expanded] (fnil into #{}) - (rest (reductions conj [] down)))) - lane? (assoc-in [:ui :target] (target-of selection))))))) + (-> db + (edit/transaction (constantly (:clip result))) + (selected selection) + (update-in [:ui :expanded] (fnil into #{}) + (rest (reductions conj [] down)))))))) (rf/reg-event-db ::new-symbol @@ -835,11 +704,9 @@ (rf/reg-event-db ::new-lane (fn [db _] - ;; A lane is an explicit, persistent top-level track of the open symbol. It - ;; must not become nested merely because the previously created lane is - ;; still aimed, and it spans the open symbol rather than starting at the - ;; current playhead. - (create-container db :top true))) + ;; A lane is an explicit persistent row across the effective target symbol. + ;; The playhead validates that nested target; the row itself begins at zero. + (create-container db :inside true))) ;; --------------------------------------------------------------------------- ;; a drop in flight @@ -875,31 +742,47 @@ pointer answered with — and `:at` is that frame carried down into the destination's. Nothing on this path crosses the output grid. - `:clip` is handed back unchanged and is in the result only so the callers that - used to be given a document with a freshly made lane in it go on reading one - thing." + `:clip` is handed back unchanged so all placement callers consume the same + result shape." [document st open frame target] - (let [target (if (vector? target) - (let [[_ sid id path] target] - {:sid sid :id id :path path}) - target) + (let [[_ sid id selected-path] target + path0 (vec (or (seq selected-path) (when id [id]))) path (cond (nil? target) [] - (= :instance (get-in document [:symbols (:sid target) - :nodes (:id target) :kind])) - (vec (:path target)) - :else (vec (butlast (:path target)))) - {:keys [sid] at :frame} (nest/inside document st open path frame)] + (= :instance (get-in document [:symbols sid :nodes id :kind])) path0 + :else (vec (butlast path0))) + {destination :sid at :frame matrix :matrix} + (nest/inside document st open path frame)] (cond - (nil? sid) {:refused "what you are dropping into is not on screen at this frame"} + (nil? destination) {:refused "what you are dropping into is not on screen at this frame"} (not (integer? at)) {:refused "the drop is not on one frame of that symbol"} - :else {:clip document :sid sid :at at :path path}))) + :else {:clip document :sid destination :at at :path path :matrix matrix}))) (defn drop-destination "`drop-destination-at` from the symbol currently open in `db`." [db document st frame target] (drop-destination-at document st (get-in db [:ui :open]) frame target)) +(defn creation-destination + "A stage creation destination from the primary selection and playhead." + [db document st frame] + (let [{:keys [sid path matrix] at :frame} + (creation/target document st (get-in db [:ui :open]) + (get-in db [:ui :selection]) frame)] + (cond + (nil? sid) {:refused "nothing under the playhead can contain this"} + (not (integer? at)) {:refused "the playhead is not on one frame of that symbol"} + :else {:clip document :sid sid :at at :path path :matrix matrix}))) + +(defn destination-point + "A point in the current tab transformed into `where`'s local coordinates." + [{:keys [matrix]} point] + (if-let [inv (and point matrix (node/invert matrix))] + (let [out (js/Float64Array. 2)] + (node/apply-pt! out 0 inv (first point) (second point)) + [(aget out 0) (aget out 1)]) + point)) + (defn landed "`db` after a drop that produced `result`, with `uuid` selected." [db {:keys [sid path]} uuid result] @@ -908,7 +791,7 @@ (-> db (update :ui dissoc :drop) (edit/transaction (constantly (:clip result))) - (assoc-in [:ui :selection] [:node sid uuid (conj (vec path) uuid)])))) + (selected [:node sid uuid (conj (vec path) uuid)])))) (rf/reg-event-db ::drop-symbol @@ -918,7 +801,10 @@ ;; a lane trims its neighbour and a drop into a composition does not. (fn [db [_ source-id frame point target]] (let [{document :clip st :store} (store/entry (:clip/current db)) - where (drop-destination db document st frame target) + where (if point + (creation-destination db document st frame) + (drop-destination db document st frame target)) + point (destination-point where point) uuid (random-uuid)] (if (:refused where) (-> db (update :ui dissoc :drop) (update :project merge {:status (:refused where)})) @@ -943,7 +829,7 @@ ;; `place-sound` positions in. It used to be multiplied by the grid rate ;; again on the way in — a second conversion of an already-converted ;; number, which dropped a sound 2.5 frames late for every frame it was - ;; aimed at in a 12fps project. + ;; selected in a 12fps project. (let [sid (:sid where) seeded (clip/place-sound (:clip where) sid source label length rate (:at where) uuid)] diff --git a/frontend/src/arthur/flow/ingest.cljs b/frontend/src/arthur/flow/ingest.cljs index bbc32c9..9235dee 100644 --- a/frontend/src/arthur/flow/ingest.cljs +++ b/frontend/src/arthur/flow/ingest.cljs @@ -41,7 +41,6 @@ frames (js/Number (:frames m)) urls (:urls m)] (when-not (and (js/Number.isFinite fps) (pos? fps) - (js/Number.isInteger frames) (<= 1 frames 900) (string? (:audio m)) (seq (:audio m)) (sequential? urls) (every? string? urls)) (throw (ex-info "a footage manifest needs fps, frames (1–900), audio and a url per frame" diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index 332855e..3ffc7b7 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -4,7 +4,8 @@ Cheap by construction, like `subs/playback`: each reads a path and returns a value, so clicking a swatch notifies the swatches and nothing else." - (:require [arthur.domain.nest :as nest] + (:require [arthur.domain.creation :as creation] + [arthur.domain.nest :as nest] [arthur.domain.pick :as pick] [arthur.footage.store :as store] [arthur.subs.playback :as playback] @@ -23,7 +24,6 @@ (cond (nil? primary) [] (some #{primary} many) many :else [primary])))) -(rf/reg-sub ::target (fn [db _] (get-in db [:ui :target]))) (rf/reg-sub ::clipboard (fn [db _] (get-in db [:ui :clipboard]))) (rf/reg-sub ::retry (fn [db _] (get-in db [:ui :retry]))) (rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone]))) @@ -67,33 +67,33 @@ (nest/inside clip st open (or path [id]) f))))) (rf/reg-sub - ::target-node - :<- [::target] - :<- [::render/clip] - (fn [[{:keys [sid id]} clip] _] - ;; The node a new thing would be parented to, for the outline that says so. - ;; `[sid id node]` as `::selected-node` gives it, so the two read alike. - (when (and clip id) - (when-let [n (get-in clip [:symbols sid :nodes id])] - [sid id n])))) - -(rf/reg-sub - ::target-placement - :<- [::target] - :<- [::target-node] + ::creation-target + :<- [::selection] :<- [::render/clip] :<- [::render/clip-id] :<- [::render/open] :<- [::render/open-frame] - (fn [[{:keys [path]} [_ id n] clip clip-id open f] _] - ;; `nest/placement` of the TARGET, with the bounds of what it draws — the - ;; same work `::selected-placement` does, kept separate because the two are - ;; separate states and are drawn differently: the target gets an outline and - ;; a tag, the selection gets handles. - (when n + (fn [[selection clip clip-id open f] _] + (when clip + (creation/target clip (:store (store/entry clip-id)) open selection f)))) + +(rf/reg-sub + ::creation-placement + :<- [::creation-target] + :<- [::render/clip] + :<- [::render/clip-id] + :<- [::render/open] + :<- [::render/open-frame] + (fn [[{:keys [path]} clip clip-id open f] _] + ;; The effective occurrence whose contents receive creation. A fallback may + ;; therefore outline an ancestor while the off-frame primary selection stays + ;; unchanged. + (when (and clip (seq path)) (let [st (:store (store/entry clip-id))] - (when-let [pl (nest/placement clip st open (or path [id]) f)] - (assoc pl :node n :bounds ((pick/bounds-of clip st (:sid pl) n) (:frame pl)))))))) + (when-let [{:keys [sid id frame] :as pl} (nest/placement clip st open path f)] + (let [n (get-in clip [:symbols sid :nodes id])] + (assoc pl :node n + :bounds ((pick/bounds-of clip st sid n) frame)))))))) (rf/reg-sub ::selected-placement diff --git a/frontend/src/arthur/ui/drag.cljs b/frontend/src/arthur/ui/drag.cljs index b6ee41a..530c4eb 100644 --- a/frontend/src/arthur/ui/drag.cljs +++ b/frontend/src/arthur/ui/drag.cljs @@ -119,7 +119,7 @@ (case kind :symbol (rf/dispatch [::ui/drop-symbol sid frame point target]) ;; Video is asked about before anything happens: which frames, and what - ;; the symbol they become is called. The lane it was aimed at travels + ;; the symbol they become is called. The explicit timeline row travels ;; with the question, so the answer lands where the drop pointed. :footage (rf/dispatch [::footage/ask-convert c frame point target]) :import (rf/dispatch [::project/import c frame point target]) diff --git a/frontend/src/arthur/ui/location.cljs b/frontend/src/arthur/ui/location.cljs index 9e68fb4..1aac0a7 100644 --- a/frontend/src/arthur/ui/location.cljs +++ b/frontend/src/arthur/ui/location.cljs @@ -141,12 +141,10 @@ last-i (dec (count crumbs)) shared (shared-with clip n) says (whereabouts clip n inside) - ;; WHAT IS AIMED, which is not what is selected: the menu has to name - ;; the place it would add to, and that place is the target. Named the - ;; way the trail names a crumb, so the menu and the outline's tag and - ;; the breadcrumb all call one node by one name. - aimed (let [[tsid tid tn] @(rf/subscribe [::sub/target-node])] - (when tn (crumb-label clip tid tn)))] + ;; The effective parent can be an active ancestor of the primary row + ;; when the preferred occurrence is off the playhead. + destination (let [{:keys [id node]} @(rf/subscribe [::sub/creation-placement])] + (when node (crumb-label clip id node)))] [:section.loc [:nav.crumbs {:aria-label "editing location"} (doall @@ -161,9 +159,7 @@ :title (if (zero? i) "the open symbol — clear the selection" (str label " · " (if lane? "lane" (name crumb-kind)))) - ;; A CRUMB AIMS, because this bar is the one that says where an - ;; edit would land: going out to a level is going there to work. - :on-click #(rf/dispatch [::ui/aim (when (pos? i) select)])} + :on-click #(rf/dispatch [::ui/select (when (pos? i) select)])} label]]))] (when says [:span.loc-fact {:title "the frame this selection is showing, in its own time"} @@ -184,20 +180,18 @@ [menu/view {:label "new" :title "add to the document" :items [{:label "inside" - :sub (if aimed - (str "a symbol in " aimed " — the outlined one") + :sub (if destination + (str "a symbol in " destination " — the outlined one") (str "a symbol at the top of " (clip/symbol-name clip open) - ", since nothing is aimed")) + ", since no nested row is active here")) :on-click #(rf/dispatch [::ui/new-symbol :inside])} ;; THE SECOND ITEM IS THE POINT OF THE MENU. With one item it was ;; impossible to add anything at the top of the open symbol - ;; without first clearing the aim, which meant the only way out - ;; of a nesting was to leave it — and nobody could see the rule - ;; they were working against. + ;; without first clearing the active row. {:label "at top" - :disabled? (nil? aimed) + :disabled? (nil? destination) :sub (str "a symbol straight into " (clip/symbol-name clip open) - ", ignoring what is aimed") + ", ignoring the active row") :on-click #(rf/dispatch [::ui/new-symbol :top])} {:label "lane" :sub (str "a row for symbol clips in " (clip/symbol-name clip open)) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 9f468fa..c4240e1 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -140,15 +140,13 @@ (defn- select! "Select the node at row path `path` of the open symbol — the selection a timeline row makes, so the row, the inspector and the stage all show it — or - the open symbol itself when `path` is empty. Blank stage is an explicit place, - not merely an absence of a picked shape, so it clears a stale drawing target - as well as the inspector selection." + the open symbol itself when `path` is empty." [{:keys [open f] :as ctx} path] (let [{document :clip st :store} (loaded ctx)] (if-let [{:keys [sid id]} (when (seq path) (nest/placement document st open path f))] (rf/dispatch [::ui/select [:node sid id path]]) - (rf/dispatch [::ui/aim nil])))) + (rf/dispatch [::ui/select nil])))) (defn- begin! "Start dragging `kind` of the node at `path` from stage point `p`." @@ -305,13 +303,12 @@ (when-let [{:keys [sid id]} (and (seq path) (nest/placement document st open path f))] [:node sid id path]))) -(defn- aim-box - "The TARGET's outline: where a new polygon or symbol would be parented, drawn - around the thing that would be its parent. +(defn- creation-box + "The effective creation parent's outline, resolved from selection and playhead. NOT THE SELECTION'S BOX AND DELIBERATELY NOT LIKE IT. The selection gets handles, because handles are how you change it; the target gets an outline - with nothing to grab, because there is nothing to change about being aimed at. + with nothing to grab, because the derived destination is not itself a handle. Same geometry — the node's bounds through its own transform, so it turns with what it is around — and a different statement. @@ -323,8 +320,9 @@ carries a `viewBox` scaled by `zoom`, which is also why the stroke widths nearby are fractions." [] - (let [{:keys [world bounds]} @(rf/subscribe [::sub/target-placement]) - [_ id n] @(rf/subscribe [::sub/target-node]) + (let [{:keys [world bounds id node]} + @(rf/subscribe [::sub/creation-placement]) + n node label (or (:name n) (some-> (node/source n) name) (when id (if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))] (when (and world bounds) @@ -333,16 +331,16 @@ ;; Furthest bottom-right of the four as drawn: the corner a reader ;; would call "the bottom right" whatever the node has been turned to. [tx ty] (apply max-key (fn [[x y]] (+ x y)) corners)] - [:g.aim {:pointer-events "none"} - [:polygon.aim-box {:points (points-text (flatten corners))}] + [:g.creation-target {:pointer-events "none"} + [:polygon.creation-target-box {:points (points-text (flatten corners))}] (when label [:g {:transform (str "translate(" (+ tx 1.5) " " (+ ty 1.5) ")")} ;; The plate behind the text, sized off the string rather than ;; measured: `textLength` would need a layout pass, and a tag that ;; is a little wide is better than one that reflows the frame. - [:rect.aim-tag-bg {:x 0 :y 0 :rx 0.8 + [:rect.creation-target-tag-bg {:x 0 :y 0 :rx 0.8 :width (+ 2 (* 2.1 (count label))) :height 5}] - [:text.aim-tag {:x 1 :y 3.8} label]])])))) + [:text.creation-target-tag {:x 1 :y 3.8} label]])])))) (defn- overlay [w h zoom] (let [tool @(rf/subscribe [::sub/tool]) @@ -444,7 +442,7 @@ ;; DRAWN WHILE DRAWING, unlike the handles. "Where will this polygon land" ;; is the question the outline exists to answer, and the moment it is being ;; asked is mid-draft. - (when-not points? [aim-box]) + (when-not points? [creation-box]) (when-not (or drawing? points?) (if (> (count placements) 1) [group-handles ctx placements] [handles ctx])) (when (and id pts (not drawing?) (not (channel/nothing? pts))) @@ -475,12 +473,7 @@ ;; unit a drop on the timeline's ruler lands in. frame @(rf/subscribe [::render/open-frame]) clip @(rf/subscribe [::render/clip]) - clip-id @(rf/subscribe [::render/clip-id]) - db-target @(rf/subscribe [::sub/target]) - open @(rf/subscribe [::render/open]) - destination (when clip - (ui/aimed-symbol clip (:store (store/entry clip-id)) - {:ui {:open open :target db-target}} frame)) + destination @(rf/subscribe [::sub/creation-target]) destination-name (when-let [sid (:sid destination)] (clip/symbol-name clip sid)) zoom @(rf/subscribe [::layout/zoom :stage])] diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 11372f9..5a4a22d 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -585,14 +585,9 @@ (memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"})))))) (defn- label-cell [{:keys [path depth label kind node-kind lane? select expandable? expanded? of via]} - selection selections target over solo tracing renaming draft] + selection selections target-path over solo tracing renaming draft] (let [node? (= :node kind) selected? (contains? selections select) - ;; AIMED IS NOT SELECTED, so it does not wear the selected class. The - ;; target is where a new thing would go; the selection is what the - ;; inspector is showing. One row is often both and must still say which - ;; of the two it is being. - aimed? (and node? (= path (:path target))) editing? (and lane? (= select @renaming)) begin-rename! (fn [] (reset! draft label) (reset! renaming select)) commit-rename! (fn [] @@ -603,7 +598,7 @@ [over-path where] @over] [:div (cond-> {:class (str "tl-label" (when (and select selected?) " on") (when (and select (= select selection)) " primary") - (when aimed? " aimed") + (when (= path target-path) " target") (when lane? " lane") (when (= :ghost kind) " ghost") (when (= path over-path) @@ -614,16 +609,13 @@ :style {:padding-left (str (+ 4 (* 11 depth)) "px")} :title label :ref (when (and select (= select selection)) (reveal selection)) - ;; A LABEL AIMS. Clicking a row's name says "I am working - ;; here", which is a statement about a place in the document; - ;; clicking its bar or one of its cels, below, says "show me - ;; this", which is a statement about a thing on screen. Only - ;; the first moves where new drawings and symbols go. + ;; Labels, bars and stage objects all select the same row. + ;; The primary row plus playhead resolves creation. :on-click (fn [^js e] (when select (rf/dispatch [(if (.-shiftKey e) - ::ui/toggle-and-aim - ::ui/aim) + ::ui/toggle-selection + ::ui/select) select]))) ;; An instance's row opens the symbol it places, as a tab. :on-double-click (fn [^js e] @@ -665,8 +657,10 @@ [:button.tl-twist {:disabled (not expandable?) :on-click (fn [^js e] + (println "heyyy") (.stopPropagation e) - (rf/dispatch [::ui/toggle-row path]))} + (rf/dispatch [::ui/toggle-row path])) + :class (println expandable?)} (when expandable? (if expanded? "▾" "▸"))] ;; A LANE IS NOT AN INSTANCE WEARING A DIFFERENT HAT, to read. Its one row ;; holds blocks that follow one another in time, where every other row @@ -726,7 +720,7 @@ moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip every row and the stage are drawn from already has in it." [{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped? owner]} - frames sliding hint {:keys [clip store open selection selections]}] + frames sliding hint {:keys [clip store open selection selections target-path]}] (let [selected-set (set selections) active-row (:row @sliding) slide (fn [^js e] @@ -882,7 +876,8 @@ [:div.tl-track ;; The track, not the bar, holds the pointer while a bar slides, so the drag ;; goes on when the bar has slid off the ruler and is no longer drawn. - {:class (str (when lane? "lane") (when (= :palette kind) " palette")) + {:class (str (when lane? "lane") (when (= :palette kind) " palette") + (when (= path target-path) " target")) :on-pointer-move slide :on-pointer-up (fn [e] (slide e) (done true)) :on-pointer-cancel (fn [_] (done false)) @@ -1000,6 +995,7 @@ :class (str (when ghost? "ghost") (when (and select (contains? selected-set select)) " on") (when (and select (= select selection)) " primary") + (when (= (nth select 3 nil) target-path) " target") (when (and select (= select (get-in @sliding [:nest :select]))) " nest-target")) :style {:position "absolute" :left (edge% in frames) @@ -1107,7 +1103,12 @@ selection @(rf/subscribe [::sub/selection]) selections @(rf/subscribe [::sub/selections]) selection-set (set selections) - target @(rf/subscribe [::sub/target]) + creation @(rf/subscribe [::sub/creation-target]) + target-path (let [path (:path creation)] + (if (and (empty? path) + (= :lane (:kind creation))) + [::lane] + path)) expanded @(rf/subscribe [::sub/expanded]) drop @(rf/subscribe [::sub/drop]) solo (set @(rf/subscribe [::render/solo])) @@ -1123,7 +1124,7 @@ ;; will resolve on release -- and with no row under the pointer it is a ;; row of its own at the top of the section it will appear in. ;; - ;; A SOUND USED TO BE THE EXCEPTION: aimed at a lane it drew no block + ;; A SOUND USED TO BE THE EXCEPTION: over a lane it drew no block ;; there and a ghost row down in the audio section instead, so dragging ;; a sound onto a lane looked exactly like a lane refusing it -- while ;; the drop itself worked and put the sound in the lane. One rule @@ -1175,18 +1176,16 @@ [:section.pane.time [transport] [:div.tl-body - ;; Blank timeline space means the open symbol. Use the same `aim` - ;; gesture as its breadcrumb so both the visible selection and the - ;; drawing destination return to the root. Child controls stop their + ;; Blank timeline space means the open symbol. Child controls stop their ;; own events; the target check keeps ordinary row/bar clicks local. {:on-click (fn [^js e] (when (= (.-target e) (.-currentTarget e)) - (rf/dispatch [::ui/aim nil])))} + (rf/dispatch [::ui/select nil])))} [:div.tl-labels ;; Empty label space takes a row back out to the top of the open symbol. {:on-click (fn [^js e] (when (= (.-target e) (.-currentTarget e)) - (rf/dispatch [::ui/aim nil]))) + (rf/dispatch [::ui/select nil]))) :on-drag-over (fn [^js e] (when (drag/row) (.preventDefault e) @@ -1202,12 +1201,13 @@ (doall (for [row visible] (with-meta (if (= :section (:kind row)) [:div.tl-label.tl-section (:label row)] - [label-cell row selection selection-set target over solo tracing renaming draft]) + [label-cell row selection selection-set target-path + over solo tracing renaming draft]) {:key (str (:path row))})))] [:div.tl-tracks {:on-click (fn [^js e] (when (= (.-target e) (.-currentTarget e)) - (rf/dispatch [::ui/aim nil]))) + (rf/dispatch [::ui/select nil]))) :on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) :on-drag-over (fn [^js e] (when (drag/accepts?) @@ -1265,7 +1265,8 @@ [:div.tl-track.tl-section] [track-cell row frames sliding hint {:clip clip :store store :open open - :selection selection :selections selections}]) + :selection selection :selections selections + :target-path target-path}]) {:key (str (:path row))}))) [:div.tl-empty "nothing in this symbol"]) [playhead :div.tl-playhead frames]]] diff --git a/frontend/src/arthur/ui/topbar.cljs b/frontend/src/arthur/ui/topbar.cljs index 278dcfa..f1bc2f4 100644 --- a/frontend/src/arthur/ui/topbar.cljs +++ b/frontend/src/arthur/ui/topbar.cljs @@ -78,9 +78,9 @@ :disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])} {:label "cut" :sub "Cut selection · ⌘/Ctrl X" :disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])} - {:label "paste" :sub "Paste at playhead into the aimed place · ⌘/Ctrl V" + {:label "paste" :sub "Paste at playhead into the active row · ⌘/Ctrl V" :disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])} - {:label "duplicate" :sub "Duplicate here; lanes repeat forward · ⌘/Ctrl D" + {:label "duplicate" :sub "Duplicate beside selection; lanes repeat forward · ⌘/Ctrl D" :disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])} {:label "duplicate unique" :sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D" diff --git a/frontend/test/arthur/domain/clipboard_test.cljs b/frontend/test/arthur/domain/clipboard_test.cljs index cb8746d..baeb8bd 100644 --- a/frontend/test/arthur/domain/clipboard_test.cljs +++ b/frontend/test/arthur/domain/clipboard_test.cljs @@ -51,6 +51,52 @@ (is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes id])))) (is (empty? (clip/problems made))))) +(deftest multi-paste-has-one-destination-and-preserves-root-timing + (let [doc (update-in (fixture/document) [:symbols :main] dissoc :display) + payload (:clipboard + (clipboard/snapshot doc [(address :main :a [:a]) + (address :main :b [:b])])) + r (clipboard/paste doc payload :main 20 {:fresh-id (ids)}) + made (:clip r) + [a2 b2] (:roots r)] + (is (= [20 24] (node/placed-span (get-in made [:symbols :main :nodes a2])))) + (is (= [24 28] (node/placed-span (get-in made [:symbols :main :nodes b2])))) + (is (= 2 (count (:roots r)))) + (is (empty? (clip/problems made))))) + +(deftest multi-paste-into-a-lane-claims-all-intervals-atomically + (let [doc (fixture/document) + payload (:clipboard + (clipboard/snapshot doc [(address :main :a [:a]) + (address :main :insert [:insert])])) + r (clipboard/paste doc payload :main 4 {:fresh-id (ids)}) + made (:clip r) + [a2 insert2] (:roots r)] + (is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes a2])))) + (is (= [12 16] (node/placed-span (get-in made [:symbols :main :nodes insert2])))) + (is (nil? (get-in made [:symbols :main :nodes :b]))) + (is (= [8 12] (node/placed-span (get-in made [:symbols :main :nodes :insert])))) + (is (= 16 (get-in made [:symbols :main :frames]))) + (is (empty? (clip/problems made))))) + +(deftest overlapping-multi-paste-into-a-lane-is-all-or-nothing + (let [base (fixture/document) + doc (-> base + (assoc-in [:symbols :source] + {:id :source :frames 8 + :nodes {:a (get-in base [:symbols :main :nodes :a]) + :b (assoc-in (get-in base [:symbols :main :nodes :b]) + [:time :at] 2)}}) + (assoc-in [:symbols :empty-lane] + {:id :empty-lane :frames 8 :display :lane :nodes {}})) + payload (:clipboard + (clipboard/snapshot doc [(address :source :a [:a]) + (address :source :b [:b])])) + r (clipboard/paste doc payload :empty-lane 0 {:fresh-id (ids)})] + (is (= "overlapping copied things cannot be pasted into one lane" (:refused r))) + (is (nil? (:clip r))) + (is (= {} (get-in doc [:symbols :empty-lane :nodes]))))) + (deftest lane-duplicate-is-the-forward-repeat-and-ripples-later-cels (let [doc (fixture/document) payload (:clipboard diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index ad4f8f6..42b576d 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -1,6 +1,7 @@ (ns arthur.events.lane-test (:require [cljs.test :refer [deftest is]] [arthur.domain.clip :as clip] + [arthur.domain.creation :as creation] [arthur.domain.history :as history] [arthur.domain.leaf :as leaf] [arthur.domain.node :as node] @@ -42,22 +43,61 @@ (node/placed-span (get-in fitted [:symbols :main :nodes instance-id]))) "the lane follows a later change to its parent's extent") (is (= 300 (clip/frames fitted lane-id)))) - (is (some? (get-in lane-db [:ui :target])) - "the new lane is aimed so drawing and pool drops can go into it")))) + (is (= :lane (:kind (creation/target document {} :main + (get-in lane-db [:ui :selection]) 6))) + "the new lane is the active creation row")))) -(deftest aiming-the-root-clears-selection-and-drawing-target +(deftest a-new-symbol-on-a-lane-is-a-one-frame-cel + (let [doc (fixture/document) + id (store/install! {:clip doc :store {}} "one-frame-new-symbol")] + (reset! rf-db/app-db {:clip/current id :paint/revision 0 + :ui {:open :main + :selection [:node :main nil []]} + :playback {:frame 5}}) + (rf/dispatch-sync [::ui/new-symbol :inside]) + (let [db @rf-db/app-db + saved (:clip (store/entry id)) + [_ sid instance-id] (get-in db [:ui :selection]) + instance (get-in saved [:symbols sid :nodes instance-id]) + source (node/source instance)] + (is (= :main sid)) + (is (= [5 6] (node/placed-span instance))) + (is (= 1 (clip/frames saved source))) + (is (every? (fn [other] + (or (= instance-id (:id other)) + (let [[a b] (node/placed-span other)] + (or (<= b 5) (<= 6 a))))) + (filter node/placed-span + (vals (get-in saved [:symbols :main :nodes])))) + "claiming the frame leaves no overlapping cel")))) + +(deftest a-new-lane-uses-the-symbol-selected-at-the-playhead + (let [doc (clip/blank) + id (store/install! {:clip doc :store {}} "nested-new-lane")] + (reset! rf-db/app-db {:clip/current id :paint/revision 0 + :ui {:open :main} :playback {:frame 6}}) + (rf/dispatch-sync [::ui/new-symbol :inside]) + (let [parent-instance (nth (get-in @rf-db/app-db [:ui :selection]) 2) + parent-sid (node/source (get-in (:clip (store/entry id)) + [:symbols :main :nodes parent-instance]))] + (rf/dispatch-sync [::ui/new-lane]) + (let [db @rf-db/app-db + saved (:clip (store/entry id)) + [_ host lane-instance] (get-in db [:ui :selection]) + lane-sid (node/source (get-in saved [:symbols host :nodes lane-instance]))] + (is (= parent-sid host) "the lane is created inside the selected symbol") + (is (= :lane (get-in saved [:symbols lane-sid :display]))) + (is (= [0 (clip/frames saved host)] + (node/placed-span (get-in saved [:symbols host :nodes lane-instance])))) + (is (nil? (get-in saved [:symbols :main :nodes lane-instance])) + "it does not bypass the target and land in the tab root"))))) + +(deftest selecting-the-root-clears-the-active-row (let [db {:ui {:selection [:node :main :shape [:lane :shape]] - :target {:sid :main :id :lane :path [:lane]}}} - after (ui/aimed db nil)] - (is (nil? (get-in after [:ui :selection]))) - (is (nil? (get-in after [:ui :target]))))) - -(deftest clearing-selection-also-clears-the-creation-target - (let [db {:ui {:selection [:symbol :drawing] - :target {:sid :main :id :lane :path [:lane]}}} + :selections [[:node :main :shape [:lane :shape]]]}} after (ui/selected db nil)] (is (nil? (get-in after [:ui :selection]))) - (is (nil? (get-in after [:ui :target]))))) + (is (empty? (get-in after [:ui :selections]))))) (deftest an-explicit-lane-is-one-row-of-clips (let [doc (fixture/document) @@ -111,7 +151,34 @@ (is (= 12 (ui/selection-frame doc nil :main [:node :main :a [:a]] 12))))) -(deftest paste-targeting-continues-an-instance-clock-past-its-window +(deftest creation-walks-to-the-nearest-valid-parent-at-the-playhead + (let [doc (-> (fixture/document) + (assoc-in [:symbols :shot] + {:id :shot :frames 12 :fps 24 + :nodes {:girl {:id :girl :kind :instance :z "a" + :span [0 12] :time {:mode :map :at 0 :rate 1} + :source {:symbol :main} + :playback {:in 0 :speed 1 :end :stop}}}}) + (assoc-in [:symbols :outer] + {:id :outer :frames 30 :fps 24 + :nodes {:take {:id :take :kind :instance :z "a" + :span [0 12] :time {:mode :map :at 10 :rate 1} + :source {:symbol :shot} + :playback {:in 0 :speed 1 :end :stop}}}})) + selection [:node :main :a [:take :girl :a]] + at #(select-keys (creation/target doc nil :outer selection %) + [:kind :sid :path :frame])] + (is (= {:kind :symbol :sid :drawing-a :path [:take :girl :a] :frame 0} + (at 12)) + "the selected cel is the parent while every enclosing occurrence is active") + (is (= {:kind :lane :sid :main :path [:take :girl] :frame 5} + (at 15)) + "past the cel, its containing lane is the nearest valid insertion surface") + (is (= {:kind :symbol :sid :outer :path [] :frame 25} + (at 25)) + "a lane behind an inactive parent occurrence is not valid"))) + +(deftest creation-walks-out-of-an-instance-past-its-window (let [doc {:fps 24 :width 20 :height 20 :symbols {:main {:id :main :frames 40 @@ -121,16 +188,40 @@ :playback {:in 0 :speed 1 :end :stop}}}} :inside {:id :inside :frames 10 :nodes {}}}} db {:ui {:open :main - :target {:sid :main :id :drawing :path [:drawing]}} + :selection [:node :main :drawing [:drawing]]} :playback {:frame 20}}] - (is (= {:sid :inside :path [:drawing] :frame 20} - (ui/paste-destination db doc nil)) - "pasting outside a target symbol's window authors cropped content"))) + (is (= {:sid :main :path [] :frame 20} + (select-keys (ui/paste-destination db doc nil) [:sid :path :frame])) + "an inactive selected symbol falls back to the open symbol"))) + +(deftest multi-paste-uses-one-current-primary-target-and-selects-the-batch + (let [doc (fixture/document) + id (store/install! {:clip doc :store {}} "multi-paste-target") + a [:node :main :a [:a]] + insert [:node :main :insert [:insert]]] + (reset! rf-db/app-db {:clip/current id :paint/revision 0 + :ui {:open :main :selection insert :selections [a insert]} + :playback {:frame 4}}) + (rf/dispatch-sync [::ui/copy]) + ;; Changing selection after copy changes the destination, not the payload. + (rf/dispatch-sync [::ui/select [:node :main nil []]]) + (rf/dispatch-sync [::ui/paste]) + (let [db @rf-db/app-db + saved (:clip (store/entry id)) + selections (get-in db [:ui :selections]) + [a2 insert2] (map #(nth % 2) selections)] + (is (= 2 (count selections))) + (is (= (peek selections) (get-in db [:ui :selection])) + "the last pasted root is the new primary creation anchor") + (is (= [4 8] (node/placed-span (get-in saved [:symbols :main :nodes a2])))) + (is (= [12 16] (node/placed-span (get-in saved [:symbols :main :nodes insert2])))) + (is (= 1 (count (get-in (store/entry id) [:history :done]))) + "the complete batch is one edit")))) (deftest polygon-landing-obeys-the-destination-symbol-mode (let [lane (fixture/document) ordinary (update-in lane [:symbols :main] dissoc :display) - db {:ui {:open :main :target {:sid :main :id :plate :path [:plate]}} + db {:ui {:open :main :selection [:node :main :plate [:plate]]} :playback {:frame 5}}] (is (true? (:lane? (ui/polygon-landing lane {} db)))) (is (false? (:lane? (ui/polygon-landing ordinary {} db)))))) @@ -148,6 +239,21 @@ (is (nil? (get-in saved [:symbols :main :display]))) (is (nil? (get-in (store/entry (:clip/current after)) [:history :done]))))) +(deftest drawing-with-the-lane-row-active-creates-a-new-cel + (let [doc (fixture/document) + id (store/install! {:clip doc :store {}} "draw-new-lane-cel") + db {:clip/current id :paint/revision 0 + :ui {:open :main :selection [:node :main nil []]} + :playback {:frame 1}} + after (ui/beginning-polygon db) + [_ sid cel-id] (get-in after [:ui :selection]) + saved (:clip (store/entry id))] + (is (= :main sid)) + (is (not= :a cel-id) "the occupied cel is not reused") + (is (= [1 2] (node/placed-span (get-in saved [:symbols sid :nodes cel-id])))) + (is (= 1 (clip/frames saved (node/source (get-in saved [:symbols sid :nodes cel-id]))))) + (is (= :polygon (get-in after [:ui :tool]))))) + (deftest sequence-commands-use-isolated-history-transactions (let [doc (fixture/document) id (store/install! {:clip doc :store {}} "sequence-command-test") @@ -184,24 +290,22 @@ (is (= [:vo] (mapv :id (:cels lane)))) (is (empty? (timeline/sound-rows doc :main #{}))))) -(deftest aiming-the-open-lanes-own-row-is-a-selection-and-not-a-crash +(deftest selecting-the-open-lanes-own-row-is-the-lane-target ;; `rows` names the open symbol's own lane row `[:node sid nil []]`, and ;; `selected` used to open the rows above a selection with `(pop path)` — ;; which THROWS on `[]`, aborting the whole event. Nothing was selected, - ;; nothing was aimed, and the next polygon went wherever the stale target - ;; still pointed, which is most of what made aiming a lane feel random. + ;; nothing was selected, and the next polygon used stale creation state. (let [doc (fixture/document) - id (store/install! {:clip doc :store {}} "aim-the-open-lane") + id (store/install! {:clip doc :store {}} "select-the-open-lane") db {:clip/current id :paint/revision 0 - :ui {:open :main :target {:sid :main :id :a :path [:a]}} + :ui {:open :main} :playback {:frame 0}} lane (first (filter :lane? (timeline/rows doc :main #{}))) - after (ui/aimed db (:select lane))] + after (ui/selected db (:select lane))] (is (= [:node :main nil []] (:select lane))) (is (= [:node :main nil []] (get-in after [:ui :selection]))) - (is (nil? (get-in after [:ui :target])) - "the open symbol IS the place, so aiming its own row clears the target") - (is (= [] (ui/where-new-goes doc after))) + (is (= :lane (:kind (creation/target doc {} :main + (get-in after [:ui :selection]) 0)))) (is (empty? (get-in after [:ui :expanded])) "and there are no rows above the top to open"))) @@ -229,7 +333,7 @@ id (store/install! {:clip doc :store {}} "finish-opens-nothing") db {:clip/current id :paint/revision 0 :ui {:open :main :tool :polygon - :target {:sid :main :id :a :path [:a]} + :selection [:node :main :a [:a]] :draft [10 10 40 10 40 40]} :playback {:frame 1}}] (reset! rf-db/app-db db) @@ -237,7 +341,7 @@ (let [after @rf-db/app-db [kind sid shape-id path] (get-in after [:ui :selection])] (is (= :node kind)) - (is (= :drawing-a sid) "the shape went into the drawing the aimed clip places") + (is (= :drawing-a sid) "the shape went into the drawing the selected clip places") (is (= [:a shape-id] path)) (is (empty? (get-in after [:ui :expanded]))) (is (nil? (get-in after [:ui :tool])))))) @@ -260,7 +364,7 @@ :playback {:in 0 :speed 1 :end :stop}}}})) id (store/install! {:clip doc :store {}} "clip-selected-where-it-lives") db {:clip/current id :paint/revision 0 - :ui {:open :shot :target {:sid :shot :id :girl :path [:girl]}} + :ui {:open :shot :selection [:node :shot :girl [:girl]]} :playback {:frame 9}} after (ui/beginning-polygon db) [_ sid clip-id path] (get-in after [:ui :selection]) diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs index 9a9a39c..6f7b32a 100644 --- a/frontend/test/browser/lane.mjs +++ b/frontend/test/browser/lane.mjs @@ -120,6 +120,11 @@ try { assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.palette)").length'), 1, 'an ordinary symbol is an ordinary timeline row'); + // The new symbol is now the active creation parent. Return to the tab root + // to ask for a sibling lane in `main`; leaving it selected would correctly + // create the lane inside that symbol. + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector( + cljs.core.keyword('arthur.events.ui/select'), null))`); await clickNew('lane'); s = await shot(); placed = mainInstances(s); @@ -178,8 +183,11 @@ try { `the dropped clip remains in the explicit lane: ${JSON.stringify(s)}`); assert.equal(laneSymbols(s).length, 1, 'the drop creates no extra lane'); - // A second explicit lane is a sibling in the open symbol even though the - // first remains aimed. Move the clip between their linear tracks. + // Return to the root active row, then make a sibling lane there. Lane + // creation follows the selected target; the unit suite separately asserts + // that leaving a symbol selected creates the lane inside that symbol. + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector( + cljs.core.keyword('arthur.events.ui/select'), null))`); await clickNew('lane'); s = await shot(); assert.equal(laneSymbols(s).length, 2, 'a second explicit command creates a second lane'); @@ -239,7 +247,7 @@ try { await shortcut('z', 'KeyZ'); assert.equal(mainInstances(await shot()).length, 2, 'one undo restores the cut'); await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector( - cljs.core.keyword('arthur.events.ui/aim'), null))`); + cljs.core.keyword('arthur.events.ui/select'), null))`); await sleep(80); await shortcut('v', 'KeyV'); assert.equal(mainInstances(await shot()).length, 3, 'paste uses the copied snapshot'); diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 4c866f0..039c147 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -501,6 +501,17 @@ async function main() { // the authored node, the raster preview and the document round trip. await page.eval(SEEK(0)); await sleep(150); + // A lane row means "new one-frame cel". This test authors drawing keys + // across the existing take, so make that cel the active row first; creation + // then enters the symbol it places, exactly as a person clicking its block. + await page.eval(`(() => { + const cel = document.querySelector('.tl-cel'); + if (!cel?.arthurCel?.select) return false; + re_frame.core.dispatch_sync(cljs.core.vector( + cljs.core.keyword('arthur.events.ui/select'), cel.arthurCel.select)); + return true; + })()`); + await sleep(150); const beforePaint = await page.eval(`(() => { const c = document.querySelector('canvas.stage'); const d = c.getContext('2d').getImageData(25, 20, 1, 1).data; @@ -513,7 +524,7 @@ async function main() { // A person cannot click twice inside one microtask; a test should not either. const armed = await page.eval(`(() => { const button = [...document.querySelectorAll('.palette-bar button')] - .find(b => b.textContent === 'polygon'); + .find(b => b.textContent === 'pen'); if (!button) return false; button.click(); return true; diff --git a/static/arthur/app.css b/static/arthur/app.css index bb82a65..5951bc7 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -40,21 +40,16 @@ --sel: #2f6fc0; --sel-bg: #cfe0f5; - /* A SECOND accent, and the one exception to the rule above — stated here - because SELECTED and AIMED are two different facts that are often true of - one node at the same time, and one accent cannot say both. - Selected is what you are LOOKING at: the inspector's subject, the thing - with handles on it. Aimed is where a new drawing or symbol would GO. They - used to be the same state, which is how drawing a polygon ended up in - whatever you had last clicked on the stage. + /* A SECOND accent because selection and effective creation target are two + facts that are often true of one node at the same time. Violet rather than another blue: in the chrome it has to be told apart from --sel, and on the stage from the warm gold the handles are drawn in. - Colour is not the only channel either — the aim outline is SOLID where the + Colour is not the only channel either — the target outline is SOLID where the selection's box is dashed, and it carries no handles at all, because there - is nothing about being aimed at that you could drag. --aim-stage is the + is nothing about being a creation parent that you could drag. --target-stage is the same hue lifted to read against --stage, which is the one dark surface. */ - --aim: #8d4bd6; - --aim-stage: #c79bf2; + --target: #8d4bd6; + --target-stage: #c79bf2; /* A THIRD, and the last. SELECTED IN THE TIMELINE had been --sel-bg, which is #cfe0f5 against spans drawn in #dfe8f4: two pale blues a hair apart, in the @@ -62,13 +57,13 @@ is showing — an expanded lane opens exactly the selected clip. The fix is not a stronger blue, which stays in the family the spans already own, but a different hue entirely. Ochre: warm against every cool surface in the pane, - clear of --aim's violet, --live's green and the playhead's red, and dark + clear of --target's violet, --live's green and the playhead's red, and dark enough at full strength to carry white type on a 21px block. */ --pick: #a85c00; --pick-bg: #ffd9a0; --pick-fg: #4a2800; - /* Auto-key is a recording state, distinct from selection and aiming. */ + /* Auto-key is a recording state, distinct from selection and creation target. */ --live: #239447; --live-bg: #e2f5e7; @@ -1085,10 +1080,18 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } color: var(--pick-fg); } .tl-label.on .kind { color: var(--pick); } -.tl-label.aimed { box-shadow: inset 0 0 0 2px var(--aim); } -/* Both at once, and they must still be two readable facts: the aim's ring sits +.tl-label.target { box-shadow: inset 0 0 0 2px var(--target); } +/* Both at once, and they must still be two readable facts: the target ring sits inside the pick's edge rather than replacing it. */ -.tl-label.on.aimed { box-shadow: inset 3px 0 0 var(--pick), inset 0 0 0 2px var(--aim); } +.tl-label.on.target { box-shadow: inset 3px 0 0 var(--pick), inset 0 0 0 2px var(--target); } +.tl-track.target::after { + content: ""; + position: absolute; + inset: 0; + border: 2px solid var(--target); + pointer-events: none; + z-index: 4; +} .tl-label:hover:not(.on) { background: #fff; } .tl-label .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .tl-label .kind { color: var(--dim); } @@ -1213,6 +1216,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } } .tl-cel.on:hover:not(:disabled) { background: var(--pick); filter: brightness(1.12); } .tl-span.on { background: var(--pick-bg); border-color: var(--pick); } +.tl-cel.target, .tl-span.target { outline: 2px solid var(--target); outline-offset: -2px; } .tl-span.on.dense { background: repeating-linear-gradient( -45deg, var(--pick-bg) 0 3px, #f0bf77 3px 6px); @@ -1397,14 +1401,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; } .paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; } -/* The aim outline and its name tag. Solid, no handles, and a tag pinned just - outside the bottom-right corner — see `ui/stage/aim-box` for why the box +/* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just + outside the bottom-right corner — see `ui/stage/creation-box` for why the box rotates with the node and the tag does not. Sizes are in user units because this SVG's viewBox is the stage's 320x200 scaled by `zoom`. */ -.paint-overlay .aim { pointer-events: none; } -.paint-overlay .aim .aim-box { fill: none; stroke: var(--aim-stage); stroke-width: 0.7; } -.paint-overlay .aim .aim-tag-bg { fill: var(--aim-stage); } -.paint-overlay .aim .aim-tag { +.paint-overlay .creation-target { pointer-events: none; } +.paint-overlay .creation-target .creation-target-box { fill: none; stroke: var(--target-stage); stroke-width: 0.7; } +.paint-overlay .creation-target .creation-target-tag-bg { fill: var(--target-stage); } +.paint-overlay .creation-target .creation-target-tag { fill: #1b1030; font: 600 3.4px ui-sans-serif, system-ui, sans-serif; letter-spacing: 0.04px;