diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs new file mode 100644 index 0000000..2947f03 --- /dev/null +++ b/frontend/src/arthur/ui/layout.cljs @@ -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 + "− + 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)])} "+"]])) diff --git a/frontend/src/arthur/ui/palette.cljs b/frontend/src/arthur/ui/palette.cljs index 79cd841..2a333d1 100644 --- a/frontend/src/arthur/ui/palette.cljs +++ b/frontend/src/arthur/ui/palette.cljs @@ -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"]])) diff --git a/frontend/src/arthur/ui/shell.cljs b/frontend/src/arthur/ui/shell.cljs index df0b605..e936fb4 100644 --- a/frontend/src/arthur/ui/shell.cljs +++ b/frontend/src/arthur/ui/shell.cljs @@ -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]])) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 76470b3..626bc35 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -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]]])) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 867d8a2..1b6e8d9 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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]) diff --git a/frontend/src/arthur/ui/topbar.cljs b/frontend/src/arthur/ui/topbar.cljs index 5435f3a..958b1c8 100644 --- a/frontend/src/arthur/ui/topbar.cljs +++ b/frontend/src/arthur/ui/topbar.cljs @@ -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] diff --git a/static/arthur/app.css b/static/arthur/app.css index 05db3d5..c6a66ca 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -97,19 +97,32 @@ audio { display: none; } /* -------------------------------------------------------------------------- the frame */ +/* THE TRACKS ARE CUSTOM PROPERTIES, NOT LENGTHS. `ui/layout` writes all six on + this element, which is what makes the three edges between the panes draggable: + a drag is one `assoc-in` and one property, and no pane's contents re-render. + The defaults here are the same numbers `ui/layout`'s `normal` holds, written + twice so that this file still describes a window on its own. */ .app { display: grid; height: 100%; - grid-template-columns: var(--label) minmax(0, 1fr) 250px; + --pool-w: 210px; + --params-w: 250px; + --time-h: 232px; + --grip-pool: 4px; + --grip-params: 4px; + --grip-time: 4px; + grid-template-columns: + var(--pool-w) var(--grip-pool) minmax(0, 1fr) var(--grip-params) var(--params-w); /* The location bar is its own row and takes its height from the stage, not from the timeline: it exists to explain what the timeline is showing, so paying for it in timeline rows would be the wrong trade. */ - grid-template-rows: 30px minmax(0, 1fr) 21px 232px; + grid-template-rows: 30px minmax(0, 1fr) 21px var(--grip-time) var(--time-h); grid-template-areas: - "top top top" - "pool view params" - "loc loc loc" - "time time time"; + "top top top top top" + "pool gpool view gparm params" + "loc loc loc loc loc" + "gtime gtime gtime gtime gtime" + "time time time time time"; gap: 1px; background: var(--line); } @@ -121,6 +134,28 @@ audio { display: none; } .loc { grid-area: loc; } .time { grid-area: time; } +/* The draggable edge between two panes. Four pixels of chrome that lights up + under the pointer: a hairline is honest about where the boundary is and + impossible to hit, and a wide gutter would be furniture. */ +.grip { touch-action: none; background: var(--chrome); } +.grip:hover, .grip:active { background: var(--sel); } +.grip.col { cursor: col-resize; } +.grip.row { cursor: row-resize; } +.grip-pool { grid-area: gpool; } +.grip-params { grid-area: gparm; } +.grip-time { grid-area: gtime; } + +/* Which panes are open, in the top bar. Lower-case words rather than icons: the + three panes have names and the names are what the rest of the chrome calls + them. */ +.panes > button { color: var(--dim); } +.panes > button.on { color: var(--fg); } + +/* A zoom readout that is also the way back to 1:1, so it is a button that looks + like the number it shows. Tabular figures, because the + beside it must not + move as the percentage passes 100. */ +.zoom-at { min-width: 44px; text-align: center; font-variant-numeric: tabular-nums; } + /* Every pane is its own scroll container. `min-height: 0` is what lets a grid row shrink below its content instead of pushing the layout taller than the viewport — the single line that separates "a page that scrolls" from "an @@ -792,7 +827,11 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } flex: 1; min-height: 0; display: grid; - place-items: center; + /* `safe`, because the stage zooms: a centred grid item larger than its scroll + container puts its top-left edge out of reach on the start side, which at + 8x is most of the picture. Safe alignment falls back to start when it does + not fit, and centring is only ever about where a SMALL stage sits. */ + place-items: safe center; overflow: auto; padding: 14px; } @@ -959,7 +998,12 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-tracks { flex: 1; - min-width: 340px; + /* The timeline's zoom, and the whole of it: every mark in here is positioned + as a percentage of this element's width, so one multiplier on the width + spreads the frame grid, the spans, the keys, the ruler and the playhead + apart together, and `.tl-body` was already a scroll container. 1 is the + width that exactly fills the body, which is what `flex: 1` alone gave. */ + min-width: max(340px, calc((100% - var(--label)) * var(--tl-zoom, 1))); position: relative; background: #fff; /* The frame grid, five frames to a division, as a background rather than as @@ -1296,3 +1340,104 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } hold, dimmed because its own frames have no place on this ruler. */ .tl-span.unmapped { opacity: 0.45; cursor: pointer; } .tl-hint.refusing { background: var(--bad, #b00); color: #fff; } + +/* -------------------------------------------------------------------------- + narrow windows + + A phone is not a small desk: there is room for ONE column, so the stage gets + it and the two side panes become drawers over the top of it rather than + columns beside it. They are mounted only while open (`ui/shell`), so a shut + drawer is not holding thumbnails live, and the top bar's pane buttons are + what opens them — which is why they are at the left end of a strip that + scrolls. + + The timeline keeps its row. Shut, that row is its transport strip and + nothing else, because a window with nowhere to press play is broken rather + than small; its height is still draggable, by touch, on the grip above it. */ + +@media (max-width: 760px) { + /* A uuid-wide label column is half the screen. */ + :root { --label: 136px; } + + .app { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 30px minmax(0, 1fr) 21px var(--grip-time) var(--time-h); + grid-template-areas: + "top" + "view" + "loc" + "gtime" + "time"; + } + + /* Everything in the strip stays reachable, by scrolling it. */ + .top { overflow-x: auto; } + .project-title { max-width: 32vw; } + /* So that 34px below is the same distance on both pages: the editor's strip + takes its 30px from the grid row, and the index page has no grid. */ + .index > .top { flex: 0 0 30px; } + + /* A SCROLL CONTAINER CLIPS BOTH AXES. `overflow-x: auto` above makes the + strip one, so a panel anchored to its own button — open, snapshots, share, + sign in — is cut off by a 30px-tall bar and drawn at whatever x the strip + happens to be scrolled to, which reads as the button doing nothing at all. + Fixed to the viewport instead, as `.menu-drop` already is for the same + reason one pane down: these are all top-bar menus, the top bar is at the top + of the window, and a near-full-width sheet is what there is room for. Both + offsets on the axis, so `.menu-left`'s anchoring is overridden rather than + left half-applied. `.menu-drop` is excluded because `ui/menu` places that + one itself, inline, and would then be over-constrained. */ + .top .menu:not(.menu-drop), + .top .menu.menu-left:not(.menu-drop) { + position: fixed; + top: 34px; + left: 4px; + right: 4px; + width: auto; + min-width: 0; + } + + /* Out of the grid altogether: fixed to the viewport, so the column the grid + has left is the stage's whether a drawer is open or not. */ + .pool, .params { + position: fixed; + z-index: 30; + top: 31px; + bottom: 0; + width: min(78vw, 300px); + box-shadow: 0 2px 16px rgba(0, 0, 0, .4); + } + .pool { left: 0; border-right: 1px solid var(--line); } + .params { right: 0; border-left: 1px solid var(--line); } + + /* There is no column edge left to drag. */ + .grip.col { display: none; } + + /* The picture, not the desk around it. */ + .stage-area { padding: 5px; } + + /* Touch targets, in the strips that are all buttons. */ + .pane-head, .palette-bar { gap: 7px; } + .palette-bar button, .pane-head button, .top button { min-height: 24px; } + + /* A STRIP OF CONTROLS SCROLLS AS A STRIP. Flexbox's instinct when a row does + not fit is to shrink every item and then push the last ones off the end — + and the last ones here are the polygon tool, the timing commands and both + zoom readouts, which would be unreachable rather than merely off-screen. + So nothing shrinks, nothing wraps, and each strip scrolls. The `.spacer` + and `.status` rules above still win on specificity, which is what keeps the + right-hand groups right-hand while there IS room. */ + .top > *, .pane-head > *, .palette-bar > * { flex-shrink: 0; } + .top button, .pane-head button { white-space: nowrap; } + .pane-head, .palette-bar { overflow-x: auto; } + + /* The one compressible thing in the palette bar is the swatches, so they + scroll inside their own share instead of taking it from the commands. The + tone's NAME goes: the ringed swatch already says which one is active, and + it is the widest thing in the bar that nothing is lost by dropping. */ + .palette-bar .swatches { flex: 1 1 130px; min-width: 56px; overflow-x: auto; } + /* A swatch is a 15px circle or it is not a swatch: the strip scrolls, the + dots do not get thinner. */ + .palette-bar .swatches > * { flex-shrink: 0; } + .palette-bar > .dim { display: none; } +}