The window's shape is editor state, not a constant

The grid was five panes at three fixed widths and one fixed height, written as
lengths in the stylesheet. That is a claim about every screen the tool is ever
opened on, and it was wrong on two of them: a phone, where 210 + 250 pixels of
side pane leave nothing for a 320x200 stage, and a large display, where the
inspector is the width somebody once typed rather than the width their work
wants.

So the tracks are custom properties and `ui/layout` owns the numbers. A drag of
an edge is one `assoc-in` and one property on `.app`; no pane's contents
re-render, which is the same reason the picture is painted by `ui/player`'s loop
rather than by anything reactive. Sizes stay in PIXELS and not fractions —
three of these panes hold fixed-width rows, so what you drag an edge to is the
width you meant at any window size.

State, events and widgets are one namespace, which is not this repo's shape
anywhere else. They are the same six integers: `:pool` is a width, a grid track,
a drag's clamp and a button's label at once, and splitting six integers across
`db`, `events/` and `ui/` is more wiring than state.

Three things follow from making it state at all:

SHUTTING A PANE. Pool and inspector unmount; the timeline collapses to its
transport strip instead, because play, pause and the frame readout live in that
strip and a window with nowhere to press play is broken rather than small. The
toggles are in the top bar because a shut pane has no head left to carry its
own handle.

A NARROW WINDOW. Below 760px the grid is one column and the side panes are
drawers over the stage rather than columns beside it. The breakpoint is read
once, at load, and only decides what the layout OPENS at: throwing away the
sizes somebody dragged because they turned a tablet sideways is worse than a
layout that is briefly the wrong shape.

ZOOM. The stage's was already a constant 2 in `ui/stage`; it is now an integer
in [1 8], still CSS over a canvas that is the raster's own size, so the browser
suite still reads 320x200 of real pixels off `canvas.stage`. Whole pixels
only — a fractional scale under `image-rendering: pixelated` draws some rows of
the raster thicker than others, which misrepresents the one thing the preview
exists to judge. The timeline's is the stylesheet's entirely: every mark in the
tracks is positioned as a percentage of their width, so one multiplier on that
width spreads the grid, the spans, the keys, the ruler and the playhead apart
together. Both readouts are the button back to normal, accented while there is
something to return from, because the quick way back belongs in the one place
you already look to find out where you are.

One trap, paid for and then found: the top strip has to scroll sideways on a
narrow screen, and `overflow-x: auto` makes an element a clipping container on
BOTH axes. Every top-bar menu — open, snapshots, share, sign in — was then cut
off by a 30px-tall bar and drawn at whatever x the strip happened to be
scrolled to, which reads as the button doing nothing at all. They are fixed to
the viewport there, as `.menu-drop` already was one pane down for the same
reason. The same shape of bug ate the polygon tool and both zoom readouts: a
flex row that does not fit shrinks every item and pushes the last ones off the
end, so nothing in a strip shrinks now and the strip scrolls as a strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-01 21:50:05 -04:00
parent 93f5112bb3
commit 4e5e02c856
7 changed files with 374 additions and 32 deletions

View file

@ -0,0 +1,167 @@
(ns arthur.ui.layout
"How big the panes are, which of them are shut, and at what zoom the two
zoomable surfaces are drawn.
ONE NAMESPACE FOR STATE, EVENTS AND WIDGETS, which is not this repo's shape
anywhere else. The reason is that all three are the same handful of numbers:
`:pool` is a pane's width in pixels, a grid track, a drag's clamp and a
button's `aria` label, and splitting six integers across `db`, `events/` and
`ui/` would be more wiring than state. Nothing below is about the document.
The panes stay sized in PIXELS rather than fractions. A pane whose width is a
share of the window changes size when the window does, and three of these hold
lists of fixed-width rows — a label column, a swatch strip, an inspector — so
what somebody drags the edge to is the width they wanted, at any window size.
THE TIMELINE DOES NOT SHUT AWAY ITS HEAD. Shutting it leaves the transport
strip on screen, because that strip is play, pause and the frame readout: a
window with nowhere to press play is not a smaller window, it is a broken one.
The library and the inspector unmount instead — on a narrow screen they are
drawers over the stage rather than columns beside it, which is the stylesheet's
half of this (see `@media` in app.css), and a drawer that is shut should not be
holding video thumbnails live."
(:require [re-frame.core :as rf]))
(def ^:private narrow?
"A phone-shaped window, read ONCE at load. It decides what the layout opens
at and nothing after that: a resize past the breakpoint re-lays the grid out
through the stylesheet, and silently throwing away the sizes somebody dragged
because they turned their tablet sideways would be a worse answer than a
layout that is briefly the wrong shape. Guarded for the node test build, which
has no window."
(and (exists? js/window) (< (.-innerWidth js/window) 760)))
(def normal
"The layout nobody chose: what the window opens at, and what the zoom readouts
go back to when pressed. Narrow windows open with both side panes shut and the
stage at 1:1, because 320x200 at 2x is wider than the screen it would be the
only thing on."
(merge {:pool 210 :params 250 :time 232 :stage 2 :tl 1 :shut #{}}
(when narrow? {:time 150 :stage 1 :shut #{:pool :params}})))
(def ^:private limits
"[lo hi] per key — pixels for a pane, a multiplier for a zoom. The low end of
`:time` is the transport strip plus a row, so dragging the timeline shut and
shutting it are the same shape of window."
{:pool [120 480] :params [150 560] :time [44 660] :stage [1 8] :tl [1 24]})
(defn- clamp [k v]
(let [[lo hi] (limits k)] (max lo (min hi v))))
(rf/reg-sub ::state (fn [db _] (merge normal (get-in db [:ui :layout]))))
(rf/reg-sub ::zoom :<- [::state] (fn [state [_ k]] (k state)))
(rf/reg-event-db
::set
;; Pixels for a pane, a multiplier for a zoom, clamped either way.
(fn [db [_ k v]] (assoc-in db [:ui :layout k] (clamp k v))))
(rf/reg-event-db
::normal
(fn [db [_ k]] (assoc-in db [:ui :layout k] (k normal))))
(rf/reg-event-db
::toggle
(fn [db [_ k]]
(update-in db [:ui :layout :shut]
#(let [shut (or % (:shut normal))]
(if (contains? shut k) (disj shut k) (conj shut k))))))
(defn vars
"The grid, as custom properties for `.app` to resolve its tracks against. A
shut pane is a zero-width track AND a zero-width grip: there is no edge to
drag when there is nothing on the other side of it."
[{:keys [pool params time tl shut]}]
(let [gone? #(contains? shut %)]
{"--pool-w" (if (gone? :pool) "0px" (str pool "px"))
"--params-w" (if (gone? :params) "0px" (str params "px"))
;; Head-height, not zero: see the namespace docstring.
"--time-h" (if (gone? :time) "22px" (str time "px"))
"--grip-pool" (if (gone? :pool) "0px" "4px")
"--grip-params" (if (gone? :params) "0px" "4px")
"--grip-time" (if (gone? :time) "0px" "4px")
;; The timeline's zoom is entirely the stylesheet's: every mark in the
;; tracks is positioned as a percentage of their width, so widening them
;; widens the frame grid, the spans, the ruler and the playhead together.
"--tl-zoom" tl}))
;; Which grip is being dragged, and from where. A PLAIN atom: it lives for the
;; length of one drag and nothing renders from it, as in `ui/stage`.
(defonce ^:private sizing (atom nil))
(defn grip
"The edge between two panes, as something you can grab. `sign` is which way
the pointer grows the pane — the inspector's edge is on its left, so dragging
left makes it wider."
[k size axis sign]
(let [at (fn [^js e] (if (= :col axis) (.-clientX e) (.-clientY e)))]
[:div
{:class (str "grip grip-" (name k) " " (name axis))
:role "separator"
:aria-label (str "resize the " (name k) " pane")
:on-pointer-down
(fn [^js e]
(.preventDefault e)
;; The size is read HERE and the drag is absolute from it, rather than
;; accumulating per-move deltas: a clamped increment loses the pointer
;; at the limits, and the pane then lags the finger coming back.
(reset! sizing [(at e) size])
;; Capture is what keeps a drag alive once the pointer leaves a 4px
;; target. As in `ui/timeline`'s ruler it throws on a synthesised
;; pointer id, and what the catch loses is the drag and nothing else.
(try (.setPointerCapture (.-currentTarget e) (.-pointerId e))
(catch :default _ nil)))
:on-pointer-move
(fn [^js e]
(when-let [[from from-size] @sizing]
(rf/dispatch [::set k (+ from-size (* sign (- (at e) from)))])))
:on-pointer-up (fn [_] (reset! sizing nil))
:on-pointer-cancel (fn [_] (reset! sizing nil))}]))
(defn panes
"Which panes are open, as a segmented control in the top bar. In the top bar
because a shut pane has no head left to carry its own handle, and because on a
narrow screen this is the whole of the navigation."
[]
(let [{:keys [shut]} @(rf/subscribe [::state])]
[:div.seg.panes {:aria-label "panes"}
(doall
(for [[k label] [[:pool "pool"] [:params "inspector"] [:time "timeline"]]]
(let [open? (not (contains? shut k))]
^{:key k}
[:button {:class (when open? "on")
:aria-pressed open?
:title (str (if open? "hide the " "show the ") label)
:on-click #(rf/dispatch [::toggle k])}
label])))]))
(defn- stepped [k z out?]
;; The stage steps by WHOLE pixels. A fractional scale under
;; `image-rendering: pixelated` draws some rows of the raster thicker than
;; others, which misrepresents the one thing the preview exists to judge. The
;; timeline has no pixel grid to honour, so it steps geometrically.
(if (= :stage k)
(+ z (if out? -1 1))
(* z (if out? (/ 1 1.5) 1.5))))
(defn zoomer
"− <readout> + for one zoomable surface. THE READOUT IS THE BUTTON BACK: it
says what the zoom is, pressing it returns to normal, and it is accented while
it has something to return from — so the quick way back is in the one place
you already look to find out where you are."
[k label]
(let [z @(rf/subscribe [::zoom k])
[lo hi] (limits k)
home (k normal)]
[:div.group.zoomer
[:button {:disabled (<= z lo) :aria-label (str label " zoom out")
:title (str "zoom " label " out")
:on-click #(rf/dispatch [::set k (stepped k z true)])} "−"]
[:button.zoom-at {:class (when (not= z home) "on")
:aria-label (str label " zoom")
:title (str "back to " (js/Math.round (* 100 home)) "% — the normal zoom")
:on-click #(rf/dispatch [::normal k])}
(str (js/Math.round (* 100 z)) "%")]
[:button {:disabled (>= z hi) :aria-label (str label " zoom in")
:title (str "zoom " label " in")
:on-click #(rf/dispatch [::set k (stepped k z false)])} "+"]]))

View file

@ -20,6 +20,7 @@
(:require [arthur.domain.palette :as pal]
[arthur.events.ui :as ui]
[arthur.subs.ui :as sub]
[arthur.ui.layout :as layout]
[re-frame.core :as rf]))
(def ^:const slots 16)
@ -61,4 +62,8 @@
[:button {:disabled (< (count draft) 6)
:on-click #(rf/dispatch [::ui/finish-polygon])} "finish"]
[:button {:on-click #(rf/dispatch [::ui/cancel-polygon])} "cancel"]]
[:button {:on-click #(rf/dispatch [::ui/begin-polygon])} "polygon"])]))
[:button {:on-click #(rf/dispatch [::ui/begin-polygon])} "polygon"])
;; The stage's zoom, at the right end of the bar above the stage: it is a
;; property of the view and not of the document, so it sits in the view's
;; own chrome rather than in the inspector.
[layout/zoomer :stage "the stage"]]))

View file

@ -2,7 +2,8 @@
"The window: one grid, five panes, a location bar, and the audio element.
Nothing else. Each pane owns its own subscriptions, so this component re-renders
only when the grid itself would change — which is never. The picture is put on
only when the grid itself would change — which is a pane being resized, shut or
reopened, and nothing a pane's contents can do. The picture is put on
the canvas by `ui/player`'s loop rather than by anything here re-rendering,
which is why scrubbing at speed does not touch React at all."
(:require [arthur.clock :as clock]
@ -10,6 +11,7 @@
[arthur.events.playback :as pb]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.ui.layout :as layout]
[arthur.ui.location :as location]
[arthur.ui.palette :as palette]
[arthur.ui.params :as params]
@ -45,18 +47,28 @@
(finally (r/dispose! remix))))
(defn view []
[:div.app
[topbar/view]
[pool/view]
[:section.view
[tabs/view]
[palette/bar]
[stage/view]]
[params/view]
;; Above the timeline rather than inside it: the bar says where an edit would
;; land, which is a fact about the SELECTION and not about either temporal
;; view, and both views are drawn below it unchanged.
[location/view]
[timeline/view]
[convert/view]
[audio]])
;; The only component that re-renders when the grid changes, which is what the
;; namespace docstring says never happens — it happens now, on a pane being
;; dragged or shut, and that is the whole of it: every pane still owns its own
;; subscriptions and a drag of the inspector's edge re-renders this div and no
;; pane's contents.
(let [{:keys [pool params shut] :as state} @(rf/subscribe [::layout/state])
gone? #(contains? shut %)]
[:div.app {:style (layout/vars state)}
[topbar/view]
(when-not (gone? :pool) [pool/view])
(when-not (gone? :pool) [layout/grip :pool pool :col 1])
[:section.view
[tabs/view]
[palette/bar]
[stage/view]]
(when-not (gone? :params) [layout/grip :params params :col -1])
(when-not (gone? :params) [params/view])
;; Above the timeline rather than inside it: the bar says where an edit would
;; land, which is a fact about the SELECTION and not about either temporal
;; view, and both views are drawn below it unchanged.
[location/view]
(when-not (gone? :time) [layout/grip :time (:time state) :row -1])
[timeline/view]
[convert/view]
[audio]]))

View file

@ -22,14 +22,15 @@
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[arthur.ui.drag :as drag]
[arthur.ui.layout :as layout]
[arthur.ui.player :as player]
[arthur.ui.underlay :as underlay]
[re-frame.core :as rf]))
(def ^:const zoom
"Integer, and the browser suite reads the canvas's own pixels rather than a
screenshot because of it."
2)
;; THE ZOOM IS NOT A CONSTANT ANY MORE — it is `ui/layout`'s, an integer in
;; [1 8], 2 to begin with. It scales the canvas with CSS and never its backing
;; store, so the browser suite still reads 320x200 of real pixels off
;; `canvas.stage` whatever the view is zoomed to; see `ui/canvas`.
(defn stage-point
"Where a pointer event landed, in stage pixels. Shared by the vertex editor and
@ -262,7 +263,7 @@
:width (+ 2 (* 2.1 (count label))) :height 5}]
[:text.aim-tag {:x 1 :y 3.8} label]])]))))
(defn- overlay [w h]
(defn- overlay [w h zoom]
(let [tool @(rf/subscribe [::sub/tool])
draft @(rf/subscribe [::sub/draft])
drawing? (= :polygon tool)
@ -351,7 +352,8 @@
;; reallocates the backing store — so this re-rendering costs nothing per frame.
(let [w @(rf/subscribe [::playback/width])
h @(rf/subscribe [::playback/height])
frame @(rf/subscribe [::playback/frame])]
frame @(rf/subscribe [::playback/frame])
zoom @(rf/subscribe [::layout/zoom :stage])]
[:div.stage-area
[:div.stage-wrap
;; A drop on the stage lands at the PLAYHEAD, where the pointer is in space;
@ -376,4 +378,4 @@
:height (str (* zoom h) "px")}}]
[:canvas.underlay {:ref #(underlay/set-canvas! %)
:width (* zoom w) :height (* zoom h)}]
[overlay w h]]]))
[overlay w h zoom]]]))

View file

@ -35,6 +35,7 @@
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[arthur.ui.drag :as drag]
[arthur.ui.layout :as layout]
[arthur.ui.player :as player]
[re-frame.core :as rf]
[reagent.core :as r]))
@ -484,6 +485,11 @@
:title "move the selected clip's end to the playhead"
:on-click (act [::ui/trim :out])} "out"]]
[:span.spacer]
;; How many pixels a frame is. The tracks are positioned in percentages of
;; their own width, so this is one custom property on the grid and no
;; arithmetic here — see `ui/layout`.
[layout/zoomer :tl "the timeline"]
[:span.sep]
;; After the spacer, both of them: an offer that appears and a reading that
;; comes and goes must not shove the fixed controls sideways when they do.
(when @(rf/subscribe [::sub/retry])

View file

@ -9,6 +9,7 @@
[arthur.events.export :as export]
[arthur.events.project :as project]
[arthur.subs.playback :as playback]
[arthur.ui.layout :as layout]
[arthur.ui.openmenu :as openmenu]
[arthur.ui.share :as share]
[arthur.ui.snapshots :as snapshots]
@ -58,6 +59,10 @@
[:header.top
[:a.brand {:href "/" :title "your projects"
:on-click (fn [e] (.preventDefault e) (collab/navigate! "/"))} "arthur"]
;; At the left end, before the document's own commands: on a narrow window
;; this strip scrolls, and which panes are open is the one control that
;; must not be the thing off the end of it.
[layout/panes]
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"]
[openmenu/view]
[undo/view]