diff options
| author | Kit Langton <[email protected]> | 2026-04-13 19:36:28 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-13 19:36:28 -0400 |
| commit | 43b37346b639bcaf6cd74e725bd912c16ce45bb0 (patch) | |
| tree | 0170750a1caaa72358001a7e2a67075ebff31932 | |
| parent | d199648aebd68cbd95b8af0afd59e608c9c18136 (diff) | |
| download | opencode-43b37346b639bcaf6cd74e725bd912c16ce45bb0.tar.gz opencode-43b37346b639bcaf6cd74e725bd912c16ce45bb0.zip | |
feat: add interactive burst to the TUI logo (#22098)
| -rw-r--r-- | bun.lock | 7 | ||||
| -rw-r--r-- | packages/opencode/package.json | 1 | ||||
| -rw-r--r-- | packages/opencode/src/audio.d.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/asset/charge.wav | bin | 0 -> 590984 bytes | |||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav | bin | 0 -> 176444 bytes | |||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav | bin | 0 -> 176444 bytes | |||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav | bin | 0 -> 176444 bytes | |||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/component/logo.tsx | 652 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/util/sound.ts | 156 |
9 files changed, 768 insertions, 52 deletions
@@ -371,6 +371,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", + "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", @@ -2668,6 +2669,8 @@ "cli-cursor": ["[email protected]", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], + "cli-sound": ["[email protected]", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="], + "cli-spinners": ["[email protected]", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], "cli-truncate": ["[email protected]", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], @@ -3092,6 +3095,8 @@ "find-babel-config": ["[email protected]", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + "find-exec": ["[email protected]", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="], + "find-my-way": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "find-my-way-ts": ["[email protected]", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], @@ -4412,6 +4417,8 @@ "shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["[email protected]", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], "shikiji": ["[email protected]", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f5cc0e0a9..fcaac7b35 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -128,6 +128,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", + "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts new file mode 100644 index 000000000..54a86efa3 --- /dev/null +++ b/packages/opencode/src/audio.d.ts @@ -0,0 +1,4 @@ +declare module "*.wav" { + const file: string + export default file +} diff --git a/packages/opencode/src/cli/cmd/tui/asset/charge.wav b/packages/opencode/src/cli/cmd/tui/asset/charge.wav Binary files differnew file mode 100644 index 000000000..d9597899c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/asset/charge.wav diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav Binary files differnew file mode 100644 index 000000000..2ebb6a38b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav Binary files differnew file mode 100644 index 000000000..4e1b59c96 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav Binary files differnew file mode 100644 index 000000000..feb56cacd --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 8e6208b14..51cf69dc1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,82 +1,630 @@ -import { TextAttributes, RGBA } from "@opentui/core" -import { For, type JSX } from "solid-js" +import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" +import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import { logo, marks } from "@/cli/logo" +import { Sound } from "@tui/util/sound" +import { logo } from "@/cli/logo" // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) // ~ = shadow top only (▀ with fg=shadow) -const SHADOW_MARKER = new RegExp(`[${marks}]`) +const GAP = 1 +const WIDTH = 0.76 +const GAIN = 2.3 +const FLASH = 2.15 +const TRAIL = 0.28 +const SWELL = 0.24 +const WIDE = 1.85 +const DRIFT = 1.45 +const EXPAND = 1.62 +const LIFE = 1020 +const CHARGE = 3000 +const HOLD = 90 +const SINK = 40 +const ARC = 2.2 +const FORK = 1.2 +const DIM = 1.04 +const KICK = 0.86 +const LAG = 60 +const SUCK = 0.34 +const SHIMMER_IN = 60 +const SHIMMER_OUT = 2.8 +const TRACE = 0.033 +const TAIL = 1.8 +const TRACE_IN = 200 +const GLOW_OUT = 1600 +const PEAK = RGBA.fromInts(255, 255, 255) + +type Ring = { + x: number + y: number + at: number + force: number + kick: number +} + +type Hold = { + x: number + y: number + at: number + glyph: number | undefined +} + +type Release = { + x: number + y: number + at: number + glyph: number | undefined + level: number + rise: number +} + +type Glow = { + glyph: number + at: number + force: number +} + +type Frame = { + t: number + list: Ring[] + hold: Hold | undefined + release: Release | undefined + glow: Glow | undefined + spark: number +} + +const LEFT = logo.left[0]?.length ?? 0 +const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i]) +const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94 +const NEAR = [ + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1], + [0, -1], + [1, -1], +] as const + +type Trace = { + glyph: number + i: number + l: number +} + +function clamp(n: number) { + return Math.max(0, Math.min(1, n)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * clamp(t) +} + +function ease(t: number) { + const p = clamp(t) + return p * p * (3 - 2 * p) +} + +function push(t: number) { + const p = clamp(t) + return ease(p * p) +} + +function ramp(t: number, start: number, end: number) { + if (end <= start) return ease(t >= end ? 1 : 0) + return ease((t - start) / (end - start)) +} + +function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) { + const mid = tint(base, theme.primary, 0.84) + const top = tint(theme.primary, PEAK, 0.96) + if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14)) + return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1)))) +} + +function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) { + if (n >= 0) return glow(base, theme, n) + return tint(base, theme.background, Math.min(0.82, -n * 0.64)) +} + +function ghost(n: number, scale: number) { + if (n < 0) return n + return n * scale +} + +function noise(x: number, y: number, t: number) { + const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453 + return n - Math.floor(n) +} + +function lit(char: string) { + return char !== " " && char !== "_" && char !== "~" +} + +function key(x: number, y: number) { + return `${x},${y}` +} + +function route(list: Array<{ x: number; y: number }>) { + const left = new Map(list.map((item) => [key(item.x, item.y), item])) + const path: Array<{ x: number; y: number }> = [] + let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0] + let dir = { x: 1, y: 0 } + + while (cur) { + path.push(cur) + left.delete(key(cur.x, cur.y)) + if (!left.size) return path + + const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy))) + .filter((item): item is { x: number; y: number } => !!item) + .sort((a, b) => { + const ax = a.x - cur.x + const ay = a.y - cur.y + const bx = b.x - cur.x + const by = b.y - cur.y + const adot = ax * dir.x + ay * dir.y + const bdot = bx * dir.x + by * dir.y + if (adot !== bdot) return bdot - adot + return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by)) + })[0] + + if (!next) { + cur = [...left.values()].sort((a, b) => { + const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2 + const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2 + return da - db + })[0] + dir = { x: 1, y: 0 } + continue + } + + dir = { x: next.x - cur.x, y: next.y - cur.y } + cur = next + } + + return path +} + +function mapGlyphs() { + const cells = [] as Array<{ x: number; y: number }> + + for (let y = 0; y < FULL.length; y++) { + for (let x = 0; x < (FULL[y]?.length ?? 0); x++) { + if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y }) + } + } + + const all = new Map(cells.map((item) => [key(item.x, item.y), item])) + const seen = new Set<string>() + const glyph = new Map<string, number>() + const trace = new Map<string, Trace>() + const center = new Map<number, { x: number; y: number }>() + let id = 0 + + for (const item of cells) { + const start = key(item.x, item.y) + if (seen.has(start)) continue + const stack = [item] + const part = [] as Array<{ x: number; y: number }> + seen.add(start) + + while (stack.length) { + const cur = stack.pop()! + part.push(cur) + glyph.set(key(cur.x, cur.y), id) + for (const [dx, dy] of NEAR) { + const next = all.get(key(cur.x + dx, cur.y + dy)) + if (!next) continue + const mark = key(next.x, next.y) + if (seen.has(mark)) continue + seen.add(mark) + stack.push(next) + } + } + + const path = route(part) + path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length })) + center.set(id, { + x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5, + y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1, + }) + id++ + } + + return { glyph, trace, center } +} + +const MAP = mapGlyphs() + +function shimmer(x: number, y: number, frame: Frame) { + return frame.list.reduce((best, item) => { + const age = frame.t - item.at + if (age < SHIMMER_IN || age > LIFE) return best + const dx = x + 0.5 - item.x + const dy = y * 2 + 1 - item.y + const dist = Math.hypot(dx, dy) + const p = age / LIFE + const r = SPAN * (1 - (1 - p) ** EXPAND) + const lag = r - dist + if (lag < 0.18 || lag > SHIMMER_OUT) return best + const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2)) + const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7) + const n = band * wobble * (1 - p) ** 1.45 + if (n > best) return n + return best + }, 0) +} + +function remain(x: number, y: number, item: Release, t: number) { + const age = t - item.at + if (age < 0 || age > LIFE) return 0 + const p = age / LIFE + const dx = x + 0.5 - item.x - 0.5 + const dy = y * 2 + 1 - item.y * 2 - 1 + const dist = Math.hypot(dx, dy) + const r = SPAN * (1 - (1 - p) ** EXPAND) + if (dist > r) return 1 + return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0) +} + +function wave(x: number, y: number, frame: Frame, live: boolean) { + return frame.list.reduce((sum, item) => { + const age = frame.t - item.at + if (age < 0 || age > LIFE) return sum + const p = age / LIFE + const dx = x + 0.5 - item.x + const dy = y * 2 + 1 - item.y + const dist = Math.hypot(dx, dy) + const r = SPAN * (1 - (1 - p) ** EXPAND) + const fade = (1 - p) ** 1.32 + const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52 + const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j + const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force + const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0 + const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j) + const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100) + const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110) + const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0 + return sum + edge + swell + trail + flash + wake - kick - suck + }, 0) +} + +function field(x: number, y: number, frame: Frame) { + const held = frame.hold + const rest = frame.release + const item = held ?? rest + if (!item) return 0 + const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise + const level = held ? push(rise) : rest!.level + const body = rise + const storm = level * level + const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise + const dx = x + 0.5 - item.x - 0.5 + const dy = y * 2 + 1 - item.y * 2 - 1 + const dist = Math.hypot(dx, dy) + const angle = Math.atan2(dy, dx) + const spin = frame.t * lerp(0.008, 0.018, storm) + const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014)) + const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body) + const shell = + Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body) + const ember = + Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) * + lerp(0.02, 0.78, body) + const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8 + const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12 + const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm) + const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK + const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm) + const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm)) + const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18 + const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1 + const flicker = + Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) * + Math.exp(-(dist * dist) / 0.15) * + lerp(0.08, 0.42, body) + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade +} + +function pick(x: number, y: number, frame: Frame) { + const held = frame.hold + const rest = frame.release + const item = held ?? rest + if (!item) return 0 + const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise + const dx = x + 0.5 - item.x - 0.5 + const dy = y * 2 + 1 - item.y * 2 - 1 + const dist = Math.hypot(dx, dy) + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade +} + +function select(x: number, y: number) { + const direct = MAP.glyph.get(key(x, y)) + if (direct !== undefined) return direct + + const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find( + (item): item is number => item !== undefined, + ) + return near +} + +function trace(x: number, y: number, frame: Frame) { + const held = frame.hold + const rest = frame.release + const item = held ?? rest + if (!item || item.glyph === undefined) return 0 + const step = MAP.trace.get(key(x, y)) + if (!step || step.glyph !== item.glyph || step.l < 2) return 0 + const age = frame.t - item.at + const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise + const appear = held ? ramp(age, 0, TRACE_IN) : 1 + const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise) + const head = (age * speed) % step.l + const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head)) + const tail = (head - TAIL + step.l) % step.l + const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail)) + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise) + const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise) + const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise) + return (core + glow + trail) * appear * fade +} + +function bloom(x: number, y: number, frame: Frame) { + const item = frame.glow + if (!item) return 0 + const glyph = MAP.glyph.get(key(x, y)) + if (glyph !== item.glyph) return 0 + const age = frame.t - item.at + if (age < 0 || age > GLOW_OUT) return 0 + const p = age / GLOW_OUT + const flash = (1 - p) ** 2 + const dx = x + 0.5 - MAP.center.get(item.glyph)!.x + const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y + const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2)) + return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash +} export function Logo() { const { theme } = useTheme() + const [rings, setRings] = createSignal<Ring[]>([]) + const [hold, setHold] = createSignal<Hold>() + const [release, setRelease] = createSignal<Release>() + const [glow, setGlow] = createSignal<Glow>() + const [now, setNow] = createSignal(0) + let box: BoxRenderable | undefined + let timer: ReturnType<typeof setInterval> | undefined + let hum = false + + const stop = () => { + if (!timer) return + clearInterval(timer) + timer = undefined + } - const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { - const shadow = tint(theme.background, fg, 0.25) + const tick = () => { + const t = performance.now() + setNow(t) + const item = hold() + if (item && !hum && t - item.at >= HOLD) { + hum = true + Sound.start() + } + if (item && t - item.at >= CHARGE) { + burst(item.x, item.y) + } + let live = false + setRings((list) => { + const next = list.filter((item) => t - item.at < LIFE) + live = next.length > 0 + return next + }) + const flash = glow() + if (flash && t - flash.at >= GLOW_OUT) { + setGlow(undefined) + } + if (!live) setRelease(undefined) + if (live || hold() || release() || glow()) return + stop() + } + + const start = () => { + if (timer) return + timer = setInterval(tick, 16) + } + + const hit = (x: number, y: number) => { + const char = FULL[y]?.[x] + return char !== undefined && char !== " " + } + + const press = (x: number, y: number, t: number) => { + const last = hold() + if (last) burst(last.x, last.y) + setNow(t) + if (!last) setRelease(undefined) + setHold({ x, y, at: t, glyph: select(x, y) }) + hum = false + start() + } + + const burst = (x: number, y: number) => { + const item = hold() + if (!item) return + hum = false + const t = performance.now() + const age = t - item.at + const rise = ramp(age, HOLD, CHARGE) + const level = push(rise) + setHold(undefined) + setRelease({ x, y, at: t, glyph: item.glyph, level, rise }) + if (item.glyph !== undefined) { + setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) }) + } + setRings((list) => [ + ...list, + { + x: x + 0.5, + y: y * 2 + 1, + at: t, + force: lerp(0.82, 2.55, level), + kick: lerp(0.32, 0.32 + KICK, level), + }, + ]) + setNow(t) + start() + Sound.pulse(lerp(0.8, 1, level)) + } + + const frame = createMemo(() => { + const t = now() + const item = hold() + return { + t, + list: rings(), + hold: item, + release: release(), + glow: glow(), + spark: item ? noise(item.x, item.y, t) : 0, + } + }) + + const dusk = createMemo(() => { + const base = frame() + const t = base.t - LAG + const item = base.hold + return { + t, + list: base.list, + hold: item, + release: base.release, + glow: base.glow, + spark: item ? noise(item.x, item.y, t) : 0, + } + }) + + const renderLine = ( + line: string, + y: number, + ink: RGBA, + bold: boolean, + off: number, + frame: Frame, + dusk: Frame, + ): JSX.Element[] => { + const shadow = tint(theme.background, ink, 0.25) const attrs = bold ? TextAttributes.BOLD : undefined - const elements: JSX.Element[] = [] - let i = 0 - - while (i < line.length) { - const rest = line.slice(i) - const markerIndex = rest.search(SHADOW_MARKER) - - if (markerIndex === -1) { - elements.push( - <text fg={fg} attributes={attrs} selectable={false}> - {rest} - </text>, + + return [...line].map((char, i) => { + const h = field(off + i, y, frame) + const n = wave(off + i, y, frame, lit(char)) + h + const s = wave(off + i, y, dusk, false) + h + const p = lit(char) ? pick(off + i, y, frame) : 0 + const e = lit(char) ? trace(off + i, y, frame) : 0 + const b = lit(char) ? bloom(off + i, y, frame) : 0 + const q = shimmer(off + i, y, frame) + + if (char === "_") { + return ( + <text + fg={shade(ink, theme, s * 0.08)} + bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))} + attributes={attrs} + selectable={false} + > + {" "} + </text> ) - break } - if (markerIndex > 0) { - elements.push( - <text fg={fg} attributes={attrs} selectable={false}> - {rest.slice(0, markerIndex)} - </text>, + if (char === "^") { + return ( + <text + fg={shade(ink, theme, n + p + e + b)} + bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))} + attributes={attrs} + selectable={false} + > + ▀ + </text> ) } - const marker = rest[markerIndex] - switch (marker) { - case "_": - elements.push( - <text fg={fg} bg={shadow} attributes={attrs} selectable={false}> - {" "} - </text>, - ) - break - case "^": - elements.push( - <text fg={fg} bg={shadow} attributes={attrs} selectable={false}> - ▀ - </text>, - ) - break - case "~": - elements.push( - <text fg={shadow} attributes={attrs} selectable={false}> - ▀ - </text>, - ) - break + if (char === "~") { + return ( + <text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}> + ▀ + </text> + ) } - i += markerIndex + 1 + if (char === " ") { + return ( + <text fg={ink} attributes={attrs} selectable={false}> + {char} + </text> + ) + } + + return ( + <text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}> + {char} + </text> + ) + }) + } + + onCleanup(() => { + stop() + hum = false + Sound.dispose() + }) + + const mouse = (evt: MouseEvent) => { + if (!box) return + if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { + const x = evt.x - box.x + const y = evt.y - box.y + if (!hit(x, y)) return + if (evt.type === "drag" && hold()) return + evt.preventDefault() + evt.stopPropagation() + const t = performance.now() + press(x, y, t) + return } - return elements + if (!hold()) return + if (evt.type === "up") { + const item = hold() + if (!item) return + burst(item.x, item.y) + } } return ( - <box> + <box ref={(item: BoxRenderable) => (box = item)}> + <box + position="absolute" + top={0} + left={0} + width={FULL[0]?.length ?? 0} + height={FULL.length} + zIndex={1} + onMouse={mouse} + /> <For each={logo.left}> {(line, index) => ( <box flexDirection="row" gap={1}> - <box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box> - <box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box> + <box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box> + <box flexDirection="row"> + {renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())} + </box> </box> )} </For> diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts new file mode 100644 index 000000000..d3a8db8b4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/sound.ts @@ -0,0 +1,156 @@ +import { Player } from "cli-sound" +import { mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import { basename, join } from "node:path" +import { Process } from "@/util/process" +import { which } from "@/util/which" +import pulseA from "../asset/pulse-a.wav" with { type: "file" } +import pulseB from "../asset/pulse-b.wav" with { type: "file" } +import pulseC from "../asset/pulse-c.wav" with { type: "file" } +import charge from "../asset/charge.wav" with { type: "file" } + +const FILE = [pulseA, pulseB, pulseC] + +const HUM = charge +const DIR = join(tmpdir(), "opencode-sfx") + +const LIST = [ + "ffplay", + "mpv", + "mpg123", + "mpg321", + "mplayer", + "afplay", + "play", + "omxplayer", + "aplay", + "cmdmp3", + "cvlc", + "powershell.exe", +] as const + +type Kind = (typeof LIST)[number] + +function args(kind: Kind, file: string, volume: number) { + if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file] + if (kind === "mpv") + return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file] + if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file] + if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file] + if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file] + if (kind === "play") return [kind, "-v", String(volume), file] + if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file] + return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] +} + +export namespace Sound { + let item: Player | null | undefined + let kind: Kind | null | undefined + let proc: Process.Child | undefined + let tail: ReturnType<typeof setTimeout> | undefined + let cache: Promise<{ hum: string; pulse: string[] }> | undefined + let seq = 0 + let shot = 0 + + function load() { + if (item !== undefined) return item + try { + item = new Player({ volume: 0.35 }) + } catch { + item = null + } + return item + } + + async function file(path: string) { + mkdirSync(DIR, { recursive: true }) + const next = join(DIR, basename(path)) + const out = Bun.file(next) + if (await out.exists()) return next + await Bun.write(out, Bun.file(path)) + return next + } + + function asset() { + cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) + return cache + } + + function pick() { + if (kind !== undefined) return kind + kind = LIST.find((item) => which(item)) ?? null + return kind + } + + function run(file: string, volume: number) { + const kind = pick() + if (!kind) return + return Process.spawn(args(kind, file, volume), { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }) + } + + function clear() { + if (!tail) return + clearTimeout(tail) + tail = undefined + } + + function play(file: string, volume: number) { + const item = load() + if (!item) return run(file, volume)?.exited + return item.play(file, { volume }).catch(() => run(file, volume)?.exited) + } + + export function start() { + stop() + const id = ++seq + void asset().then(({ hum }) => { + if (id !== seq) return + const next = run(hum, 0.24) + if (!next) return + proc = next + void next.exited.then( + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + ) + }) + } + + export function stop(delay = 0) { + seq++ + clear() + if (!proc) return + const next = proc + if (delay <= 0) { + proc = undefined + void Process.stop(next).catch(() => undefined) + return + } + tail = setTimeout(() => { + tail = undefined + if (proc === next) proc = undefined + void Process.stop(next).catch(() => undefined) + }, delay) + } + + export function pulse(scale = 1) { + stop(140) + const index = shot++ % FILE.length + void asset() + .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) + .catch(() => undefined) + } + + export function dispose() { + stop() + } +} |
