// 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(null) const gutterRef = useRef(null) const fileInputRef = useRef(null) const [validation, setValidation] = useState | 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 = { 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) => { 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) => { 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 (
{/* toolbar */}
{/* editor with gutter */}
{Array.from({ length: lineCount }, (_, i) => (
{i + 1}
))}