openvisengine/src/components/ava/script-tab.tsx
2026-10-22 03:29:51 +00:00

268 lines
9.4 KiB
TypeScript
Executable file

// AVA — Script tab: the custom visualization script editor.
//
// Toolbar: Apply, Save as Default, Save to File, Load from File, New, Help.
// Editor: line-number gutter + monospace textarea (synced scroll, Tab inserts
// two spaces), error lines flagged in the gutter. Status line under the editor
// shows compile status or runtime errors.
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, CircleHelp, Download, FilePlus2, FolderOpen, Play, Save, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
import { validateScript } from '@/lib/ava/script'
import { useAvaStore, persistDefaultScript } from '@/lib/store'
import { applyScriptText } from '@/components/ava/audio-source'
import { HelpPanel } from '@/components/ava/help-panel'
import { sanitizeFileName } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
export function ScriptTab() {
const { toast } = useToast()
const scriptText = useAvaStore((s) => s.scriptText)
const setScriptText = useAvaStore((s) => s.setScriptText)
const runtimeError = useAvaStore((s) => s.stats?.error ?? null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [validation, setValidation] = useState<ReturnType<typeof validateScript> | null>(null)
const [helpOpen, setHelpOpen] = useState(false)
// debounced live validation (compile is cheap, scripts are small)
useEffect(() => {
const t = setTimeout(() => setValidation(validateScript(scriptText)), 250)
return () => clearTimeout(t)
}, [scriptText])
const errorLine = validation && !validation.ok ? validation.error?.line ?? null : null
const lineCount = useMemo(() => scriptText.split('\n').length, [scriptText])
// evaluate [init] on a scratch env to report the effective point count
const initN = useMemo(() => {
if (!validation?.ok || !validation.script) return null
try {
const env: Record<string, number> = { n: 400 }
validation.script.initProg?.exec(env)
return Math.min(4096, Math.max(4, Math.round(env.n ?? 400)))
} catch {
return null
}
}, [validation])
const apply = () => {
const result = applyScriptText(scriptText)
if (result.ok) {
const v = validateScript(scriptText)
toast({
title: 'Script applied',
description: `mode: ${v.meta?.mode ?? '?'}, n=${initN ?? '?'} — running "${v.meta?.name ?? 'Untitled'}".`,
})
} else if (result.error) {
toast({
title: 'Compile error',
description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`,
variant: 'destructive',
})
}
}
const saveAsDefault = () => {
persistDefaultScript(scriptText)
toast({
title: 'Saved as default',
description: 'This script loads automatically on your next visit.',
})
}
const saveToFile = () => {
const meta = validateScript(scriptText).meta
const base = sanitizeFileName(meta?.name && meta.name !== 'Untitled' ? meta.name : 'untitled')
try {
const blob = new Blob([scriptText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${base}.ava`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast({ title: 'Script exported', description: `${base}.ava downloaded.` })
} catch {
toast({ title: 'Export failed', description: 'Could not create the download.', variant: 'destructive' })
}
}
const loadFromFile = async (file: File) => {
try {
const text = await file.text()
const result = applyScriptText(text)
if (result.ok) {
const meta = validateScript(text).meta
toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` })
} else if (result.error) {
toast({
title: 'Loaded with errors',
description: `[${result.error.section}] line ${result.error.line}: ${result.error.message} — fix and Apply.`,
variant: 'destructive',
})
}
} catch {
toast({ title: 'Could not read file', variant: 'destructive' })
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Tab') return
e.preventDefault()
const ta = e.currentTarget
const start = ta.selectionStart
const end = ta.selectionEnd
const next = scriptText.slice(0, start) + ' ' + scriptText.slice(end)
setScriptText(next)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + 2
})
}
const syncScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (gutterRef.current) gutterRef.current.scrollTop = e.currentTarget.scrollTop
}
const status = (() => {
if (validation && !validation.ok && validation.error) {
return { ok: false, text: `✘ [${validation.error.section}] line ${validation.error.line}: ${validation.error.message}` }
}
if (runtimeError) {
return { ok: false, text: `✘ runtime: ${runtimeError}` }
}
if (validation?.ok) {
return { ok: true, text: `✔ Compiled OK — mode: ${validation.meta?.mode}, n=${initN ?? 400}` }
}
return { ok: null, text: '…' }
})()
return (
<div className="space-y-3">
{/* toolbar */}
<div className="flex flex-wrap gap-1.5">
<Button
size="sm"
onClick={apply}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-zinc-950 hover:bg-emerald-300"
title="Validate and apply to the engine"
>
<Play className="size-3.5" /> Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={saveAsDefault}
className="h-9 gap-1.5 border-zinc-700"
title="Persist as your default startup script"
>
<Save className="size-3.5" /> <span className="hidden sm:inline">Save as</span> Default
</Button>
<Button
size="sm"
variant="outline"
onClick={saveToFile}
className="h-9 gap-1.5 border-zinc-700"
title="Download the script as a .ava file"
>
<Download className="size-3.5" /> <span className="hidden sm:inline">Save to</span> File
</Button>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="h-9 gap-1.5 border-zinc-700"
title="Load a .ava script from disk"
>
<FolderOpen className="size-3.5" /> <span className="hidden sm:inline">Load from</span> File
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setScriptText(STARTER_TEMPLATE)}
className="h-9 gap-1.5 border-zinc-700"
title="Start over from the template"
>
<FilePlus2 className="size-3.5" /> New
</Button>
<Button
size="sm"
variant={helpOpen ? 'secondary' : 'outline'}
onClick={() => setHelpOpen((v) => !v)}
className={cn('h-9 gap-1.5 border-zinc-700', helpOpen && 'border-emerald-400/40 text-emerald-300')}
title="Variables, functions & file format reference"
>
<CircleHelp className="size-3.5" /> Help
</Button>
</div>
{/* editor with gutter */}
<div className="flex overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950 font-mono text-xs">
<div
ref={gutterRef}
aria-hidden
className="w-11 shrink-0 select-none overflow-hidden border-r border-zinc-800/70 bg-zinc-900/50 py-2 text-right"
>
{Array.from({ length: lineCount }, (_, i) => (
<div
key={i}
className={cn(
'px-2 leading-5',
errorLine === i + 1 ? 'bg-rose-500/15 font-bold text-rose-400' : 'text-zinc-600',
)}
>
{i + 1}
</div>
))}
</div>
<textarea
ref={textareaRef}
value={scriptText}
onChange={(e) => setScriptText(e.target.value)}
onKeyDown={onKeyDown}
onScroll={syncScroll}
spellCheck={false}
wrap="off"
aria-label="Visualization script editor"
className="min-h-[320px] flex-1 resize-none bg-transparent p-2 font-mono leading-5 text-zinc-200 caret-emerald-400 outline-none"
/>
</div>
{/* status line */}
<div
className={cn(
'flex items-start gap-2 font-mono text-[11px] leading-relaxed',
status.ok === false ? 'text-rose-400' : status.ok ? 'text-emerald-400' : 'text-zinc-600',
)}
role="status"
>
{status.ok === false ? <X className="mt-0.5 size-3.5 shrink-0" /> : status.ok ? <Check className="mt-0.5 size-3.5 shrink-0" /> : null}
<span className="break-all">{status.text}</span>
</div>
{/* help panel */}
{helpOpen && <HelpPanel />}
<input
ref={fileInputRef}
type="file"
accept=".ava,.avs,.txt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void loadFromFile(file)
e.target.value = ''
}}
/>
</div>
)
}