diff --git a/packages/app/e2e/regression/panel-edges.spec.ts b/packages/app/e2e/regression/panel-edges.spec.ts index 92d8263dda12..0793e6137e99 100644 --- a/packages/app/e2e/regression/panel-edges.spec.ts +++ b/packages/app/e2e/regression/panel-edges.spec.ts @@ -43,7 +43,7 @@ test("the content beside a panel follows the panel's edge on every frame of a to // Half speed, so even a slow runner draws several frames of each flight. await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } }, - storage: { "opencode.motion.v2": { speed: 0.5 } }, + storage: { "opencode.motion.v2": { speed: 0.5, "sidebars.james": 0 } }, }) await page.goto(sessionHref(fixture.targetID)) await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible() diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 3c81502fd1ba..2eecf6e1fc01 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -47,7 +47,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { useServer } from "@/runtime/server/current" import { reviewGlyphFill, SessionReviewToggle } from "./header/session-header-actions" import { createTimelineCache } from "./timeline/cache" -import { createPanelMotion, replayPanelToggle } from "@/shell/panel-motion" +import { createPanelMotion, jamesPanelShift, replayPanelToggle } from "@/shell/panel-motion" export function SessionScreenView(props: { session: SessionModel }) { // The timeline cache captures its owner when created, so link handling must be provided above it. @@ -152,6 +152,7 @@ function SessionScreenContent(props: { // The chat keeps its open width as the far end of a close's travel. const chatOpenWidth = createMemo((previous) => (sideVisible() ? screen.panel.width() : previous), "100%") let chat: HTMLDivElement | undefined + let sidePresence: HTMLDivElement | undefined // Once opened, the side region stays mounted and is skipped by content-visibility when a close settles, so neither // direction mounts its contents mid-flight, and a reopen reuses their style and layout. @@ -182,6 +183,10 @@ function SessionScreenContent(props: { chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})` }, + element: () => sidePresence, + offset: jamesPanelShift, + neighbours: () => [[chat, "center"]], + resize: () => chat, }) replayPanelToggle({ @@ -482,6 +487,7 @@ function SessionScreenContent(props: {
flights() > 0 +// The "James" option, chosen in the preset strip beside Snappy, Smooth and Calm, swaps the section's knobs for its own: layout +// changes in one frame and only the panel moves, a few pixels toward its edge, while it fades. The neighbours beside it +// glide from their old box to the new one. All run as Web Animations, so the compositor plays them through main-thread +// work. +// The shipped sidebar motion; the spring presets and Instant stay one choice away. +const james = motionTuning.mode("sidebars.james", { label: "James", group, value: true }) + +const springs = () => !james() + // The left sidebar and the side panel share one spring, so they move as one system. const spring = motionTuning.spring("sidebars", { label: "Spring", group, + when: springs, shipped: "smooth", presets: { snappy: { duration: 0.2, bounce: 0 }, @@ -24,10 +34,87 @@ const spring = motionTuning.spring("sidebars", { const style = motionTuning.choice("sidebars.style", { label: "Motion", group, + when: springs, value: "spring", labels: { spring: "Spring", instant: "Instant" }, }) +const jamesShift = motionTuning.spring("sidebars.james.shift", { + label: "Movement", + group, + when: james, + shipped: "smooth", + presets: { + snappy: { duration: 0.18, bounce: 0 }, + smooth: { duration: 0.24, bounce: 0 }, + calm: { duration: 0.32, bounce: 0 }, + }, +}) + +const jamesFade = motionTuning.easing("sidebars.james.fade", { + label: "Fade", + group, + when: james, + value: [0.44, 0.75, 0.63, 1.03], +}) + +const jamesFadeDuration = motionTuning.number("sidebars.james.fadeDuration", { + label: "Fade duration", + group, + when: james, + value: 0.18, + min: 0.05, + max: 0.6, + step: 0.01, + unit: "s", + time: "duration", +}) + +/** How far the right side panel shifts in from its edge when James opens it, in pixels. */ +export const jamesPanelShift = motionTuning.number("sidebars.james.panelShift", { + label: "Panel shift", + group, + when: james, + value: 30, + min: 0, + max: 48, + step: 1, + unit: "px", +}) + +// "flip" translates the neighbours from their old box on the compositor; "width" animates the resized box's real width, +// re-wrapping its content each frame. +const jamesLayout = motionTuning.choice("sidebars.james.layout", { + label: "Layout", + group, + when: james, + value: "flip", + labels: { flip: "Glide (FLIP)", width: "Width", off: "Off" }, +}) + +const layoutSpring = (direction: string, label: string) => + motionTuning.spring(`sidebars.james.layout.${direction}`, { + label, + group, + when: () => james() && jamesLayout() !== "off", + shipped: "smooth", + presets: { + snappy: { duration: 0.12, bounce: 0 }, + smooth: { duration: 0.16, bounce: 0 }, + calm: { duration: 0.24, bounce: 0 }, + }, + }) + +const jamesLayoutOpen = layoutSpring("open", "Layout open") + +const jamesLayoutClose = layoutSpring("close", "Layout close") + +// The opacity a panel fades up from under James and down to. +const faded = 0.15 + +/** Where a neighbour's content sits: centred content follows its box's centre, start-pinned content its start edge. */ +export type Anchor = "center" | "start" + /** Adds a button that toggles a panel to the Motion workspace's Sidebars section while the panel is mounted. */ export function replayPanelToggle(input: { id: string; label: string; toggle: () => void }) { onCleanup(motionTuning.action({ id: `sidebars.${input.id}`, label: input.label, group, run: input.toggle })) @@ -48,6 +135,16 @@ export function createPanelMotion(input: { onClosed?: () => void /** Reads layout in the frame loop's read phase, before each frame of a flight paints. */ measure?: () => void + /** + * James: the panel layer that shifts and fades, and how far it shifts, in pixels toward its edge (negative for + * the inline-start edge; mirrored in RTL). + */ + element?: Accessor + offset?: () => number + /** James: the boxes beside the panel that glide into place when its layout changes, and how. */ + neighbours?: () => Array<[HTMLElement | null | undefined, Anchor]> + /** James, width layout: the box whose real width animates. */ + resize?: () => HTMLElement | null | undefined }) { const [present, setPresent] = createSignal(input.open()) // Percent closed, so the spring's rest threshold stays well under a pixel. @@ -57,6 +154,8 @@ export function createPanelMotion(input: { // A state `sync` asked to show at once, for when the change hasn't reached this motion yet, e.g. inside a batch. let snapTo: boolean | undefined let flying = false + // James's flight in progress, if any. + let fades: Animation[] = [] const fly = (next: boolean) => { if (flying === next) return @@ -73,10 +172,11 @@ export function createPanelMotion(input: { const queued = landed landed = [] - if (!input.open()) { - setPresent(false) - input.onClosed?.() - } + if (!input.open()) setPresent(false) + fades.forEach((animation) => animation.cancel()) + fades = [] + + if (!input.open()) input.onClosed?.() queued.forEach((run) => run()) } @@ -102,6 +202,8 @@ export function createPanelMotion(input: { if (previous === undefined || previous[1] !== identity) { stop?.() stop = undefined + fades.forEach((animation) => animation.cancel()) + fades = [] fly(false) motion.snap(target) paint() @@ -111,7 +213,10 @@ export function createPanelMotion(input: { return } - if (now || prefersReducedMotion() || style() === "instant") return snap(open) + if (now || prefersReducedMotion() || (!james() && style() === "instant")) return snap(open) + + // James closes at once; only its neighbours glide into place. + if (james()) return open ? fade() : glide(false, () => snap(false)) const tuning = spring() motion.omega = (2 * Math.PI) / tuning.duration @@ -135,22 +240,92 @@ export function createPanelMotion(input: { ), ) + /** The layout opens at once, gliding its neighbours into place; the panel then shifts in and fades up. */ + const fade = () => { + stop?.() + stop = undefined + glide(true, () => { + motion.snap(0) + paint() + }) + fly(true) + // Deferred so a panel that mounts with this open exists before it animates; microtasks run before the next paint. + queueMicrotask(() => { + const element = input.element?.() + + if (!input.open()) return + + if (!element?.animate) return settle() + + const x = (input.offset?.() ?? 0) * (element.matches(":dir(rtl)") ? -1 : 1) + fades.forEach((animation) => animation.cancel()) + fades = [ + element.animate({ transform: [`translateX(${x}px)`, "none"] }, springEasing(jamesShift())), + element.animate( + { opacity: [faded, 1] }, + { duration: jamesFadeDuration() * 1000, easing: `cubic-bezier(${jamesFade().join(", ")})` }, + ), + ] + const current = fades + void Promise.all(current.map((animation) => animation.finished)).then( + () => current === fades && settle(), + () => undefined, + ) + }) + } + + /** + * Runs `change`, which moves the layout, then glides the neighbours from their old boxes to the new ones, measured + * on either side of the change and started in the same frame. + */ + const glide = (open: boolean, change: () => void) => { + const mode = jamesLayout() + + const before = + mode === "flip" + ? (input.neighbours?.() ?? []).flatMap(([element, anchor]) => + element ? [{ element, anchor, x: anchorX(element, anchor) }] : [], + ) + : [] + + const box = mode === "width" ? input.resize?.() : undefined + + const width = box?.getBoundingClientRect().width + change() + + if (before.length === 0 && !box) return + const timing = layoutEasing(open ? jamesLayoutOpen() : jamesLayoutClose()) + + before.forEach(({ element, anchor, x }) => { + const dx = x - anchorX(element, anchor) + + if (Math.abs(dx) > 0.5) + element.animate({ transform: [`translateX(${dx}px)`, "none"] }, { ...timing, composite: "add" }) + }) + + if (!box || width === undefined) return + const to = box.getBoundingClientRect().width + + if (Math.abs(to - width) > 0.5) box.animate({ width: [`${width}px`, `${to}px`] }, timing) + } + // Repaints at rest when the open size changes, e.g. a resize drag or a window resize. createEffect(paint) onCleanup(() => { stop?.() + fades.forEach((animation) => animation.cancel()) fly(false) }) return { present, sync: (open: boolean) => { - if (stop && input.open() === open) return snap(open) + if (flying && input.open() === open) return snap(open) snapTo = open }, whenSettled: (run: () => void) => { - if (stop) return void landed.push(run) + if (flying) return void landed.push(run) run() }, } @@ -186,3 +361,40 @@ export function guardClosingFocus(input: { onCleanup(() => root.removeEventListener("focusin", evict)) }) } + +/** A spring sampled per 60 Hz frame into a CSS `linear()` easing. It ends once within 0.5% of rest, a twentieth of a + * pixel on these shifts. */ +function springEasing(tuning: DurationOptions) { + const spring = Spring.fromDuration(0, tuning).set(1) + const points = [0] + + while (points.length < 240 && points.at(-1)! < 0.995) points.push(Math.round(spring.step(1 / 60) * 1000) / 1000) + points[points.length - 1] = 1 + + return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } +} + +/** A layout spring sampled per 60 Hz frame into `linear()`, until it rests within 0.5% and nearly still. */ +function layoutEasing(tuning: DurationOptions) { + const spring = Spring.fromDuration(0, tuning).set(1) + const points = [0] + + while (points.length < 240) { + const y = Math.round(spring.step(1 / 60) * 1000) / 1000 + points.push(y) + + if (Math.abs(1 - y) < 0.005 && Math.abs(spring.velocity) < 0.05) break + } + + points[points.length - 1] = 1 + + return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } +} + +function anchorX(element: HTMLElement, anchor: Anchor) { + const box = element.getBoundingClientRect() + + if (anchor === "center") return box.left + box.width / 2 + + return element.matches(":dir(rtl)") ? box.right : box.left +} diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 5628933fe8b0..bdef172cc1fb 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -29,10 +29,12 @@ export default function Layout(props: ParentProps) { tabsWidth: number tabsMount: HTMLElement | undefined tabsAside: HTMLElement | undefined + tabsPanel: HTMLElement | undefined }>({ tabsWidth: 260, tabsMount: undefined, tabsAside: undefined, + tabsPanel: undefined, }) const verticalTabs = () => preferences.appearance.tabLayout() !== "horizontal" && !mobile() @@ -52,6 +54,13 @@ export default function Layout(props: ParentProps) { paintCollapsedSlots(closed) }, measure: () => measureSidebarToggles(state.tabsWidth), + element: () => state.tabsPanel, + offset: () => -10, + neighbours: () => [ + [document.querySelector('[data-slot="session-chat-panel"]'), "center"], + [document.querySelector('[data-slot="session-side-panel-presence"]'), "start"], + ], + resize: () => state.tabsAside, }) replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar }) @@ -123,6 +132,7 @@ export default function Layout(props: ParentProps) { {/* Skipped rather than display: none or inert, so neither direction restyles the sidebar's contents and a reopen reuses their layout. */}
setState("tabsPanel", element)} class="absolute inset-0 overflow-hidden [contain:layout_style_paint]" classList={{ "[content-visibility:hidden]": !sidebarMotion.present() }} > diff --git a/packages/app/test-browser/panel-motion.test.ts b/packages/app/test-browser/panel-motion.test.ts index 85f2c3793aa9..dfb3422215fd 100644 --- a/packages/app/test-browser/panel-motion.test.ts +++ b/packages/app/test-browser/panel-motion.test.ts @@ -14,6 +14,8 @@ const cleanups: (() => void)[] = [] const originals = { raf: globalThis.requestAnimationFrame, caf: globalThis.cancelAnimationFrame } beforeEach(() => { + // The spring tests run with James off; James's own tests turn it on. + setJames(false) frames = new Map() id = 0 time = 0 @@ -33,6 +35,7 @@ afterEach(() => { globalThis.requestAnimationFrame = originals.raf globalThis.cancelAnimationFrame = originals.caf setStyle("spring") + setJames(false) }) function setStyle(value: string) { @@ -41,6 +44,13 @@ function setStyle(value: string) { if (knob?.kind === "choice") motionTuning.set(knob, value) } +// James is a mode, chosen in the preset strip rather than the Motion choice. +function setJames(on: boolean) { + const knob = motionTuning.list().find((item) => item.id === "sidebars.james") + + if (knob?.kind === "mode") motionTuning.set(knob, on) +} + function frame() { time += 1000 / 60 const next = [...frames.values()] @@ -235,3 +245,136 @@ it("moves focus out of a closing panel when the close starts and when it re-ente inner.focus() expect(document.activeElement).toBe(inner) }) + +type Played = { + keyframes: PropertyIndexedKeyframes + options: KeyframeAnimationOptions + cancelled: boolean + finish: () => void +} + +// happy-dom has no Web Animations, so the panel layer records what James asks it to play. +function setupJames(initial: boolean) { + setJames(true) + const [open, setOpen] = createSignal(initial) + const played: Played[] = [] + const closed: number[] = [] + const painted = { width: initial ? 200 : 0 } + const element = document.createElement("div") + Object.defineProperty(element, "animate", { + value: (keyframes: PropertyIndexedKeyframes, options: KeyframeAnimationOptions) => { + const finished = Promise.withResolvers() + const animation = { keyframes, options, cancelled: false, finish: () => finished.resolve() } + played.push(animation) + + return { + finished: finished.promise, + cancel: () => { + animation.cancelled = true + finished.reject(new Error("cancelled")) + }, + } + }, + }) + + const motion = createRoot((dispose) => { + cleanups.push(dispose) + + return createPanelMotion({ + open, + paint: (shut) => { + painted.width = 200 * (1 - shut) + }, + onClosed: () => closed.push(time), + element: () => element, + offset: () => -10, + }) + }) + + return { played, setOpen, closed, painted, motion } +} + +const tick = async () => { + await Promise.resolve() + await Promise.resolve() +} + +const land = async (played: Played[]) => { + played.filter((animation) => !animation.cancelled).forEach((animation) => animation.finish()) + await tick() +} + +it("James opens with the layout first, then shifts the panel in on a spring and fades it up from 0.15", async () => { + const panel = setupJames(false) + panel.setOpen(true) + expect(panel.painted.width).toBe(200) + expect(panel.motion.present()).toBe(true) + await tick() + + const [shift, fade] = panel.played + expect(shift.keyframes.transform).toEqual(["translateX(-10px)", "none"]) + // A 240 ms spring without bounce: x(t) = 1 - (1 + ωt)e^(-ωt), sampled per 60 Hz frame. + const omega = (2 * Math.PI) / 0.24 + const points = String(shift.options.easing).slice("linear(".length, -1).split(", ").map(Number) + points.slice(1, -1).forEach((point, index) => { + const t = (index + 1) / 60 + expect(point).toBeCloseTo(1 - (1 + omega * t) * Math.exp(-omega * t), 2) + }) + expect(fade.keyframes.opacity).toEqual([0.15, 1]) + expect(fade.options).toMatchObject({ duration: 180, easing: "cubic-bezier(0.44, 0.75, 0.63, 1.03)" }) + expect(panelsMoving()).toBe(true) + + const ran: string[] = [] + panel.motion.whenSettled(() => ran.push("opened")) + await land(panel.played) + expect(ran).toEqual(["opened"]) + expect(panelsMoving()).toBe(false) +}) + +it("James closes at once, cancelling an opening in flight", async () => { + const panel = setupJames(false) + panel.setOpen(true) + await tick() + panel.setOpen(false) + + expect(panel.painted.width).toBe(0) + expect(panel.motion.present()).toBe(false) + expect(panel.closed).toHaveLength(1) + expect(panel.played).toHaveLength(2) + expect(panel.played.every((animation) => animation.cancelled)).toBe(true) + expect(panelsMoving()).toBe(false) +}) + +it("James snaps under reduced motion", () => { + document.documentElement.dataset.motion = "reduce" + cleanups.push(() => delete document.documentElement.dataset.motion) + const panel = setupJames(true) + panel.setOpen(false) + + expect(panel.played).toEqual([]) + expect(panel.painted.width).toBe(0) + expect(panel.motion.present()).toBe(false) +}) + +it("swaps the spring knobs for James's while it's on", () => { + const shown = () => + motionTuning + .list() + .filter((knob) => knob.group === "Sidebars" && knob.kind !== "mode" && (knob.when?.() ?? true)) + .map((knob) => knob.label) + + expect(shown()).toEqual(["Spring", "Motion"]) + setJames(true) + expect(shown()).toEqual(["Movement", "Fade", "Fade duration", "Panel shift", "Layout", "Layout open", "Layout close"]) + setJames(false) + expect(shown()).toEqual(["Spring", "Motion"]) +}) + +it("ships James as the default sidebar motion", () => { + const knob = motionTuning.list().find((item) => item.id === "sidebars.james") + + expect(knob?.kind).toBe("mode") + expect(knob?.initial).toBe(true) + // The side panel shifts 30px by default; the left sidebar stays at 10px. + expect(motionTuning.list().find((item) => item.id === "sidebars.james.panelShift")?.initial).toBe(30) +}) diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts index 2668f38376b8..f785a20ff793 100644 --- a/packages/ui/src/motion-tuning-panel.ts +++ b/packages/ui/src/motion-tuning-panel.ts @@ -1,5 +1,5 @@ import type { DurationOptions } from "./motion" -import { motionPresets, motionTuning, type MotionKnob, type MotionPreset } from "./motion-tuning" +import { motionPresets, motionTuning, type Bezier, type MotionKnob, type MotionPreset } from "./motion-tuning" const global = "Global" @@ -15,6 +15,8 @@ export function mountMotionPanel( options: { title?: string; section?: string; onClose?: () => void } = {}, ) { let unmount: (() => void) | undefined + // The preset chosen to leave a mode, applied once the rebuilt panel has the knobs it sets. + let switching: string | undefined let disposed = false let section = options.section @@ -26,14 +28,17 @@ export function mountMotionPanel( } async function start() { - const { action, choice, defineControls, knob, spring, toggle } = await import("knobs") + const { action, choice, defineControls, easing, knob, spring, toggle } = await import("knobs") const field = (item: MotionKnob) => { const meta = { label: item.label, group: item.group, tab: item.group === global ? undefined : item.group } if (item.kind === "spring") return spring(item.initial, meta) - if (item.kind === "toggle") return toggle(item.initial, meta) + // Modes never reach here as rows; they sit in the preset strip. + if (item.kind === "toggle" || item.kind === "mode") return toggle(item.initial, meta) + + if (item.kind === "easing") return easing(item.initial, meta) if (item.kind === "choice") return choice(item.initial, item.options, { ...meta, labels: item.labels }) @@ -46,7 +51,12 @@ export function mountMotionPanel( return motionTuning.set(item, value as DurationOptions) } - if (item.kind === "toggle") { + if (item.kind === "easing") { + // SAFETY: an easing knob's field is an easing field. + return motionTuning.set(item, [...(value as Bezier)] as Bezier) + } + + if (item.kind === "toggle" || item.kind === "mode") { // SAFETY: a toggle knob's field is a toggle field. return motionTuning.set(item, value as boolean) } @@ -60,19 +70,32 @@ export function mountMotionPanel( motionTuning.set(item, value as number) } - const identity = () => - [...motionTuning.list(), ...motionTuning.actions()].map((item) => `${item.group}/${item.id}`).join() + // Knobs whose `when` doesn't hold are left out; a change to it rebuilds the panel like a new knob would. + const shown = () => motionTuning.list().filter((item) => item.when?.() ?? true) + + const identity = () => [...shown(), ...motionTuning.actions()].map((item) => `${item.group}/${item.id}`).join() // Rebuilt when a surface registers or removes a knob or action. const mount = () => { unmount?.() - const list = motionTuning.list() + const all = shown() + // Modes sit in the preset strip, not as rows. While one is on, the spring presets leave its section's springs alone + // (choosing one on that section's tab switches back to them first); other sections' springs take presets as usual. + const active = all.find((item) => item.kind === "mode" && item.value) + const list = all.filter((item) => item.kind !== "mode") const actions = motionTuning.actions() const mounted = identity() - const springs = list.flatMap((item) => (item.kind === "spring" ? [item] : [])) + const springs = list.flatMap((item) => (item.kind === "spring" && item.group !== active?.group ? [item] : [])) const preset = (id: MotionPreset) => Object.fromEntries(springs.map((item) => [item.id, item.presets[id]])) + // Easing editors open inline, like the spring rows beside them, rather than folded behind a chevron. + openEditors(list.flatMap((item) => (item.kind === "easing" ? [item.id] : []))) const sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global) + // A mode sits in the strip only on its own section's tab; switching to or from that tab rebuilds the strip. + const modes = all.flatMap((item) => + item.kind === "mode" && item.group === (section ?? sections[0]) ? [item] : [], + ) + const controls = defineControls( "opencode-motion", { @@ -99,8 +122,13 @@ export function mountMotionPanel( persist: "variants", // Springs ship different presets, so the defaults get their own chip. defaultLabel: "Shipped", - presetLabels: motionPresets, - presets: { snappy: preset("snappy"), smooth: preset("smooth"), calm: preset("calm") }, + presetLabels: { ...motionPresets, ...Object.fromEntries(modes.map((item) => [item.id, item.label])) }, + presets: { + snappy: preset("snappy"), + smooth: preset("smooth"), + calm: preset("calm"), + ...Object.fromEntries(modes.map((item) => [item.id, {}])), + }, onChange: (patch) => list.forEach((item) => { // SAFETY: each field was made from its knob above, so its value is that knob's kind. @@ -114,6 +142,25 @@ export function mountMotionPanel( const sync = () => controls.sync(Object.fromEntries(list.map((item) => [item.id, item.value]))) sync() + // The strip shows the mode that's on; a preset chosen while a mode was on applies once its knobs are back. + if (active && active.id in controls.presets) controls.preset(active.id) + + if (!active && switching && switching in controls.presets) controls.preset(switching) + switching = undefined + + const stopModes = controls.subscribe(() => { + const chosen = controls.activePreset() + + modes.forEach((item) => { + const on = chosen?.kind === "preset" && chosen.id === item.id + + if (on === item.value) return + + if (!on) switching = chosen?.kind === "preset" ? chosen.id : chosen ? undefined : "default" + motionTuning.set(item, on) + }) + }) + const stopPanel = controls.mount(container, { theme: "auto", title: options.title ?? "Knobs", @@ -123,7 +170,15 @@ export function mountMotionPanel( hints: "panel", tabs: sections, tab: section, - onTab: (id) => (section = id), + onTab: (id) => { + const offered = (group: string | undefined) => + all.some((item) => item.kind === "mode" && item.group === group) + + const rebuild = offered(section) !== offered(id) + section = id + + if (rebuild) mount() + }, }) const stopWatch = motionTuning.subscribe(() => { @@ -134,6 +189,7 @@ export function mountMotionPanel( unmount = () => { stopWatch() + stopModes() stopPanel() } } @@ -141,3 +197,14 @@ export function mountMotionPanel( if (!disposed) mount() } } + +// knobs remembers which rows' editors are open in this tab's session; adding ids there opens them on mount. +function openEditors(ids: string[]) { + if (ids.length === 0 || typeof sessionStorage === "undefined") return + const key = "knobs:opencode-motion:session:v1" + const session = JSON.parse(sessionStorage.getItem(key) ?? "{}") + const expanded: string[] = Array.isArray(session.expanded) ? session.expanded : [] + + if (ids.every((id) => expanded.includes(id))) return + sessionStorage.setItem(key, JSON.stringify({ ...session, expanded: [...new Set([...expanded, ...ids])] })) +} diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts index 24712c52c316..12446a500c6b 100644 --- a/packages/ui/src/motion-tuning.ts +++ b/packages/ui/src/motion-tuning.ts @@ -9,6 +9,8 @@ type Knob = { id: string label: string group: string + /** Shown only while this holds, e.g. the controls of the option a choice selects. */ + when?: () => boolean initial: Value value: Value } @@ -18,15 +20,20 @@ export const motionPresets = { snappy: "Snappy", smooth: "Smooth", calm: "Calm" export type MotionPreset = keyof typeof motionPresets +/** A CSS `cubic-bezier(x1, y1, x2, y2)`. */ +export type Bezier = [number, number, number, number] + export type MotionKnob = | (Knob<"spring", DurationOptions> & { presets: Record }) + | Knob<"easing", Bezier> + | Knob<"mode", boolean> | Knob<"toggle", boolean> | (Knob<"number", number> & { min: number; max: number; step: number; unit?: string }) | (Knob<"choice", string> & { options: readonly [string, ...string[]]; labels: Record }) export type MotionAction = { id: string; label: string; group: string; run: () => void } -type Meta = { label: string; group: string } +type Meta = { label: string; group: string; when?: () => boolean } // Bumped when knobs are merged or renamed, so values saved for the old ones are dropped. const key = "opencode.motion.v2" @@ -37,7 +44,7 @@ const actions = new Map() const listeners = new Set<() => void>() -// Saved as numbers only: `.duration` and `.bounce` for springs, 0 or 1 for toggles, the option's index for choices. +// Saved as numbers only: `.duration` and `.bounce` for springs, `.0`…`.3` for easings, 0 or 1 for toggles, the option's index for choices. const saved = load() export const motionTuning = { @@ -50,6 +57,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial, value: { duration: number(`${id}.duration`, initial.duration), bounce: number(`${id}.bounce`, initial.bounce) }, presets: meta.presets, @@ -60,12 +68,47 @@ export const motionTuning = { bounce: Math.min(0.95, Math.max(0, knob.value.bounce)), }) }, + /** A cubic-bezier curve, edited with its handles. */ + easing(id: string, meta: Meta & { value: Bezier }) { + const knob = register({ + kind: "easing", + id, + label: meta.label, + group: meta.group, + when: meta.when, + initial: meta.value, + // SAFETY: mapping the four indices of a Bezier yields four numbers. + value: [0, 1, 2, 3].map((index) => number(`${id}.${index}`, meta.value[index])) as Bezier, + }) + + return () => knob.value + }, + /** + * An alternative to a section's springs, offered beside the Snappy, Smooth and Calm presets rather than as a row. + * Choosing it turns it on; choosing any other preset turns it off. Knobs can show only in one or the other with `when`. + */ + mode(id: string, meta: Meta & { value?: boolean }) { + const initial = meta.value ?? false + + const knob = register({ + kind: "mode", + id, + label: meta.label, + group: meta.group, + when: meta.when, + initial, + value: number(id, initial ? 1 : 0) === 1, + }) + + return () => knob.value + }, toggle(id: string, meta: Meta & { value: boolean }) { const knob = register({ kind: "toggle", id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, value: number(id, meta.value ? 1 : 0) === 1, }) @@ -82,6 +125,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, value: options[number(id, options.indexOf(meta.value))] ?? meta.value, options, @@ -104,6 +148,7 @@ export const motionTuning = { id, label: meta.label, group: meta.group, + when: meta.when, initial: meta.value, value: number(id, meta.value), min: meta.min, @@ -212,7 +257,14 @@ function save() { [`${knob.id}.bounce`, knob.value.bounce, knob.initial.bounce], ] - if (knob.kind === "toggle") return [[knob.id, Number(knob.value), Number(knob.initial)]] + if (knob.kind === "easing") + return knob.value.map((value, index): [string, number, number] => [ + `${knob.id}.${index}`, + value, + knob.initial[index], + ]) + + if (knob.kind === "toggle" || knob.kind === "mode") return [[knob.id, Number(knob.value), Number(knob.initial)]] if (knob.kind === "choice") return [[knob.id, knob.options.indexOf(knob.value), knob.options.indexOf(knob.initial)]]