From d8e9fbda26cf40a8164444713b43b174b11c8918 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 17:08:51 +0200 Subject: [PATCH] Round paging follows the finger: faces and dashboards on a track, swiped one to one with the next beside it, turned or sent back on release, rubber-banded at the ends; keys and dots slide the same track; the reading dot a smaller companion of the handle --- docs/interface/dashboards.md | 58 +- .../components/Dashboard/DashboardView.tsx | 5 +- .../src/components/Dashboard/PanelSurface.tsx | 7 +- .../src/components/Dashboard/RoundFace.tsx | 536 +++++++++++++----- .../src/components/Dashboard/ui/core/core.css | 4 +- .../src/components/Dashboard/ui/core/ring.ts | 35 +- frontend/src/routes/panel.$id.tsx | 12 +- 7 files changed, 474 insertions(+), 183 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index e27ef28..0997e2b 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -101,10 +101,12 @@ the track, the value's fill, or the handle. | Dot | Size, tile / ring | On the track | On the fill | On the handle | |---|---|---|---|---| | Stop: a tick, an option | half the track's thickness (4 on the 8 track) / 6 | `--muted-foreground` | `--primary-foreground` at 70 % | hidden under it | -| Reading | three quarters of the track's thickness (6) / 10 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % | +| Reading | the track's thickness (8) / 12 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % | -The reading is the larger dot in the stronger colour, and it is the one dot -that shows on the handle, so a reading at the setpoint is still visible. +The reading is a filled circle like the handle, smaller and in the text colour +rather than the handle's paint, so it reads as what the room is beside what it +is set to. It is larger than a stop and stronger in colour, and it is the one +dot that shows on the handle, so a reading at the setpoint is still visible. | Part | Value | |---|---| @@ -260,11 +262,11 @@ face, and the editor marks it **Poor fit for round**. | Shift+PageDown / Shift+PageUp | next / previous dashboard of the panel | | Arrow keys | turn the ring, not the page | -A swipe is a drag of at least 40 canvas pixels, decided by its longer axis; a -drag that starts on the ring turns the ring instead. A dashboard reopens on the -face it was last left on in this session, otherwise on its first. Landing on -another dashboard shows its title and icon in the middle of the face for one -second; a round panel has no rail. +A swipe carries the page with the finger ([Swiping](#swiping)); a drag that +starts on the ring turns the ring instead. A dashboard reopens on the face it +was last left on in this session, otherwise on its first. Landing on another +dashboard shows its title and icon in the middle of the face for one second; a +round panel has no rail. ### Geometry @@ -282,7 +284,7 @@ look's muted text colour. | Ring band | 40 along the edge | | Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius c − 20, stroke 16 with round caps | | Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across, its ticks as [dots](#dots-on-the-track); the value as the centre reading | -| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 10 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) | +| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 12 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) | | Selector | a value arc from the start to the selected option (none at the first), a dot 6 across per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 | | Colour | hue h at h/360 of the sweep in 150 strokes of 2°, red at both ends; the handle filled with the colour; a swatch 96 across in the middle, with a hairline border | | Gauge | track and value arc, no handle; the value as the centre reading | @@ -326,10 +328,10 @@ dashboard's look's. | What | Moves | From → to | Timing | Input during it | |---|---|---|---|---| -| Face paging (round) | the new face and the old | new: x +48 (next) or −48 (previous), opacity 0 → x 0, opacity 1; old: x 0, opacity 1 → x −48 (next) or +48, opacity 0. Both at once | spring | another page retargets: each face heads for its new end from where it is | -| Dashboard paging (round panel) | the new dashboard's face and the old | the same on y: the next rises from +48, the previous comes down from −48. One opened by its address only fades | spring | as faces | +| Face paging (round) | the faces, side by side on a track | the track slides one face width S, the new face coming in from the side it lies on (the next from the right). A dot that skips faces brings its face in beside the one shown, so a turn is always one width | spring, from where the track is (a swipe's release speed carried on) | a new turn while one runs: the running one lands at once and the next starts | +| Dashboard paging (round panel) | the dashboard, and beside it the next one's name | slides S up (the next, from below) or down (the previous, from above), the name pill centred on an otherwise empty face; once it has landed and the dashboard has loaded, the dashboard replaces its name in place. One opened by its address replaces the last at once | spring | further paging waits for the turn to land | | Page dot | the current dot and the last | muted at 0.5 opacity ↔ text colour at 1 | 200 ms, cubic-bezier(0.4, 0, 0.2, 1) | retargets | -| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — | +| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0; on a dashboard a turn brought, where the name came in already, it starts at 1 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — | | Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has | | Ring value set by a drag | the same | follows the pointer exactly | none | — | | Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — | @@ -341,17 +343,31 @@ dashboard's look's. | Dashboard change (rectangular panel) | every tile | the old dashboard goes at once; each new tile opacity 0 → 1, y +8 → 0 | spring | — | | Rail | the current entry's marker | slides to the new entry | spring | retargets | -With reduced motion asked for, nothing travels: pages and dashboards only -cross-fade (opacity at the spring), and every value is drawn at its new place -at once. The dot and the name pill, which only change colour or opacity, keep -their timing. +With reduced motion asked for, nothing travels on its own: a page under the +finger still follows it, but every turn, return and value lands at once. The +dot and the name pill, which only change colour or opacity, keep their timing. A device may simplify where its hardware is slow, keeping the direction and -the end state: a page may slide without the fade, fade without the slide, or -cut; a retarget may restart the curve from where it is without carrying the -speed; a counting number may switch; and the value arc may be redrawn each -frame as an arc to the eased angle, which is what the browser's reveal -amounts to. +the end state: a turn a key or a dot asked for may cut; a retarget may restart +the curve from where it is without carrying the speed; a counting number may +switch; and the value arc may be redrawn each frame as an arc to the eased +angle, which is what the browser's reveal amounts to. + +### Swiping + +Faces are swiped sideways and a panel's dashboards up and down, with the page +under the finger, as a tile view moves. + +| Part | Value | +|---|---| +| Start | a press anywhere on the face but the ring band, which turns the ring instead | +| Axis | none until the drag has gone 8 from where it began along either axis; then the longer of the two, kept until release | +| Follow | the page moves with the drag one to one, and the page that way is drawn beside it, edge to edge, S away: the next face, or the next dashboard's name pill on an otherwise empty face | +| Past the last | nothing beside it; the page moves 0.3 of the drag, at most 48 | +| Release | turns if the drag went at least S/4 (90 on the Knob), or went at least 8 and was moving at least 500 a second towards the next page over its last 80 ms; otherwise goes back | +| Turn, go back | the look's spring from where the page is, carrying the release speed; a device may use the look's cubic-bezier over its duration from where it is | +| Press during a turn | lands the turn at once; the new drag starts from a page at rest | +| A swipe that began on a control | is not a press of it | ## Showing one diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 6b76dbb..b2568bb 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -347,6 +347,7 @@ export function DashboardView({ rail, editing, onDashboard, + around, }: { dashboard: Dashboard renderWidget?: (widget: WidgetDef) => React.ReactNode @@ -358,8 +359,9 @@ export function DashboardView({ * stacked editor draws through here, where the canvas draws its own grid. */ editing?: boolean /** A round one's way to the panel's other dashboards: next (1) or - * previous (-1). */ + * previous (-1), and their names. */ onDashboard?: (by: number) => void + around?: [string | undefined, string | undefined] }) { const all = widgetsOf(dashboard) const round = isRound(dashboard) @@ -397,6 +399,7 @@ export function DashboardView({ dashboard={dashboard} size={canvasOf(dashboard).width} onDashboard={onDashboard} + around={around} /> ) : ( void + around?: [string | undefined, string | undefined] }) { const hasRail = Boolean(rail) const stack = stacked && !isRound(dashboard) @@ -53,9 +55,10 @@ export function PanelSurface({ dashboard={dashboard} rail={hasRail} onDashboard={onDashboard} + around={around} /> ), - [dashboard, hasRail, onDashboard], + [dashboard, hasRail, onDashboard, around], ) return (
diff --git a/frontend/src/components/Dashboard/RoundFace.tsx b/frontend/src/components/Dashboard/RoundFace.tsx index a5ac503..5e82b30 100644 --- a/frontend/src/components/Dashboard/RoundFace.tsx +++ b/frontend/src/components/Dashboard/RoundFace.tsx @@ -1,12 +1,15 @@ +import { useQuery } from "@tanstack/react-query" import { Lock, Power } from "lucide-react" import { AnimatePresence, + animate, motion, - useIsPresent, + useMotionValue, + useReducedMotion, useTransform, - type Variants, } from "motion/react" -import { useEffect, useRef, useState } from "react" +import { useEffect, useLayoutEffect, useRef, useState } from "react" +import { flushSync } from "react-dom" import { ErrorBoundary } from "react-error-boundary" import type { WidgetDef } from "@/client" import { slotsFor } from "@/components/Common/UplotChart" @@ -17,6 +20,7 @@ import type { Dashboard } from "./DashboardView" import { useBoundValue, useBoundValues } from "./dataContext" import { resolveIcon } from "./icons" import { usePublish } from "./publish" +import { dashboardQueryOptions } from "./queries" import { CentreReading, FaceTitle, hasGapLine, Ring } from "./RingWidget" import { useLocked, usePalette } from "./settings" import { useUi } from "./ui" @@ -48,13 +52,15 @@ import { fitSize, GAP_RISE, GAP_SIZE, + PAGE_SLOP, PRESS, + pageOffset, RING_START, RING_SWEEP, - SWIPE, TITLE_RISE, TITLE_SIZE, TITLE_WIDTH, + turnsPage, } from "./ui/core/ring" import { barReadings, @@ -103,85 +109,253 @@ const lastFace = new Map() /** The dashboard a panel showed last, so landing on another can say which. */ let lastShown: string | null = null -/** How far a face or a dashboard slides in as it is paged to, in canvas - * pixels, fading in as it comes. */ -const PAGE_SLIDE = 48 - -/** A face arrives from the side it was paged towards. */ -const TURN: Variants = { - enter: (way: number) => ({ opacity: 0, x: way * PAGE_SLIDE }), - shown: { opacity: 1, x: 0 }, - leave: (way: number) => ({ opacity: 0, x: way * -PAGE_SLIDE }), +/** + * One way a round face is swiped: sideways through its faces, or up and down + * through the panel's dashboards. Offsets and speeds are canvas pixels. + */ +type Axis = { + /** Whether there is a page onwards (1) or back (-1). */ + open: (by: 1 | -1) => boolean + /** The page dragged to `offset`, the one `by` that way beside it. */ + drag: (offset: number, by: 1 | -1) => void + /** Let go: turn to the page that way, or go back, at `speed` per second. */ + release: (by: 1 | -1, turn: boolean, speed: number) => void } -/** And a dashboard from below or above, as the faces do from the side. */ -const RISE: Variants = { - enter: (way: number) => ({ opacity: 0, y: way * PAGE_SLIDE }), - shown: { opacity: 1, y: 0 }, - leave: (way: number) => ({ opacity: 0, y: way * -PAGE_SLIDE }), +/** + * Swiping, with the page under the finger. + * + * Nothing moves until the drag has gone `PAGE_SLOP` along one axis, which it + * then keeps; from there the page follows the finger exactly and the next one + * comes in beside it, or, past the last, the page gives a little and no more. + * On release it turns if it went far enough or was flung (`turnsPage`), and + * goes back otherwise. A drag that starts on the ring turns the ring instead. + */ +function usePageDrag( + size: number, + box: React.RefObject, + axes: { x: Axis; y?: Axis }, + swiped: React.RefObject, + /** Settles whatever is still moving before a new drag takes hold. */ + settle: () => void, +) { + const latest = useRef(axes) + latest.current = axes + return (event: React.PointerEvent) => { + swiped.current = false + if (!event.isPrimary) return + if ((event.target as Element).closest(".dui-ring-hit")) return + settle() + const scale = (box.current?.getBoundingClientRect().width || size) / size + const from = { x: event.clientX, y: event.clientY } + let axis: "x" | "y" | null = null + let along = 0 + // The last moments of the drag, for the speed it was let go at. + const trail: [number, number][] = [] + const target = () => (axis === "y" ? latest.current.y : latest.current.x) + + const move = (moved: PointerEvent) => { + if (moved.pointerId !== event.pointerId) return + const dx = (moved.clientX - from.x) / scale + const dy = (moved.clientY - from.y) / scale + if (!axis) { + if (Math.max(Math.abs(dx), Math.abs(dy)) < PAGE_SLOP) return + axis = Math.abs(dx) >= Math.abs(dy) ? "x" : "y" + swiped.current = true + } + along = axis === "x" ? dx : dy + trail.push([moved.timeStamp, along]) + while (trail.length > 2 && moved.timeStamp - trail[0][0] > 80) { + trail.shift() + } + // Towards the left, or up, is onwards, as a page is turned. + const by = along < 0 ? 1 : -1 + const page = target() + page?.drag(pageOffset(along, page.open(by)), by) + } + const end = (ended: PointerEvent) => { + if (ended.pointerId !== event.pointerId) return + window.removeEventListener("pointermove", move) + window.removeEventListener("pointerup", end) + window.removeEventListener("pointercancel", end) + const page = target() + if (!axis || !page) return + // Over the last 80 ms before letting go: a drag held still and then + // released was not flung. + const recent = trail.filter(([at]) => ended.timeStamp - at <= 80) + const [then, was] = recent[0] ?? [ended.timeStamp, along] + const speed = + ended.timeStamp > then + ? ((along - was) / (ended.timeStamp - then)) * 1000 + : 0 + const by = along < 0 ? 1 : -1 + page.release( + by, + ended.type === "pointerup" && + page.open(by) && + turnsPage(along, speed, size), + speed, + ) + } + window.addEventListener("pointermove", move) + window.addEventListener("pointerup", end) + window.addEventListener("pointercancel", end) + } } /** * A panel's round dashboards, one at a time, paged up and down. * - * The next one rises in from below as the last leaves upwards — the way a - * swipe up moved them — and the previous one comes down from above. One that - * arrives without a turn, by its address, only fades in. + * Swiped, the dashboard follows the finger and the next one's name comes up + * from below it (the previous one's down from above); let go far enough and + * the turn completes, and the dashboard itself takes its name's place once it + * has loaded. Shift+PageUp/PageDown turn the same way. One opened by its + * address replaces the last at once. */ export function RoundPanel({ dashboard, size, onDashboard, + around, }: { dashboard: Dashboard size: number onDashboard?: (by: number) => void + /** The panel's dashboards before and after this one. */ + around?: [string | undefined, string | undefined] }) { const { look } = useLook() - // Read between renders: the turn asked for, and the side the dashboard it - // brings arrives from once it has loaded. - const turned = useRef(0) - const shown = useRef(dashboard.name) - const way = useRef(0) - if (shown.current !== dashboard.name) { - shown.current = dashboard.name - way.current = turned.current - turned.current = 0 - } - return ( - - - { - turned.current = by - onDashboard(by) - }) - } - /> - - + const still = useReducedMotion() + const spring = LOOK[look].spring + const y = useMotionValue(0) + // The dashboard held at the origin while a turn is on its way to the next, + // the side the next one's name is on, whether the turn has landed, and the + // last dashboard a turn brought, which has been named already. + const [held, setHeld] = useState(null) + const [beside, setBeside] = useState<{ by: 1 | -1; name: string } | null>( + null, ) + const [landed, setLanded] = useState(false) + const [reached, setReached] = useState(null) + const shown = held ?? dashboard + const name = (by: number) => around?.[by > 0 ? 1 : 0] + + const turn = (by: 1 | -1, speed = 0) => { + const next = name(by) + if (!next || !onDashboard || held) return + setHeld(dashboard) + // Its name as it was when the turn began: the panel's neighbours are + // counted from the new dashboard as soon as it is asked for. + setBeside({ by, name: next }) + setLanded(false) + onDashboard(by) + if (still) { + y.jump(-by * size) + setLanded(true) + return + } + // Landed once it is within a pixel, rather than when the spring has + // quite come to rest: the dashboard can take over from there. + animate(y, -by * size, { + ...spring, + velocity: speed, + onUpdate: (at) => { + if (Math.abs(at + by * size) < 1) setLanded(true) + }, + }).then(() => setLanded(true)) + } + + // Landed, and the dashboard it went to has loaded: that one takes its + // name's place, which it was drawn as, so nothing moves. + useLayoutEffect(() => { + if (!held || !landed || dashboard.name === held.name) return + setHeld(null) + setBeside(null) + setLanded(false) + setReached(dashboard.name) + y.jump(0) + }, [held, landed, dashboard.name, y]) + + const rise: Axis = { + open: (by) => Boolean(name(by) && onDashboard) && !held, + drag: (offset, by) => { + if (held) return + const next = onDashboard ? name(by) : undefined + if (next !== beside?.name) setBeside(next ? { by, name: next } : null) + y.set(offset) + }, + release: (by, turnIt, speed) => { + if (held) return + if (turnIt) { + turn(by, speed) + return + } + if (still) { + y.jump(0) + setBeside(null) + return + } + animate(y, 0, { ...spring, velocity: speed }).then(() => setBeside(null)) + }, + } + + return ( + + turn(by > 0 ? 1 : -1))} + rise={rise} + named={reached === shown.name} + /> + {beside ? ( +
+ +
+ ) : null} +
+ ) +} + +/** A dashboard's icon and title, on the frosted surface: what a panel shows + * of a dashboard it is turning to, and of one it has just landed on. */ +function NamePill({ dashboard }: { dashboard: Dashboard | undefined }) { + const Glyph = resolveIcon(dashboard?.icon ?? "") + return ( + + {Glyph ? : null} + {dashboard?.title || dashboard?.name} + + ) +} + +/** The name of a dashboard not drawn yet, read for it — which is also what + * has it loaded by the time a turn lands on it. */ +function DashboardName({ name }: { name: string }) { + const { data } = useQuery({ + ...dashboardQueryOptions(name), + enabled: Boolean(name), + }) + return } /** * A round dashboard: one widget at a time, each drawn as the whole face. * - * Its widgets are the faces, in their order. Left and right page through - * them — a swipe, a page dot, PageUp/PageDown — stopping at either end; up and - * down are the panel's other dashboards (`onDashboard`, a swipe or + * Its widgets are the faces, in their order, side by side: a swipe drags the + * one shown and brings the next in beside it, and a page dot or + * PageUp/PageDown slides it across; either stops at the ends. Up and down are + * the panel's other dashboards (`rise`, `onDashboard`: a swipe or * Shift+PageUp/PageDown), and landing on one names it for a moment. Each * dashboard comes back to the face it was left on. The arrow keys stay the * ring's. @@ -197,6 +371,8 @@ export function RoundView({ editing, onPick, onDashboard, + rise, + named, }: { dashboard: Dashboard /** The face's diameter: the canvas width. */ @@ -207,25 +383,31 @@ export function RoundView({ onPick?: (id: string) => void /** To the next (1) or the previous (-1) dashboard on this panel. */ onDashboard?: (by: number) => void + /** Swiping up and down, to the panel's other dashboards. */ + rise?: Axis + /** Turned to, its name already shown on the way: it stays without + * fading in again. */ + named?: boolean }) { const widgets = dashboard.widgets ?? [] const { look } = useLook() + const still = useReducedMotion() const [own, setOwn] = useState(() => lastFace.get(dashboard.name) ?? 0) const index = Math.max(0, Math.min(widgets.length - 1, face ?? own)) const widget = widgets[index] const box = useRef(null) - // Read between renders: the face a page turn left, for the side the next - // one arrives from. - const was = useRef(index) - const way = useRef(0) - if (was.current !== index) { - way.current = index > was.current ? 1 : -1 - was.current = index - } const swiped = useRef(false) - // A dashboard on its way out keeps its keys to itself. - const present = useIsPresent() const locked = useLocked() + // The faces on the track: the one at its origin and, while one is peeked + // at or on its way in, the one beside it, on the side it lies. + const [strip, setStrip] = useState<{ at: number; beside: number | null }>( + () => ({ at: index, beside: null }), + ) + const x = useMotionValue(0) + // The speed a swipe was let go at, which the turn it starts carries on, and + // whether a drag has the track. + const fling = useRef(0) + const dragging = useRef(false) const [announce, setAnnounce] = useState( () => Boolean(onDashboard) && @@ -243,6 +425,51 @@ export function RoundView({ return () => clearTimeout(timer) }, [announce]) + // A new face slides in from beside the one shown: brought there by a + // swipe already, or put there now for a dot, a key or the editor. + useLayoutEffect(() => { + if (index === strip.at) { + // Turned back to before a turn had landed: it slides back. + if (strip.beside === null || dragging.current || x.isAnimating()) return + let live = true + const back = animate(x, 0, LOOK[look].spring) + back.then(() => live && setStrip({ at: index, beside: null })) + return () => { + live = false + back.stop() + } + } + if (strip.beside !== index) { + // A turn still on its way finishes at once first. + x.jump(0) + setStrip({ at: strip.beside ?? strip.at, beside: index }) + return + } + const side = Math.sign(index - strip.at) + const land = () => setStrip({ at: index, beside: null }) + if (still) { + land() + return + } + let live = true + const turn = animate(x, -side * size, { + ...LOOK[look].spring, + velocity: fling.current, + }) + fling.current = 0 + turn.then(() => live && land()) + return () => { + live = false + turn.stop() + } + }, [index, strip, still, look, size, x]) + + // One face on the track again: it is moved to the origin and the track + // with it, in the same frame, so nothing is seen to move. + useLayoutEffect(() => { + if (strip.beside === null) x.jump(0) + }, [strip.beside, x]) + const page = (next: number) => { if (next < 0 || next >= widgets.length || next === index) return lastFace.set(dashboard.name, next) @@ -250,42 +477,96 @@ export function RoundView({ onFace?.(next) } - const keys = !editing && present useShortcuts({ - pagedown: () => (keys ? page(index + 1) : false), - pageup: () => (keys ? page(index - 1) : false), - "shift+pagedown": () => (onDashboard && keys ? onDashboard(1) : false), - "shift+pageup": () => (onDashboard && keys ? onDashboard(-1) : false), + pagedown: () => (editing ? false : page(index + 1)), + pageup: () => (editing ? false : page(index - 1)), + "shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false), + "shift+pageup": () => (onDashboard && !editing ? onDashboard(-1) : false), }) - /** A drag that ends far enough away is a swipe, measured in canvas pixels - * whatever the canvas is scaled to. A drag along the ring turns it. */ - const track = (event: React.PointerEvent) => { - swiped.current = false - if ((event.target as Element).closest(".dui-ring-hit")) return - const from = { x: event.clientX, y: event.clientY } - window.addEventListener( - "pointerup", - (end) => { - const scale = - (box.current?.getBoundingClientRect().width || size) / size - const dx = (end.clientX - from.x) / scale - const dy = (end.clientY - from.y) / scale - if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE) return - swiped.current = true - // Towards the left is onwards, as a page is turned. - if (Math.abs(dx) >= Math.abs(dy)) page(index + (dx < 0 ? 1 : -1)) - else onDashboard?.(dy < 0 ? 1 : -1) - }, - { once: true }, - ) + const sideways: Axis = { + open: (by) => index + by >= 0 && index + by < widgets.length, + drag: (offset, by) => { + dragging.current = true + const beside = sideways.open(by) ? index + by : null + if (strip.at !== index || strip.beside !== beside) { + setStrip({ at: index, beside }) + } + x.set(offset) + }, + release: (by, turn, speed) => { + dragging.current = false + if (turn) { + fling.current = speed + page(index + by) + return + } + const back = () => setStrip({ at: index, beside: null }) + if (still) { + back() + return + } + animate(x, 0, { ...LOOK[look].spring, velocity: speed }).then(back) + }, } + const track = usePageDrag(size, box, { x: sideways, y: rise }, swiped, () => { + // A turn still running lands at once, so the new drag starts from a + // page at rest. + if (!x.isAnimating() && strip.beside === null) return + x.stop() + flushSync(() => setStrip({ at: index, beside: null })) + x.jump(0) + }) + if (!widget) return null - const Glyph = resolveIcon(dashboard.icon ?? "") // Closer together where there are more than fit the gap. const pitch = Math.min(DOT_PITCH, DOTS_WIDTH / widgets.length) + const drawn = (at: number, offset: number) => { + const one = widgets[at] + if (!one) return null + return ( +
+ This face could not be drawn.} + resetKeys={[one.id]} + > + onPick(one.id))} + /> + + {/* In the ring's gap, where a knob's bezel does not reach; a slider's + gap line holds it already, led by the lock. */} + {locked && !editing && !hasGapLine(one) ? ( +
+ + Read-only +
+ ) : null} +
+ ) + } + return ( // A face is pressed, swiped and paged rather than focused: its controls // and the dots below are what the keyboard reaches. @@ -315,50 +596,12 @@ export function RoundView({ } }} > - - - This face could not be drawn. - } - resetKeys={[widget.id]} - > - onPick(widget.id))} - /> - - {/* In the ring's gap, where a knob's bezel does not reach; a slider's - gap line holds it already, led by the lock. */} - {locked && !editing && !hasGapLine(widget) ? ( -
- - Read-only -
- ) : null} -
-
+ + {drawn(strip.at, 0)} + {strip.beside === null + ? null + : drawn(strip.beside, Math.sign(strip.beside - strip.at) * size)} + {widgets.length > 1 ? (