diff --git a/packages/app/src/shell/titlebar/sidebar-hover.tsx b/packages/app/src/shell/titlebar/sidebar-hover.tsx new file mode 100644 index 00000000000..2a37b8681fa --- /dev/null +++ b/packages/app/src/shell/titlebar/sidebar-hover.tsx @@ -0,0 +1,195 @@ +import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion" +import { motionTuning } from "@opencode/ui/motion-tuning" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createSignal, onCleanup, onMount, Show } from "solid-js" + +const choice = motionTuning.choice("sidebar.hover", { + label: "Sidebar hover", + group: "Experiments", + value: "today", + labels: { today: "Today", glide: "Glide" }, +}) + +const tuning = motionTuning.spring("sidebar.hover.glide", { + label: "Sidebar hover glide", + group: "Experiments", + presets: { + snappy: { duration: 0.14, bounce: 0 }, + smooth: { duration: 0.2, bounce: 0 }, + calm: { duration: 0.28, bounce: 0 }, + }, +}) + +const [mode, setMode] = createSignal(choice()) + +motionTuning.subscribe(() => setMode(choice())) + +const rows = "[data-titlebar-tab], [data-titlebar-tab-action]" + +// The pill's corner radius, matching the rows'. +const radius = 6 + +const grid = (value: number) => Math.round(value * 64) / 64 + +const edges = ["top", "bottom", "left", "right"] as const + +/** + * Experiment: one hover pill glides behind the sidebar's rows, instead of each row lighting its own background. It + * targets the row nearest the pointer, so crossing a gap between rows holds it rather than dropping it. + */ +export function SidebarHover() { + return ( + + + + ) +} + +function Pill() { + let pill!: HTMLDivElement + let top!: HTMLDivElement + let fill!: HTMLDivElement + let bottom!: HTMLDivElement + let pointer: { x: number; y: number } | undefined + // The hovered row's edges relative to the sidebar, from the last frame's read phase. + let hit: { top: number; bottom: number; left: number; right: number } | undefined + let frame: (() => void) | undefined + + const motion = { + top: Spring.fromDuration(0, tuning()), + bottom: Spring.fromDuration(0, tuning()), + left: Spring.fromDuration(0, tuning()), + right: Spring.fromDuration(0, tuning()), + opacity: Spring.fromDuration(0, { duration: 0.15, bounce: 0 }), + } + + onMount(() => { + // SAFETY: the pill renders inside the sidebar's portal root. + const root = pill.parentElement! + root.dataset.sidebarHover = "glide" + onCleanup(() => delete root.dataset.sidebarHover) + + const read = () => { + hit = pointer && !root.querySelector('[data-dragging="true"]') ? nearest(root, pointer) : undefined + } + + const write = (dt: number) => { + const spring = tuning() + const reduced = prefersReducedMotion() + + const target = hit + + edges.forEach((edge) => { + motion[edge].omega = (2 * Math.PI) / spring.duration + motion[edge].zeta = 1 - spring.bounce + + if (!target) return + + // Appearing from hidden starts on the row rather than sweeping in from where it left. + if (motion.opacity.value === 0 || reduced) motion[edge].snap(target[edge]) + motion[edge].set(target[edge]) + }) + + motion.opacity.set(target ? 1 : 0) + + const y0 = grid(motion.top.step(dt)) + const y1 = grid(motion.bottom.step(dt)) + top.style.transform = `translateY(${y0}px)` + bottom.style.transform = `translateY(${y1 - radius * 2}px)` + fill.style.transform = `translateY(${y0 + radius}px) scaleY(${Math.max(0, y1 - y0 - radius * 2)})` + + const x0 = grid(motion.left.step(dt)) + const width = `${grid(motion.right.step(dt)) - x0}px` + pill.style.transform = `translateX(${x0}px)` + + // Only Home beside the sidebar toggle is narrower, so width changes only on the way to or from it. + if (pill.style.width !== width) pill.style.width = width + pill.style.opacity = String(motion.opacity.step(dt)) + + if (Object.values(motion).some((item) => !item.resting)) return true + frame = undefined + + return false + } + + const wake = () => { + frame ??= frameLoop.addMeasured(read, write) + } + + // While the pointer rests inside, rows that open, close, resize or finish a drag move the pill to the row now under it. + const resize = new ResizeObserver(wake) + const mutation = new MutationObserver(wake) + + makeEventListener(root, "pointermove", (event) => { + if (event.pointerType === "touch") return + + if (!pointer) + root.querySelectorAll("[data-titlebar-tab-list]").forEach((list) => { + resize.observe(list) + mutation.observe(list, { childList: true, subtree: true, attributeFilter: ["data-dragging"] }) + }) + + pointer = { x: event.clientX, y: event.clientY } + wake() + }) + makeEventListener(root, "pointerleave", () => { + pointer = undefined + resize.disconnect() + mutation.disconnect() + wake() + }) + makeEventListener(root, "pointerup", wake) + // Rows that scroll under a resting pointer move the pill with them. + makeEventListener(root, "scroll", wake, { capture: true, passive: true }) + onCleanup(() => { + frame?.() + resize.disconnect() + mutation.disconnect() + }) + }) + + // Opaque slices, so the caps can overlap the middle without darkening it; the group fades as one. + return ( +