From 0bf2b12268e3e33dbde0823e39aa3cc6e2767bc3 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 17:20:22 +0200 Subject: [PATCH] Round panel: the dashboards either side drawn in advance and live, on the face they open on, and dragged in up and down as faces are sideways; the name pill only on landing Co-Authored-By: Claude Opus 5.5 --- docs/interface/dashboards.md | 7 +- .../components/Dashboard/DashboardView.tsx | 1 + .../src/components/Dashboard/RoundFace.tsx | 186 ++++++++++-------- 3 files changed, 108 insertions(+), 86 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 0997e2b..1633d73 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -329,9 +329,9 @@ dashboard's look's. | What | Moves | From → to | Timing | Input during it | |---|---|---|---|---| | 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 | +| Dashboard paging (round panel) | the dashboard, and beside it the next one | slides S up (the next, from below) or down (the previous, from above), the next one drawn on the face it opens on; once landed, it is the dashboard shown, and its name pill shows for a moment. 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; 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) | — | +| Dashboard name | the pill, on landing on another dashboard | opacity 0 → 1, held 1000 ms, 1 → 0 | 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 | — | @@ -362,7 +362,8 @@ under the finger, as a tile view moves. |---|---| | 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 | +| 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 on the face it would open on (the one last shown there, else its first), with its live values | +| Neighbours | the dashboards before and after the one shown are built in advance — loaded, drawn and fed live values — so the one a swipe reaches is complete from the first move. They take no input until they are the one shown | | 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 | diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index b2568bb..3c15089 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -400,6 +400,7 @@ export function DashboardView({ size={canvasOf(dashboard).width} onDashboard={onDashboard} around={around} + editing={editing} /> ) : ( void /** The panel's dashboards before and after this one. */ around?: [string | undefined, string | undefined] + /** Being arranged, so nothing is locked. */ + editing?: boolean }) { const { look } = useLook() 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. + // where that one is and what it is called, and whether the turn has landed. const [held, setHeld] = useState(null) - const [beside, setBeside] = useState<{ by: 1 | -1; name: string } | null>( + const [turning, setTurning] = 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 read = (of: string | undefined) => ({ + ...dashboardQueryOptions(of ?? ""), + enabled: Boolean(of), + }) + const before = useQuery(read(around?.[0])).data + const after = useQuery(read(around?.[1])).data + // By the name it had when the turn began: the panel's neighbours are + // counted from the new dashboard as soon as it is asked for. + const target = useQuery(read(turning?.name)).data 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 }) + setTurning({ by, name: next }) setLanded(false) onDashboard(by) if (still) { @@ -265,68 +277,87 @@ export function RoundPanel({ }).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. + // Landed, and the panel has moved on to it: it becomes the one at the + // origin, which it is already drawn as, so nothing moves. useLayoutEffect(() => { if (!held || !landed || dashboard.name === held.name) return setHeld(null) - setBeside(null) + setTurning(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) + drag: (offset) => { + if (!held) 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)) + if (turnIt) turn(by, speed) + else if (still) y.jump(0) + else animate(y, 0, { ...spring, velocity: speed }) }, } + // On the track by place: the one shown and its neighbours a screen either + // side; during a turn, the one it left and the one it is going to. + const pages: [Dashboard | undefined, number][] = + held && turning + ? [ + [held, 0], + [target, turning.by], + ] + : [ + [before, -1], + [dashboard, 0], + [after, 1], + ] + return ( - turn(by > 0 ? 1 : -1))} - rise={rise} - named={reached === shown.name} - /> - {beside ? ( -
- -
- ) : null} + {pages.map(([page, at]) => { + if (!page) return null + const view = ( + turn(by > 0 ? 1 : -1) + : undefined + } + rise={at === 0 ? rise : undefined} + /> + ) + return ( + // Keyed by name, so a neighbour becomes the one shown, and back, + // without being drawn afresh: its own face memory and all. +
+ + {editing ? ( + view + ) : ( + {view} + )} + +
+ ) + })}
) } /** 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 ?? "") + * for a moment of a dashboard it has just landed on. */ +function NamePill({ dashboard }: { dashboard: Dashboard }) { + const Glyph = resolveIcon(dashboard.icon ?? "") return ( {Glyph ? : null} - {dashboard?.title || dashboard?.name} + {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. * @@ -358,7 +379,8 @@ function DashboardName({ name }: { name: string }) { * 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. + * ring's. A `peek` is a neighbour drawn beside the one shown: it answers to + * nothing until it becomes the one shown. * * Arranging, the editor says which face is shown (`face`, `onFace`) and a * press on a face that is not one of its controls picks it (`onPick`). @@ -372,7 +394,7 @@ export function RoundView({ onPick, onDashboard, rise, - named, + peek, }: { dashboard: Dashboard /** The face's diameter: the canvas width. */ @@ -385,9 +407,7 @@ export function RoundView({ 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 + peek?: boolean }) { const widgets = dashboard.widgets ?? [] const { look } = useLook() @@ -408,16 +428,15 @@ export function RoundView({ // whether a drag has the track. const fling = useRef(0) const dragging = useRef(false) - const [announce, setAnnounce] = useState( - () => - Boolean(onDashboard) && - lastShown !== null && - lastShown !== dashboard.name, - ) + const [announce, setAnnounce] = useState(false) + // Named for a moment when a panel lands on it from another — once it is + // the one shown, not while it waits beside it. useEffect(() => { - if (onDashboard) lastShown = dashboard.name - }, [onDashboard, dashboard.name]) + if (!onDashboard || peek) return + if (lastShown !== null && lastShown !== dashboard.name) setAnnounce(true) + lastShown = dashboard.name + }, [onDashboard, peek, dashboard.name]) useEffect(() => { if (!announce) return @@ -477,11 +496,12 @@ export function RoundView({ onFace?.(next) } + const keys = !editing && !peek useShortcuts({ - 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), + 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), }) const sideways: Axis = { @@ -629,7 +649,7 @@ export function RoundView({ ) : null} - + {announce ? (