mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
refactor(app): instant sidebars and a tidier Motion workspace (#56996)
This commit is contained in:
committed by
GitHub
Unverified
parent
e79de6666b
commit
538ef7f78d
@@ -32,7 +32,7 @@ async function trace(page: Page, toggle: Locator, before: string, after: string)
|
||||
return frames
|
||||
}
|
||||
|
||||
test("the content beside a panel follows the panel's edge on every frame of a toggle", async ({ page }) => {
|
||||
test("a toggle moves the panel and the content beside it in the same frame, without animating", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } } })
|
||||
@@ -57,8 +57,8 @@ test("the content beside a panel follows the panel's edge on every frame of a to
|
||||
|
||||
for (const run of runs) {
|
||||
const frames = await trace(page, run.toggle, run.before, run.after)
|
||||
// The toggle animates over several frames rather than jumping to its end.
|
||||
expect(new Set(frames.map((frame) => Math.round(frame.edge))).size).toBeGreaterThan(3)
|
||||
// The edge jumps straight from where it was to where it lands.
|
||||
expect(new Set(frames.map((frame) => Math.round(frame.edge))).size).toBeLessThanOrEqual(2)
|
||||
frames.flatMap((frame) => frame.gap ?? []).forEach((gap) => expect(gap).toBeCloseTo(run.gap, 0))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1304,13 +1304,14 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
// The chat's width springs; once the panel settles open, it fills the rest of the row beside the chat.
|
||||
// Opened, the panel fills the rest of the row beside the chat; closed, it is gone at once.
|
||||
for (const opened of [true, false]) {
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await expect(panel).toHaveAttribute("aria-hidden", String(!opened))
|
||||
|
||||
if (opened) await expect(() => expectHeaderClearOfToggle(page, toggle)).toPass()
|
||||
if (!opened) continue
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(() => expectHeaderClearOfToggle(page, toggle)).toPass()
|
||||
}
|
||||
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
@@ -21,6 +21,7 @@ export function ExtensionCommands() {
|
||||
id: `${item.extension}.${item.value.id}`,
|
||||
title: item.value.title,
|
||||
description: item.value.description,
|
||||
keywords: item.value.keywords,
|
||||
category: item.value.group,
|
||||
section: item.value.section,
|
||||
keybind: item.value.bind,
|
||||
|
||||
@@ -37,7 +37,6 @@ import { createTimelineQuote, TimelineQuote } from "./timeline/quote"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createPanelMotion, panelSprings, replayPanelToggle } from "@/shell/panel-motion"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
|
||||
export function SessionScreenView(props: { session: SessionModel }) {
|
||||
@@ -115,41 +114,17 @@ function SessionScreenContent(props: {
|
||||
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.open())
|
||||
|
||||
// The chat keeps its open width as the far end of a close's travel.
|
||||
const chatOpenWidth = createMemo<string>((previous) => (sideVisible() ? screen.panel.width() : previous), "100%")
|
||||
let chat: HTMLDivElement | undefined
|
||||
|
||||
const sideMotion = createPanelMotion({
|
||||
open: sideVisible,
|
||||
spring: panelSprings.side,
|
||||
identity: session.layout.tabKey,
|
||||
// The chat's real width moves the side panel's edge, whose contents keep their open width pinned to it.
|
||||
paint: (closed) => {
|
||||
if (!chat) return
|
||||
const open = chatOpenWidth()
|
||||
|
||||
if (closed === 1) {
|
||||
chat.style.width = "100%"
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})`
|
||||
},
|
||||
onClosed: () => {
|
||||
setStore("sideRegionPresent", false)
|
||||
setStore("sideTabsPresent", false)
|
||||
},
|
||||
})
|
||||
|
||||
replayPanelToggle({
|
||||
id: "sidePanel",
|
||||
group: "Side panel",
|
||||
toggle: () => session.layout.view().side.toggle(),
|
||||
spring: panelSprings.side,
|
||||
})
|
||||
|
||||
const sideFileTree = createMemo<boolean>((previous) => (sideVisible() ? screen.files.open() : previous), false)
|
||||
// A close unmounts the side region; switching to a layout whose side is closed keeps it, hidden.
|
||||
createEffect(
|
||||
on(
|
||||
() => [sideVisible(), session.layout.tabKey()] as const,
|
||||
([visible, key], previous) => {
|
||||
if (visible || !previous || previous[1] !== key) return
|
||||
setStore("sideRegionPresent", false)
|
||||
setStore("sideTabsPresent", false)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const hideTimelineScrollbar = () => setStore("timelineScrollbarHidden", true)
|
||||
|
||||
@@ -374,10 +349,10 @@ function SessionScreenContent(props: {
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
ref={chat}
|
||||
class="@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none"
|
||||
data-slot="session-chat-panel"
|
||||
data-scrollbar-hidden={store.timelineScrollbarHidden}
|
||||
style={{ width: screen.panel.width() }}
|
||||
onPointerMove={revealTimelineScrollbar}
|
||||
onPointerDown={revealTimelineScrollbar}
|
||||
on:wheel={{ passive: true, handleEvent: revealTimelineScrollbar }}
|
||||
@@ -406,13 +381,12 @@ function SessionScreenContent(props: {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={sideMotion.present() || store.sideTabsPresent}>
|
||||
<Show when={sideVisible() || store.sideTabsPresent}>
|
||||
<div
|
||||
data-slot="session-side-panel-presence"
|
||||
classList={{
|
||||
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sideMotion.present(),
|
||||
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible":
|
||||
!sideMotion.present(),
|
||||
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sideVisible(),
|
||||
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible": !sideVisible(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -425,14 +399,14 @@ function SessionScreenContent(props: {
|
||||
<div
|
||||
data-slot="session-side-region-presence"
|
||||
class="absolute inset-0"
|
||||
classList={{ invisible: !sideMotion.present() }}
|
||||
classList={{ invisible: !sideVisible() }}
|
||||
>
|
||||
<SideRegion
|
||||
screen={props.screen}
|
||||
view={props.view()}
|
||||
region={region}
|
||||
sidebar={sidebar}
|
||||
fileTree={sideFileTree()}
|
||||
fileTree={screen.files.open()}
|
||||
present={store.sideTabsPresent}
|
||||
size={screen.size}
|
||||
/>
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface CommandOption {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
/** Extra words the command palette matches, separated by spaces. */
|
||||
keywords?: string
|
||||
category?: string
|
||||
/** Section of Settings > Shortcuts. Host commands leave it unset and are placed by id prefix. */
|
||||
section?: CommandSection
|
||||
|
||||
@@ -27,7 +27,9 @@ function groups(entries: CommandPaletteEntry[]) {
|
||||
export function matchesCommandPaletteEntry(entry: CommandPaletteEntry, query: string) {
|
||||
const value = query.toLowerCase()
|
||||
|
||||
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
|
||||
return [entry.title, entry.description, entry.category, entry.keywords].some((text) =>
|
||||
text?.toLowerCase().includes(value),
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogCommandPalette(props: { onOpenFile?: (path: string) => void }) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type CommandPaletteEntry = {
|
||||
type: "command" | "file" | "session"
|
||||
title: string
|
||||
description?: string
|
||||
keywords?: string
|
||||
keybind?: string
|
||||
category: string
|
||||
option?: CommandOption
|
||||
@@ -215,6 +216,7 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
|
||||
type: "command",
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
keywords: option.keywords,
|
||||
keybind: option.keybind,
|
||||
category,
|
||||
option,
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { frameLoop, prefersReducedMotion, Spring, type DurationOptions } from "@opencode/ui/motion"
|
||||
import { motionTuning } from "@opencode/ui/motion-tuning"
|
||||
|
||||
const sidebar = motionTuning.spring("sidebar", { label: "Spring", group: "Left sidebar", duration: 0.2, bounce: 0 })
|
||||
|
||||
const side = motionTuning.spring("sidePanel", { label: "Spring", group: "Side panel", duration: 0.2, bounce: 0 })
|
||||
|
||||
const linked = motionTuning.toggle("sidePanelLinked", {
|
||||
label: "Follow the left sidebar's spring",
|
||||
group: "Side panel",
|
||||
value: true,
|
||||
})
|
||||
|
||||
const keyboardInstant = motionTuning.toggle("keyboardInstant", {
|
||||
label: "Keyboard toggles are instant",
|
||||
group: "Global",
|
||||
value: true,
|
||||
})
|
||||
|
||||
export const panelSprings = {
|
||||
sidebar,
|
||||
side: () => (linked() ? sidebar() : side()),
|
||||
}
|
||||
|
||||
/** Adds buttons that play a panel's toggle to its Motion workspace section while the panel is mounted. */
|
||||
export function replayPanelToggle(input: {
|
||||
id: string
|
||||
group: string
|
||||
toggle: () => void
|
||||
spring: () => DurationOptions
|
||||
}) {
|
||||
onCleanup(
|
||||
motionTuning.action({ id: `${input.id}.toggle`, label: "Open / close", group: input.group, run: input.toggle }),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
motionTuning.action({
|
||||
id: `${input.id}.reverse`,
|
||||
label: "Toggle, reverse mid-flight",
|
||||
group: input.group,
|
||||
run: () => {
|
||||
input.toggle()
|
||||
setTimeout(input.toggle, input.spring().duration * 350)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Whether the input that caused a toggle was a key press, so shortcuts can skip the motion.
|
||||
let keyboard = false
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("keydown", () => (keyboard = true), true)
|
||||
window.addEventListener("pointerdown", () => (keyboard = false), true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and closes an edge panel on a spring. `paint` receives how far closed the panel is, from 0 (open) to 1
|
||||
* (closed), every frame of a flight and whenever a signal it reads changes at rest. It sizes the panel's real
|
||||
* layout box, so the neighbouring content's edge follows the panel's edge on every frame. `present` stays true until
|
||||
* a close settles.
|
||||
*/
|
||||
export function createPanelMotion(input: {
|
||||
open: Accessor<boolean>
|
||||
spring: () => DurationOptions
|
||||
paint: (closed: number) => void
|
||||
/** A change shows the new state without motion, e.g. switching to another session's layout. */
|
||||
identity?: Accessor<unknown>
|
||||
onClosed?: () => void
|
||||
}) {
|
||||
const [present, setPresent] = createSignal(input.open())
|
||||
// Percent closed, so the spring's rest threshold stays well under a pixel.
|
||||
const spring = new Spring(input.open() ? 0 : 100)
|
||||
let stop: (() => void) | undefined
|
||||
|
||||
const paint = () => input.paint(Math.min(1, Math.max(0, spring.value / 100)))
|
||||
|
||||
const settle = () => {
|
||||
stop?.()
|
||||
stop = undefined
|
||||
|
||||
if (input.open()) return
|
||||
setPresent(false)
|
||||
input.onClosed?.()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.open(), input.identity?.()] as const,
|
||||
([open, identity], previous) => {
|
||||
const target = open ? 0 : 100
|
||||
|
||||
if (open) setPresent(true)
|
||||
|
||||
if (previous === undefined || previous[1] !== identity) {
|
||||
stop?.()
|
||||
stop = undefined
|
||||
spring.snap(target)
|
||||
paint()
|
||||
|
||||
if (!open) setPresent(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (prefersReducedMotion() || (keyboard && keyboardInstant())) {
|
||||
spring.snap(target)
|
||||
paint()
|
||||
settle()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const tuning = input.spring()
|
||||
spring.omega = (2 * Math.PI) / tuning.duration
|
||||
spring.zeta = 1 - tuning.bounce
|
||||
spring.set(target)
|
||||
stop ??= frameLoop.add((dt) => {
|
||||
spring.step(dt)
|
||||
paint()
|
||||
|
||||
if (!spring.resting) return true
|
||||
stop = undefined
|
||||
settle()
|
||||
|
||||
return false
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Repaints at rest when the open size changes, e.g. a resize drag or a window resize.
|
||||
createEffect(paint)
|
||||
|
||||
onCleanup(() => stop?.())
|
||||
|
||||
return { present }
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { ExtensionSlot } from "@/runtime/extension/render"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { CollapsedSidebarToggle } from "@/shell/sidebar-toggle"
|
||||
import { createPanelMotion, panelSprings, replayPanelToggle } from "@/shell/panel-motion"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
@@ -27,33 +26,13 @@ export default function Layout(props: ParentProps) {
|
||||
const [state, setState] = createStore<{
|
||||
tabsWidth: number
|
||||
tabsMount: HTMLElement | undefined
|
||||
tabsAside: HTMLElement | undefined
|
||||
}>({
|
||||
tabsWidth: 260,
|
||||
tabsMount: undefined,
|
||||
tabsAside: undefined,
|
||||
})
|
||||
|
||||
const verticalTabs = () => preferences.appearance.tabLayout() !== "horizontal" && !mobile()
|
||||
|
||||
const sidebarMotion = createPanelMotion({
|
||||
open: preferences.appearance.sidebarOpen,
|
||||
spring: panelSprings.sidebar,
|
||||
// The aside's real width moves main's edge with the sidebar's; its contents keep their width, pinned to that edge.
|
||||
paint: (closed) => {
|
||||
const aside = state.tabsAside
|
||||
|
||||
if (aside) aside.style.width = `${state.tabsWidth * (1 - closed)}px`
|
||||
},
|
||||
})
|
||||
|
||||
replayPanelToggle({
|
||||
id: "sidebar",
|
||||
group: "Left sidebar",
|
||||
toggle: () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen()),
|
||||
spring: panelSprings.sidebar,
|
||||
})
|
||||
|
||||
command.register("sidebar", () =>
|
||||
verticalTabs()
|
||||
? [
|
||||
@@ -95,11 +74,14 @@ export default function Layout(props: ParentProps) {
|
||||
id="vertical-tabs-sidebar"
|
||||
data-slot="vertical-tabs-sidebar"
|
||||
data-state={preferences.appearance.sidebarOpen() ? "open" : "closed"}
|
||||
ref={(element) => setState("tabsAside", element)}
|
||||
class="relative z-10 h-full min-h-0 shrink-0"
|
||||
style={{ width: preferences.appearance.sidebarOpen() ? `${state.tabsWidth}px` : "0px" }}
|
||||
inert={!preferences.appearance.sidebarOpen()}
|
||||
>
|
||||
<div class="absolute inset-0 overflow-hidden" classList={{ invisible: !sidebarMotion.present() }}>
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden"
|
||||
classList={{ invisible: !preferences.appearance.sidebarOpen() }}
|
||||
>
|
||||
<div
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
class="absolute inset-y-0 end-0 contain-strict flex min-h-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createServerSessionEntries } from "@/shell/commands/palette"
|
||||
import { createCommandPaletteCommandEntry, createServerSessionEntries } from "@/shell/commands/palette"
|
||||
import { matchesCommandPaletteEntry } from "@/shell/commands/dialog"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { getProjectAvatarSource } from "@opencode/ui/project-avatar"
|
||||
|
||||
@@ -121,3 +122,16 @@ describe("command palette sessions", () => {
|
||||
expect(result).toMatchObject([{ sessionID: exact.id, title: exact.title }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("command palette commands", () => {
|
||||
test("match their keywords as well as their title", () => {
|
||||
const entry = createCommandPaletteCommandEntry(
|
||||
{ id: "debug.toggle", title: "Toggle debug bar", keywords: "dev bar developer tools diagnostics" },
|
||||
"View",
|
||||
)
|
||||
|
||||
expect(matchesCommandPaletteEntry(entry, "dev bar")).toBe(true)
|
||||
expect(matchesCommandPaletteEntry(entry, "Diagnostics")).toBe(true)
|
||||
expect(matchesCommandPaletteEntry(entry, "terminal")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, expect, it } from "bun:test"
|
||||
import { motionTuning } from "@opencode/ui/motion-tuning"
|
||||
import { mountMotionPanel } from "@opencode/ui/motion-tuning-panel"
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it("offers Snappy, Smooth and Calm for every spring, with one Global card and one card per section", async () => {
|
||||
const drawer = motionTuning.spring("test.drawer", {
|
||||
label: "Spring",
|
||||
group: "Drawer",
|
||||
presets: {
|
||||
snappy: { duration: 0.15, bounce: 0 },
|
||||
smooth: { duration: 0.25, bounce: 0 },
|
||||
calm: { duration: 0.4, bounce: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const image = motionTuning.spring("test.image", {
|
||||
label: "Spring",
|
||||
group: "Image open",
|
||||
presets: {
|
||||
snappy: { duration: 0.2, bounce: 0 },
|
||||
smooth: { duration: 0.3, bounce: 0 },
|
||||
calm: { duration: 0.4, bounce: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
cleanups.push(motionTuning.action({ id: "test.replay", label: "Replay", group: "Drawer", run: () => {} }))
|
||||
|
||||
const container = document.createElement("div")
|
||||
document.body.append(container)
|
||||
cleanups.push(mountMotionPanel(container, { section: "Drawer" }))
|
||||
|
||||
const chip = await waitFor(() =>
|
||||
[...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Smooth"),
|
||||
)
|
||||
|
||||
const labels = [...container.querySelectorAll("button")].map((button) => button.textContent?.trim())
|
||||
|
||||
expect(labels).toContain("Snappy")
|
||||
expect(labels).toContain("Calm")
|
||||
expect(labels).not.toContain("default")
|
||||
|
||||
// Global once, then the section's spring with its replay buttons under a sub-heading in the same card.
|
||||
const shown = (selector: string) =>
|
||||
[...container.querySelectorAll(selector)].flatMap((element) =>
|
||||
element.closest("[hidden]") === null ? [element.textContent?.trim()] : [],
|
||||
)
|
||||
|
||||
expect(shown(".knobs-group-label")).toEqual(["Global", "Drawer"])
|
||||
expect(shown(".knobs-subhead-label")).toEqual(["Replay"])
|
||||
expect(labels).not.toContain("Jump to…")
|
||||
expect(drawer()).toEqual({ duration: 0.15, bounce: 0 })
|
||||
|
||||
chip.click()
|
||||
expect(drawer()).toEqual({ duration: 0.25, bounce: 0 })
|
||||
expect(image()).toEqual({ duration: 0.3, bounce: 0 })
|
||||
})
|
||||
|
||||
async function waitFor<T>(find: () => T | undefined) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const found = find()
|
||||
|
||||
if (found) return found
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
throw new Error("timed out")
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, spyOn } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createPanelMotion } from "../src/shell/panel-motion"
|
||||
|
||||
let frames: Map<number, FrameRequestCallback>
|
||||
|
||||
let id: number
|
||||
|
||||
let time: number
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const originals = { raf: globalThis.requestAnimationFrame, caf: globalThis.cancelAnimationFrame }
|
||||
|
||||
beforeEach(() => {
|
||||
frames = new Map()
|
||||
id = 0
|
||||
time = 0
|
||||
spyOn(performance, "now").mockImplementation(() => time)
|
||||
globalThis.requestAnimationFrame = (fn: FrameRequestCallback) => {
|
||||
frames.set(++id, fn)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
globalThis.cancelAnimationFrame = (key: number) => {
|
||||
frames.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
globalThis.requestAnimationFrame = originals.raf
|
||||
globalThis.cancelAnimationFrame = originals.caf
|
||||
})
|
||||
|
||||
function frame() {
|
||||
time += 1000 / 60
|
||||
const next = [...frames.values()]
|
||||
frames.clear()
|
||||
|
||||
for (const callback of next) callback(time)
|
||||
}
|
||||
|
||||
function settle() {
|
||||
for (let i = 0; i < 120 && frames.size; i++) frame()
|
||||
}
|
||||
|
||||
function setup(initial: boolean) {
|
||||
const [open, setOpen] = createSignal(initial)
|
||||
const [identity, setIdentity] = createSignal("a")
|
||||
const [size, setSize] = createSignal(200)
|
||||
const closed: number[] = []
|
||||
const painted = { width: 0 }
|
||||
|
||||
const motion = createRoot((dispose) => {
|
||||
cleanups.push(dispose)
|
||||
|
||||
return createPanelMotion({
|
||||
open,
|
||||
spring: () => ({ duration: 0.2, bounce: 0 }),
|
||||
paint: (shut) => {
|
||||
painted.width = size() * (1 - shut)
|
||||
},
|
||||
identity,
|
||||
onClosed: () => closed.push(time),
|
||||
})
|
||||
})
|
||||
|
||||
return { painted, setOpen, setIdentity, setSize, closed, present: motion.present }
|
||||
}
|
||||
|
||||
it("sizes a pointer toggle open and closed on a spring, present until the close settles", () => {
|
||||
window.dispatchEvent(new Event("pointerdown"))
|
||||
const panel = setup(false)
|
||||
expect(panel.present()).toBe(false)
|
||||
expect(panel.painted.width).toBe(0)
|
||||
|
||||
panel.setOpen(true)
|
||||
expect(panel.present()).toBe(true)
|
||||
expect(panel.painted.width).toBe(0)
|
||||
frame()
|
||||
expect(panel.painted.width).toBeGreaterThan(0)
|
||||
expect(panel.painted.width).toBeLessThan(200)
|
||||
settle()
|
||||
expect(panel.painted.width).toBe(200)
|
||||
|
||||
panel.setOpen(false)
|
||||
frame()
|
||||
expect(panel.present()).toBe(true)
|
||||
expect(panel.closed).toEqual([])
|
||||
settle()
|
||||
expect(panel.painted.width).toBe(0)
|
||||
expect(panel.present()).toBe(false)
|
||||
expect(panel.closed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("moves the panel's size a little on every frame, with no jump at the start or settle", () => {
|
||||
window.dispatchEvent(new Event("pointerdown"))
|
||||
const panel = setup(false)
|
||||
panel.setOpen(true)
|
||||
const widths: number[] = []
|
||||
|
||||
for (let i = 0; i < 120 && frames.size; i++) {
|
||||
frame()
|
||||
widths.push(panel.painted.width)
|
||||
}
|
||||
|
||||
const steps = widths.map((width, index) => width - (widths[index - 1] ?? 0))
|
||||
expect(steps.every((step) => step >= 0)).toBe(true)
|
||||
expect(Math.max(...steps)).toBeLessThan(60)
|
||||
// The last moving frame lands within a pixel of rest.
|
||||
expect(200 - widths.findLast((width) => width < 200)!).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it("turns around mid-flight from where it is", () => {
|
||||
window.dispatchEvent(new Event("pointerdown"))
|
||||
const panel = setup(false)
|
||||
panel.setOpen(true)
|
||||
frame()
|
||||
frame()
|
||||
frame()
|
||||
const before = panel.painted.width
|
||||
|
||||
panel.setOpen(false)
|
||||
expect(panel.painted.width).toBe(before)
|
||||
frame()
|
||||
// Still carrying its opening velocity, it does not jump back closed.
|
||||
expect(Math.abs(panel.painted.width - before)).toBeLessThan(30)
|
||||
settle()
|
||||
expect(panel.present()).toBe(false)
|
||||
expect(panel.painted.width).toBe(0)
|
||||
})
|
||||
|
||||
it("repaints at rest when the open size changes, like a resize drag", () => {
|
||||
window.dispatchEvent(new Event("pointerdown"))
|
||||
const panel = setup(true)
|
||||
expect(panel.painted.width).toBe(200)
|
||||
|
||||
panel.setSize(260)
|
||||
expect(panel.painted.width).toBe(260)
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
|
||||
it("applies keyboard toggles at once", () => {
|
||||
window.dispatchEvent(new Event("keydown"))
|
||||
const panel = setup(true)
|
||||
|
||||
panel.setOpen(false)
|
||||
expect(panel.painted.width).toBe(0)
|
||||
expect(panel.present()).toBe(false)
|
||||
expect(panel.closed).toHaveLength(1)
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
|
||||
it("shows another layout's state without motion or a close", () => {
|
||||
window.dispatchEvent(new Event("pointerdown"))
|
||||
const panel = setup(true)
|
||||
|
||||
panel.setIdentity("b")
|
||||
panel.setOpen(false)
|
||||
expect(frames.size).toBeGreaterThan(0)
|
||||
settle()
|
||||
|
||||
panel.setOpen(true)
|
||||
settle()
|
||||
panel.setOpen(false)
|
||||
panel.setIdentity("c")
|
||||
expect(panel.present()).toBe(false)
|
||||
expect(panel.painted.width).toBe(0)
|
||||
expect(panel.closed).toHaveLength(1)
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
"command.toggle": "Toggle debug bar",
|
||||
"command.motion": "Toggle motion controls",
|
||||
"status.toggle": "Toggle debug tools",
|
||||
ariaLabel: "Development performance diagnostics",
|
||||
providerAriaLabel: "Provider performance diagnostics",
|
||||
|
||||
@@ -1,31 +1,74 @@
|
||||
import { onCleanup, Show } from "solid-js"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
|
||||
// The Motion workspace and its floating button at the window's bottom-right corner. knobs loads once it opens.
|
||||
export default function Motion(props: { open: boolean; button: boolean; title: string; toggle: () => void }) {
|
||||
return (
|
||||
<Portal>
|
||||
<Show when={props.button}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={props.open}
|
||||
class="fixed bottom-2 right-2 z-50 rounded-full border border-border-weak-base bg-surface-raised-base px-2 py-0.5 font-mono text-[10px] font-black uppercase tracking-[0.04em] text-text-weak opacity-60 hover:opacity-100 aria-pressed:text-text-strong aria-pressed:opacity-100"
|
||||
onClick={props.toggle}
|
||||
>
|
||||
{props.title}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.open}>
|
||||
<Workspace title={props.title} />
|
||||
</Show>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
// Where the panel was dragged to, as an offset from its bottom-right home; kept per device.
|
||||
const key = "opencode.motion.panel"
|
||||
|
||||
function Workspace(props: { title: string }) {
|
||||
// The Motion workspace, docked above the debug bar's right end until its header is dragged. knobs loads once it opens.
|
||||
export default function Motion(props: { title: string }) {
|
||||
let container!: HTMLDivElement
|
||||
let unmount: (() => void) | undefined
|
||||
let disposed = false
|
||||
const offset = load()
|
||||
|
||||
const place = () => (container.style.transform = `translate(${offset.x}px, ${offset.y}px)`)
|
||||
|
||||
// Moves by at most what keeps the whole panel in the window, reading layout once rather than every frame.
|
||||
const bounds = () => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
x: [offset.x - rect.left, offset.x + window.innerWidth - rect.right],
|
||||
y: [offset.y - rect.top, offset.y + window.innerHeight - rect.bottom],
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (limits: ReturnType<typeof bounds>, x: number, y: number) => {
|
||||
offset.x = Math.min(Math.max(x, limits.x[0]), Math.max(limits.x[0], limits.x[1]))
|
||||
offset.y = Math.min(Math.max(y, limits.y[0]), Math.max(limits.y[0], limits.y[1]))
|
||||
place()
|
||||
}
|
||||
|
||||
const settle = () => {
|
||||
clamp(bounds(), offset.x, offset.y)
|
||||
save(offset)
|
||||
}
|
||||
|
||||
const grab = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || !(event.target instanceof Element)) return
|
||||
|
||||
const header = event.target.closest<HTMLElement>(".knobs-panel > header")
|
||||
|
||||
if (!header || event.target.closest("button, input, [role=tab]")) return
|
||||
event.preventDefault()
|
||||
const limits = bounds()
|
||||
const start = { x: event.clientX - offset.x, y: event.clientY - offset.y }
|
||||
header.setPointerCapture(event.pointerId)
|
||||
|
||||
const move = (moved: PointerEvent) => clamp(limits, moved.clientX - start.x, moved.clientY - start.y)
|
||||
|
||||
const drop = () => {
|
||||
header.removeEventListener("pointermove", move)
|
||||
header.removeEventListener("pointerup", drop)
|
||||
header.removeEventListener("pointercancel", drop)
|
||||
save(offset)
|
||||
}
|
||||
|
||||
header.addEventListener("pointermove", move)
|
||||
header.addEventListener("pointerup", drop)
|
||||
header.addEventListener("pointercancel", drop)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
place()
|
||||
const observer = new ResizeObserver(settle)
|
||||
observer.observe(container)
|
||||
window.addEventListener("resize", settle)
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener("resize", settle)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
@@ -37,9 +80,28 @@ function Workspace(props: { title: string }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={container}
|
||||
class="pointer-events-auto fixed bottom-10 right-3 z-50 max-h-[calc(100vh-6rem)] w-[320px] overflow-auto"
|
||||
/>
|
||||
<Portal>
|
||||
<div
|
||||
ref={container}
|
||||
class="pointer-events-auto fixed bottom-10 right-3 z-50 max-h-[calc(100vh-6rem)] w-[320px] overflow-auto [&_.knobs-panel>header]:cursor-grab [&_.knobs-panel>header]:touch-none"
|
||||
onPointerDown={grab}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key) ?? "{}")
|
||||
|
||||
return { x: Number(saved.x) || 0, y: Number(saved.y) || 0 }
|
||||
} catch {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function save(offset: { x: number; y: number }) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(offset))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import type Debug from "./index"
|
||||
|
||||
const setup: Setup<typeof Debug> = (ctx) => {
|
||||
const DebugBar = lazy(() => import("./bar"))
|
||||
const Motion = lazy(() => import("./motion"))
|
||||
// Motion tuning lives only in the debug bar, on the dev server and in Beta. Both are fixed at build time, so other
|
||||
// builds, prod included, drop the import and never contain knobs.
|
||||
const motionTools = import.meta.env.DEV || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
const Motion = motionTools ? lazy(() => import("./motion")) : undefined
|
||||
const channel = ctx.build.channel
|
||||
// Window-local; every window starts with the bar hidden.
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
@@ -19,30 +22,17 @@ const setup: Setup<typeof Debug> = (ctx) => {
|
||||
setMotion((value) => !value)
|
||||
}
|
||||
|
||||
// Motion tuning is a dev-server tool: no built app (dev, beta or prod channel) ships its button or panel.
|
||||
const motionTools = import.meta.env.DEV
|
||||
|
||||
ctx.add(
|
||||
Command,
|
||||
(): Command => ({
|
||||
id: "toggle",
|
||||
title: ctx.t("command.toggle"),
|
||||
group: ctx.t("command.category.view"),
|
||||
keywords: "dev bar developer tools diagnostics",
|
||||
run: toggle,
|
||||
}),
|
||||
)
|
||||
|
||||
if (motionTools)
|
||||
ctx.add(
|
||||
Command,
|
||||
(): Command => ({
|
||||
id: "motion",
|
||||
title: ctx.t("command.motion"),
|
||||
group: ctx.t("command.category.view"),
|
||||
run: toggleMotion,
|
||||
}),
|
||||
)
|
||||
|
||||
ctx.add(Slot, {
|
||||
at: "window.bottom",
|
||||
render: () => (
|
||||
@@ -51,16 +41,19 @@ const setup: Setup<typeof Debug> = (ctx) => {
|
||||
<Suspense>
|
||||
<DebugBar
|
||||
diagnostics={import.meta.env.DEV}
|
||||
motion={motionTools ? { open: motion(), toggle: toggleMotion } : undefined}
|
||||
motion={Motion ? { open: motion(), toggle: toggleMotion } : undefined}
|
||||
inline
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={motionTools}>
|
||||
<Suspense>
|
||||
<Motion open={motion()} button={!visible()} toggle={toggleMotion} title={ctx.t("motion.title")} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
{Motion && (
|
||||
// The Motion workspace opens only from the debug bar's MOTION cell, and shows while the bar does.
|
||||
<Show when={visible() && motion()}>
|
||||
<Suspense>
|
||||
<Motion title={ctx.t("motion.title")} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface Command {
|
||||
readonly title: string
|
||||
/** The palette's second line. */
|
||||
readonly description?: string
|
||||
/** Extra words the palette matches, separated by spaces, e.g. "dev bar" for the debug bar. */
|
||||
readonly keywords?: string
|
||||
/** The palette category the command is listed under; translated text. */
|
||||
readonly group?: string
|
||||
/**
|
||||
|
||||
@@ -102,6 +102,17 @@ describe("createFlight", () => {
|
||||
expect(flight.frame()).toEqual(preview)
|
||||
})
|
||||
|
||||
test("keeps its launch shape on a slower spring, stretched in time", () => {
|
||||
const snappy = createFlight(thumbnail)
|
||||
const calm = createFlight(thumbnail)
|
||||
snappy.to(preview, KICK.open)
|
||||
calm.to(preview, KICK.open, { duration: 0.4, bounce: 0 })
|
||||
snappy.step(1 / 60)
|
||||
calm.step(2 / 60)
|
||||
|
||||
expect(progress(calm)).toBeCloseTo(progress(snappy), 5)
|
||||
})
|
||||
|
||||
test("turns around mid-flight from where it is, carrying its velocity", () => {
|
||||
const flight = createFlight(thumbnail)
|
||||
flight.to(preview, KICK.open)
|
||||
|
||||
@@ -15,10 +15,11 @@ export type FlightFrame = { image: Box; clip: Box; radius: number }
|
||||
|
||||
/**
|
||||
* Claude's preview, fitted frame by frame: one critically damped spring for both directions, launched with a push of
|
||||
* this many times the remaining distance per second. A spring from rest starts too slowly to match.
|
||||
* `KICK` times the remaining distance per second. A spring from rest starts too slowly to match.
|
||||
*/
|
||||
export const FLIGHT_SPRING = { duration: 0.2, bounce: 0 }
|
||||
|
||||
/** The launch push on `FLIGHT_SPRING`. Other durations scale it with the spring, so a slower spring keeps its shape. */
|
||||
export const KICK = { open: 14, close: 32.5 }
|
||||
|
||||
/** A flight frame with one spring per edge, so heading somewhere new mid-flight keeps the velocity it already has. */
|
||||
@@ -26,13 +27,15 @@ export function createFlight(frame: FlightFrame) {
|
||||
const springs = values(frame).map((value) => Spring.fromDuration(value, FLIGHT_SPRING))
|
||||
|
||||
return {
|
||||
/** Heads for `frame` on `tuning`, adding a launch velocity of `kick` times the remaining distance per second. */
|
||||
/** Heads for `frame` on `tuning`, launched with a `KICK` push scaled to the spring's duration. */
|
||||
to(frame: FlightFrame, kick: number, tuning: DurationOptions = FLIGHT_SPRING) {
|
||||
const push = (kick * FLIGHT_SPRING.duration) / tuning.duration
|
||||
|
||||
values(frame).forEach((target, index) => {
|
||||
const spring = springs[index]
|
||||
spring.omega = (2 * Math.PI) / tuning.duration
|
||||
spring.zeta = 1 - tuning.bounce
|
||||
spring.set(target, { velocity: spring.velocity + kick * (target - spring.value) })
|
||||
spring.set(target, { velocity: spring.velocity + push * (target - spring.value) })
|
||||
})
|
||||
},
|
||||
/** Advances `dt` seconds; true until every edge has landed. */
|
||||
|
||||
@@ -33,91 +33,22 @@ const STEP = Math.SQRT2
|
||||
|
||||
const group = "Image open"
|
||||
|
||||
// Claude's backdrop dims from rest, more gently than the image flies. Without a flight (reduced motion, or no thumbnail
|
||||
// to fly to), the preview only fades.
|
||||
const claude = {
|
||||
springs: {
|
||||
imageOpen: FLIGHT_SPRING,
|
||||
imageClose: FLIGHT_SPRING,
|
||||
imageScrimOpen: { duration: 0.34, bounce: 0 },
|
||||
imageScrimClose: { duration: 0.2, bounce: 0 },
|
||||
// One spring carries the open and the close, and the backdrop dims on it too. Snappy is Claude's measured preview.
|
||||
const tuning = motionTuning.spring("imagePreview", {
|
||||
label: "Spring",
|
||||
group,
|
||||
presets: {
|
||||
snappy: FLIGHT_SPRING,
|
||||
smooth: { duration: 0.3, bounce: 0 },
|
||||
calm: { duration: 0.4, bounce: 0 },
|
||||
},
|
||||
numbers: { imageOpenKick: KICK.open, imageCloseKick: KICK.close, imageFade: 0.15 },
|
||||
}
|
||||
})
|
||||
|
||||
const presets = {
|
||||
"Claude match": claude,
|
||||
Calm: {
|
||||
springs: {
|
||||
imageOpen: { duration: 0.32, bounce: 0 },
|
||||
imageClose: { duration: 0.26, bounce: 0 },
|
||||
imageScrimOpen: { duration: 0.45, bounce: 0 },
|
||||
imageScrimClose: { duration: 0.28, bounce: 0 },
|
||||
},
|
||||
numbers: { imageOpenKick: 6, imageCloseKick: 16, imageFade: 0.2 },
|
||||
},
|
||||
}
|
||||
|
||||
const kick = { group, min: 0, max: 60, step: 0.5, unit: "×/s", time: "rate" } as const
|
||||
|
||||
const tuning = {
|
||||
open: motionTuning.spring("imageOpen", { label: "Open spring", group, ...claude.springs.imageOpen }),
|
||||
openKick: motionTuning.number("imageOpenKick", {
|
||||
...kick,
|
||||
label: "Open launch velocity",
|
||||
value: claude.numbers.imageOpenKick,
|
||||
}),
|
||||
close: motionTuning.spring("imageClose", { label: "Close spring", group, ...claude.springs.imageClose }),
|
||||
closeKick: motionTuning.number("imageCloseKick", {
|
||||
...kick,
|
||||
label: "Close launch velocity",
|
||||
value: claude.numbers.imageCloseKick,
|
||||
}),
|
||||
scrimOpen: motionTuning.spring("imageScrimOpen", { label: "Backdrop in", group, ...claude.springs.imageScrimOpen }),
|
||||
scrimClose: motionTuning.spring("imageScrimClose", {
|
||||
label: "Backdrop out",
|
||||
group,
|
||||
...claude.springs.imageScrimClose,
|
||||
}),
|
||||
fade: motionTuning.number("imageFade", {
|
||||
label: "Reduced-motion fade",
|
||||
group,
|
||||
value: claude.numbers.imageFade,
|
||||
min: 0.05,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
unit: "s",
|
||||
time: "duration",
|
||||
}),
|
||||
}
|
||||
|
||||
Object.entries(presets).forEach(([name, preset]) =>
|
||||
motionTuning.action({
|
||||
id: `imagePreview.preset.${name}`,
|
||||
label: `Preset: ${name}`,
|
||||
group,
|
||||
run: () => {
|
||||
const knobs = motionTuning.list()
|
||||
|
||||
Object.entries(preset.springs).forEach(([id, value]) => {
|
||||
const knob = knobs.find((item) => item.id === id)
|
||||
|
||||
if (knob?.kind === "spring") motionTuning.set(knob, value)
|
||||
})
|
||||
|
||||
Object.entries(preset.numbers).forEach(([id, value]) => {
|
||||
const knob = knobs.find((item) => item.id === id)
|
||||
|
||||
if (knob?.kind === "number") motionTuning.set(knob, value)
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const fade = () => ({ duration: Math.max(0.03, tuning.fade()), bounce: 0 })
|
||||
// Without a flight (reduced motion, or no thumbnail to fly to), the preview only fades.
|
||||
const fade = { duration: 0.15, bounce: 0 }
|
||||
|
||||
// How long the dialog stays mounted after a close starts, in milliseconds: the flight lands well within this.
|
||||
const exitDuration = () => Math.max(tuning.close().duration, tuning.scrimClose().duration, tuning.fade()) * 1800
|
||||
const exitDuration = () => Math.max(tuning().duration, fade.duration) * 1800
|
||||
|
||||
const CENTER = { x: 0, y: 0 }
|
||||
|
||||
@@ -136,7 +67,7 @@ export function ImagePreview(props: ImagePreviewProps) {
|
||||
// The flight under way: where the stand-in is laid out (the flight only transforms and clips it), its springs, and
|
||||
// what happens when it lands.
|
||||
let course: { at: Box; flight: ReturnType<typeof createFlight>; landed: () => void } | undefined
|
||||
const dim = Spring.fromDuration(0, claude.springs.imageScrimOpen)
|
||||
const dim = Spring.fromDuration(0, FLIGHT_SPRING)
|
||||
// Without a flight the panel and any stand-in fade with the backdrop; with one, the panel follows it more steeply,
|
||||
// so it settles in around the image as it lands and clears before the image leaves.
|
||||
let plain = false
|
||||
@@ -204,7 +135,7 @@ export function ImagePreview(props: ImagePreviewProps) {
|
||||
if (stage() !== "pending" || !dialog.isOpen()) return
|
||||
const from = prefersReducedMotion() ? undefined : originFrame()
|
||||
holdExit()
|
||||
dimTo(1, from ? tuning.scrimOpen() : fade())
|
||||
dimTo(1, from ? tuning() : fade)
|
||||
|
||||
if (!from) {
|
||||
plain = true
|
||||
@@ -215,7 +146,7 @@ export function ImagePreview(props: ImagePreviewProps) {
|
||||
}
|
||||
|
||||
const flight = createFlight(from)
|
||||
flight.to(restFrame(), tuning.openKick(), tuning.open())
|
||||
flight.to(restFrame(), KICK.open, tuning())
|
||||
course = { at: place(fitted()), flight, landed: () => setStage("rest") }
|
||||
hideOrigin()
|
||||
paint()
|
||||
@@ -228,7 +159,7 @@ export function ImagePreview(props: ImagePreviewProps) {
|
||||
|
||||
if (stage() === "pending") return
|
||||
const to = prefersReducedMotion() ? undefined : originFrame()
|
||||
dimTo(0, to ? tuning.scrimClose() : fade())
|
||||
dimTo(0, to ? tuning() : fade)
|
||||
|
||||
if (!to) {
|
||||
plain = true
|
||||
@@ -238,7 +169,7 @@ export function ImagePreview(props: ImagePreviewProps) {
|
||||
|
||||
// A close mid-open turns the flight around where it is, keeping its velocity.
|
||||
course ??= { at: place(fitted()), flight: createFlight(restFrame()), landed: showOrigin }
|
||||
course.flight.to(to, tuning.closeKick(), tuning.close())
|
||||
course.flight.to(to, KICK.close, tuning())
|
||||
course.landed = showOrigin
|
||||
hideOrigin()
|
||||
paint()
|
||||
@@ -603,7 +534,7 @@ function addReplayActions() {
|
||||
// The flight starts once the image has decoded; close a little way into it.
|
||||
const wait = () => {
|
||||
if (document.querySelector('[data-component="image-preview"][data-stage="flying"]'))
|
||||
return void setTimeout(close, tuning.open().duration * 350)
|
||||
return void setTimeout(close, tuning().duration * 350)
|
||||
|
||||
if (performance.now() - start < 2000) requestAnimationFrame(wait)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { SpringValue } from "knobs"
|
||||
import { motionTuning, type MotionKnob } from "./motion-tuning"
|
||||
import type { DurationOptions } from "./motion"
|
||||
import { motionPresets, motionTuning, type MotionKnob } from "./motion-tuning"
|
||||
|
||||
const global = "Global"
|
||||
|
||||
// Kit's palette, applied to every spring at once.
|
||||
const durations = { snappy: 0.25, calm: 0.3, smooth: 0.45 }
|
||||
|
||||
/**
|
||||
* Mounts the Motion workspace into `container`: a tab per surface, Global on every tab, an action row per replay
|
||||
* button, a "Jump to" palette over sections, actions and presets, presets and copy as code. knobs loads on first
|
||||
* Mounts the Motion workspace into `container`: a tab per surface under the Snappy, Smooth and Calm presets for every
|
||||
* spring at once. Each tab shows one Global card (speed, slow motion and other settings every surface reads), then one
|
||||
* card for its surface: its springs and knobs, with its replay buttons under a Replay sub-heading. knobs loads on first
|
||||
* mount, so importing this module costs nothing until then. Works anywhere a surface registered its knobs, including
|
||||
* Storybook stories. Returns its removal.
|
||||
*/
|
||||
@@ -25,10 +23,7 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const [{ action, defineControls, knob, spring, toggle }, { commandPalette, commandsFrom }] = await Promise.all([
|
||||
import("knobs"),
|
||||
import("knobs/ui"),
|
||||
])
|
||||
const { action, defineControls, 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 }
|
||||
@@ -40,10 +35,10 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
|
||||
return knob(item.initial, { ...meta, min: item.min, max: item.max, step: item.step, unit: item.unit ?? "×" })
|
||||
}
|
||||
|
||||
const apply = (item: MotionKnob, value: SpringValue | boolean | number) => {
|
||||
const apply = (item: MotionKnob, value: MotionKnob["value"]) => {
|
||||
if (item.kind === "spring") {
|
||||
// SAFETY: a spring knob's field is a spring field.
|
||||
return motionTuning.set(item, value as SpringValue)
|
||||
return motionTuning.set(item, value as DurationOptions)
|
||||
}
|
||||
|
||||
if (item.kind === "toggle") {
|
||||
@@ -67,50 +62,41 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
|
||||
const springs = list.flatMap((item) => (item.kind === "spring" ? [item] : []))
|
||||
const sections = [...new Set([...list, ...actions].map((item) => item.group))].filter((group) => group !== global)
|
||||
|
||||
const palette = commandPalette({
|
||||
hotkey: false,
|
||||
recents: false,
|
||||
placeholder: "Jump to a section, action or preset",
|
||||
commands: () => [
|
||||
...sections.map((id) => ({
|
||||
id: `section.${id}`,
|
||||
label: id,
|
||||
section: "Sections",
|
||||
run: () => {
|
||||
section = id
|
||||
mount()
|
||||
},
|
||||
})),
|
||||
...commandsFrom(controls, { fields: false }).filter((command) => command.label !== "Jump to…"),
|
||||
],
|
||||
})
|
||||
|
||||
const controls = defineControls(
|
||||
"opencode-motion",
|
||||
{
|
||||
...Object.fromEntries(list.map((item) => [item.id, field(item)])),
|
||||
// Global first, then each section's knobs and its replay buttons together, so each shows as one card.
|
||||
...Object.fromEntries(list.filter((item) => item.group === global).map((item) => [item.id, field(item)])),
|
||||
...Object.fromEntries(
|
||||
actions.map((item) => [
|
||||
`action.${item.id}`,
|
||||
action(item.label, { group: item.group, tab: item.group, run: item.run }),
|
||||
]),
|
||||
[...list, ...actions]
|
||||
.filter((item) => item.group !== global)
|
||||
.toSorted((a, b) => sections.indexOf(a.group) - sections.indexOf(b.group))
|
||||
.map((item) =>
|
||||
"run" in item
|
||||
? [
|
||||
`action.${item.id}`,
|
||||
action(item.label, { group: item.group, tab: item.group, subsection: "Replay", run: item.run }),
|
||||
]
|
||||
: [item.id, field(item)],
|
||||
),
|
||||
),
|
||||
...Object.fromEntries([["jump", action("Jump to…", { group: global, run: () => palette.open() })]]),
|
||||
},
|
||||
{
|
||||
// knobs enables itself only on localhost; this module is only loaded in developer channels.
|
||||
enabled: true,
|
||||
ship: () => import("knobs/panel"),
|
||||
persist: "variants",
|
||||
presets: Object.fromEntries(
|
||||
Object.entries(durations).map(([name, duration]) => [
|
||||
name,
|
||||
Object.fromEntries(springs.map((item) => [item.id, { duration, bounce: 0 }])),
|
||||
]),
|
||||
),
|
||||
// The defaults are Snappy; the strip names them so rather than "default".
|
||||
defaultLabel: motionPresets.snappy,
|
||||
presetLabels: { smooth: motionPresets.smooth, calm: motionPresets.calm },
|
||||
presets: {
|
||||
smooth: Object.fromEntries(springs.map((item) => [item.id, item.presets.smooth])),
|
||||
calm: Object.fromEntries(springs.map((item) => [item.id, item.presets.calm])),
|
||||
},
|
||||
onChange: (patch) =>
|
||||
list.forEach((item) => {
|
||||
const value = patch[item.id]
|
||||
// SAFETY: each field was made from its knob above, so its value is that knob's kind.
|
||||
const value = patch[item.id] as MotionKnob["value"] | undefined
|
||||
|
||||
if (value !== undefined) apply(item, value)
|
||||
}),
|
||||
@@ -137,7 +123,6 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
|
||||
unmount = () => {
|
||||
stopWatch()
|
||||
stopPanel()
|
||||
palette.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { motionGlobals, motionTuning } from "./motion-tuning"
|
||||
|
||||
const presets = {
|
||||
snappy: { duration: 0.15, bounce: 0 },
|
||||
smooth: { duration: 0.25, bounce: 0 },
|
||||
calm: { duration: 0.4, bounce: 0 },
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
motionTuning.set(motionGlobals.speed, 1)
|
||||
motionTuning.set(motionGlobals.slow, false)
|
||||
})
|
||||
|
||||
describe("motionTuning.spring", () => {
|
||||
test("ships Snappy and keeps the other presets on the knob", () => {
|
||||
const spring = motionTuning.spring("test.shared", { label: "Spring", group: "Test", presets })
|
||||
const knob = motionTuning.list().find((item) => item.id === "test.shared")
|
||||
|
||||
expect(spring()).toEqual(presets.snappy)
|
||||
expect(knob?.kind === "spring" && knob.initial).toEqual(presets.snappy)
|
||||
expect(knob?.kind === "spring" && knob.presets).toEqual({ smooth: presets.smooth, calm: presets.calm })
|
||||
})
|
||||
|
||||
test("runs a preset applied to its knob, stretched by the global speed and slow motion", () => {
|
||||
const spring = motionTuning.spring("test.scaled", { label: "Spring", group: "Test", presets })
|
||||
const knob = motionTuning.list().find((item) => item.id === "test.scaled")
|
||||
|
||||
if (knob?.kind !== "spring") throw new Error("expected a spring knob")
|
||||
motionTuning.set(knob, knob.presets.calm)
|
||||
expect(spring()).toEqual({ duration: 0.4, bounce: 0 })
|
||||
|
||||
motionTuning.set(motionGlobals.speed, 2)
|
||||
expect(spring().duration).toBeCloseTo(0.2)
|
||||
|
||||
motionTuning.set(motionGlobals.slow, true)
|
||||
expect(spring().duration).toBeCloseTo(0.8)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,13 @@ type Knob<Kind extends string, Value> = {
|
||||
value: Value
|
||||
}
|
||||
|
||||
/** The presets every spring offers. Its shipped value is Snappy; Smooth and Calm are slower, all without bounce. */
|
||||
export const motionPresets = { snappy: "Snappy", smooth: "Smooth", calm: "Calm" } as const
|
||||
|
||||
export type MotionPreset = keyof typeof motionPresets
|
||||
|
||||
export type MotionKnob =
|
||||
| Knob<"spring", DurationOptions>
|
||||
| (Knob<"spring", DurationOptions> & { presets: Record<Exclude<MotionPreset, "snappy">, DurationOptions> })
|
||||
| Knob<"toggle", boolean>
|
||||
| (Knob<"number", number> & { min: number; max: number; step: number; unit?: string })
|
||||
|
||||
@@ -22,7 +27,8 @@ export type MotionAction = { id: string; label: string; group: string; run: () =
|
||||
|
||||
type Meta = { label: string; group: string }
|
||||
|
||||
const key = "opencode.motion.v1"
|
||||
// Bumped when knobs are merged or renamed, so values saved for the old ones are dropped.
|
||||
const key = "opencode.motion.v2"
|
||||
|
||||
const knobs = new Map<string, MotionKnob>()
|
||||
|
||||
@@ -35,8 +41,8 @@ const saved = load()
|
||||
|
||||
export const motionTuning = {
|
||||
/** The spring to run now: its tuned duration scaled by the global speed and slow motion. */
|
||||
spring(id: string, meta: Meta & DurationOptions) {
|
||||
const initial = { duration: meta.duration, bounce: meta.bounce }
|
||||
spring(id: string, meta: Meta & { presets: Record<MotionPreset, DurationOptions> }) {
|
||||
const initial = meta.presets.snappy
|
||||
|
||||
const knob = register({
|
||||
kind: "spring",
|
||||
@@ -44,11 +50,12 @@ export const motionTuning = {
|
||||
label: meta.label,
|
||||
group: meta.group,
|
||||
initial,
|
||||
value: { duration: number(`${id}.duration`, meta.duration), bounce: number(`${id}.bounce`, meta.bounce) },
|
||||
value: { duration: number(`${id}.duration`, initial.duration), bounce: number(`${id}.bounce`, initial.bounce) },
|
||||
presets: { smooth: meta.presets.smooth, calm: meta.presets.calm },
|
||||
})
|
||||
|
||||
return (): DurationOptions => ({
|
||||
duration: Math.max(0.03, (knob.value.duration * (slow.value ? 4 : 1)) / speed.value),
|
||||
duration: Math.max(0.03, knob.value.duration * timeScale()),
|
||||
bounce: Math.min(0.95, Math.max(0, knob.value.bounce)),
|
||||
})
|
||||
},
|
||||
@@ -168,6 +175,8 @@ function number(id: string, fallback: number) {
|
||||
|
||||
function load(): Record<string, number> {
|
||||
try {
|
||||
localStorage.removeItem("opencode.motion.v1")
|
||||
|
||||
return JSON.parse(localStorage.getItem(key) ?? "{}")
|
||||
} catch {
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user