268 lines
9.4 KiB
TypeScript
Executable file
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>
|
|
)
|
|
}
|