/** * CustomChipDialog — modal for editing/compiling a Velxio custom chip. * * Two tabs: * - "Examples": gallery of pre-built chips. Click → loads the source. * - "Editor": Monaco editors for chip.c and chip.json + Compile button + * stdout/stderr panel + Save button. * * The Save action persists the compiled WASM (base64) into the component's * `properties` so the chip is fully self-contained inside the project. */ import { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import Editor from '@monaco-editor/react'; import { CHIP_EXAMPLES, BLANK_CHIP, type ChipExample } from './chipExamples'; import { compileChip, type ChipCompileResult } from '../../services/chipCompileService'; export interface CustomChipDialogProps { /** Initial chip data (from component.properties). */ initial: { chipName: string; sourceC: string; chipJson: string; wasmBase64: string; attrs?: Record; }; onClose: () => void; onSave: (data: { chipName: string; sourceC: string; chipJson: string; wasmBase64: string; attrs: Record; }) => void; } interface AttributeDef { name: string; label?: string; type?: 'int' | 'float' | 'number'; default?: number; min?: number; max?: number; step?: number; } function parseAttributes(chipJson: string): AttributeDef[] { try { const obj = JSON.parse(chipJson); if (Array.isArray(obj.attributes)) { return obj.attributes.map((a: any): AttributeDef => ({ name: String(a.name ?? ''), label: typeof a.label === 'string' ? a.label : undefined, type: a.type === 'float' || a.type === 'int' ? a.type : 'number', default: typeof a.default === 'number' ? a.default : undefined, min: typeof a.min === 'number' ? a.min : undefined, max: typeof a.max === 'number' ? a.max : undefined, step: typeof a.step === 'number' ? a.step : undefined, })).filter((a: AttributeDef) => a.name); } } catch { /* ignore */ } return []; } type Tab = 'examples' | 'editor'; export const CustomChipDialog = ({ initial, onClose, onSave }: CustomChipDialogProps) => { const { t } = useTranslation(); // If the chip already has source code, skip the examples tab on open. const [tab, setTab] = useState(initial.sourceC.trim() ? 'editor' : 'examples'); const [chipName, setChipName] = useState(initial.chipName || 'My Chip'); const [sourceC, setSourceC] = useState(initial.sourceC || BLANK_CHIP.sourceC); const [chipJson, setChipJson] = useState(initial.chipJson || BLANK_CHIP.chipJson); const [wasmBase64, setWasmBase64] = useState(initial.wasmBase64 || ''); const [compiling, setCompiling] = useState(false); const [result, setResult] = useState(null); const [attrs, setAttrs] = useState>(initial.attrs ?? {}); const attrDefs = useMemo(() => parseAttributes(chipJson), [chipJson]); // When attribute defs change (e.g. user loaded a new example), seed defaults // for any newly-introduced attribute that doesn't have a saved value yet. useEffect(() => { setAttrs((prev) => { const next = { ...prev }; let changed = false; for (const a of attrDefs) { if (!(a.name in next) && a.default !== undefined) { next[a.name] = a.default; changed = true; } } return changed ? next : prev; }); }, [attrDefs]); // Categories shown as section headers in the gallery. const grouped = useMemo(() => { const m = new Map(); for (const e of CHIP_EXAMPLES) { if (!m.has(e.category)) m.set(e.category, []); m.get(e.category)!.push(e); } return Array.from(m.entries()); }, []); // Pull chipName out of chip.json whenever it changes — keeps a single source // of truth without forcing the user to edit two fields. useEffect(() => { try { const obj = JSON.parse(chipJson); if (obj && typeof obj.name === 'string' && obj.name.trim()) { setChipName(obj.name); } } catch { /* user is mid-edit, ignore */ } }, [chipJson]); const loadExample = (e: ChipExample) => { setSourceC(e.sourceC); setChipJson(e.chipJson); setWasmBase64(''); // force re-compile so the binary matches the loaded source setResult(null); setTab('editor'); }; const doCompile = async () => { setCompiling(true); setResult(null); try { const r = await compileChip(sourceC, chipJson); setResult(r); if (r.success && r.wasm_base64) setWasmBase64(r.wasm_base64); } finally { setCompiling(false); } }; const canSave = wasmBase64.length > 0; const doSave = () => { if (!canSave) return; onSave({ chipName, sourceC, chipJson, wasmBase64, attrs }); }; return (
e.stopPropagation()}>
{t('editor.customChip.title', { chipName })}
{tab === 'examples' && (
{grouped.map(([cat, list]) => (
{cat.toUpperCase()}
{list.map((ex) => ( ))}
))}
)} {tab === 'editor' && (
chip.c
{ setSourceC(v ?? ''); setWasmBase64(''); }} options={{ minimap: { enabled: false }, fontSize: 12, automaticLayout: true, scrollBeyondLastLine: false, }} />
chip.json
setChipJson(v ?? '')} options={{ minimap: { enabled: false }, fontSize: 12, automaticLayout: true, }} />
{attrDefs.length > 0 && (
{t('editor.customChip.attributes')}
{attrDefs.map((a) => ( setAttrs((prev) => ({ ...prev, [a.name]: v }))} /> ))}
)}
)}
{tab === 'editor' && (
{compiling && {t('editor.customChip.compiling')}} {!compiling && result && result.success && ( {t('editor.customChip.compiledOk', { kb: (result.byte_size / 1024).toFixed(1) })} )} {!compiling && result && !result.success && (
{result.error || t('editor.customChip.compileFailed')}{'\n'}{result.stderr}
)}
)}
{tab === 'editor' && ( )}
); }; // ── Inline styles (matches the visual language of other Velxio modals) ── const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', }; const dialogStyle: React.CSSProperties = { width: '90vw', height: '85vh', maxWidth: 1280, background: '#1f1f1f', color: '#e0e0e0', borderRadius: 6, display: 'flex', flexDirection: 'column', boxShadow: '0 12px 36px rgba(0,0,0,0.5)', }; const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid #333', background: '#252526', }; const closeBtn: React.CSSProperties = { background: 'transparent', border: 'none', color: '#999', fontSize: 18, cursor: 'pointer', padding: '2px 8px', }; const tabsRowStyle: React.CSSProperties = { display: 'flex', gap: 4, padding: '6px 12px 0', borderBottom: '1px solid #333', background: '#252526', }; const tabBtn: React.CSSProperties = { padding: '6px 14px', background: 'transparent', color: '#999', border: 'none', borderBottom: '2px solid transparent', cursor: 'pointer', fontSize: 13, }; const tabActive: React.CSSProperties = { color: '#e0e0e0', borderBottom: '2px solid #007acc' }; const bodyStyle: React.CSSProperties = { flex: 1, overflow: 'hidden', display: 'flex' }; const editorLayout: React.CSSProperties = { display: 'flex', flex: 1, gap: 8, padding: 8, overflow: 'hidden', }; const editorLabel: React.CSSProperties = { padding: '4px 8px', fontSize: 11, color: '#888', background: '#252526', borderTopLeftRadius: 4, borderTopRightRadius: 4, }; const resultPanelStyle: React.CSSProperties = { borderTop: '1px solid #333', padding: '6px 14px', fontSize: 12, fontFamily: 'monospace', maxHeight: 120, overflow: 'auto', }; const errorPreStyle: React.CSSProperties = { margin: 0, color: '#f87171', whiteSpace: 'pre-wrap', fontSize: 11, }; const footerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', borderTop: '1px solid #333', background: '#252526', }; const compileBtn: React.CSSProperties = { padding: '6px 14px', background: '#007acc', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 13, }; const cancelBtn: React.CSSProperties = { padding: '6px 14px', background: '#3a3a3a', color: '#e0e0e0', border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 13, }; const saveBtn: React.CSSProperties = { padding: '6px 14px', background: '#22c55e', color: 'white', border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 13, }; const saveBtnDisabled: React.CSSProperties = { ...saveBtn, background: '#3a3a3a', cursor: 'not-allowed' }; const blankBtn: React.CSSProperties = { padding: '8px 14px', background: '#2d2d30', color: '#e0e0e0', border: '1px dashed #555', borderRadius: 4, cursor: 'pointer', width: '100%', textAlign: 'left', fontSize: 13, }; const categoryStyle: React.CSSProperties = { fontSize: 10, color: '#888', letterSpacing: 1, marginBottom: 6, }; const gridStyle: React.CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 8, }; const cardBtn: React.CSSProperties = { padding: '10px 12px', background: '#2d2d30', color: '#e0e0e0', border: '1px solid #3a3a3a', borderRadius: 4, cursor: 'pointer', textAlign: 'left', }; const cardName: React.CSSProperties = { fontSize: 13, fontWeight: 'bold', marginBottom: 4 }; const cardDesc: React.CSSProperties = { fontSize: 11, color: '#999', lineHeight: 1.4 }; const attrPanelStyle: React.CSSProperties = { marginTop: 8, padding: '8px 10px', background: '#252526', borderRadius: 4, maxHeight: 220, overflow: 'auto', }; const attrPanelHeader: React.CSSProperties = { fontSize: 10, color: '#888', letterSpacing: 1, marginBottom: 6, textTransform: 'uppercase', }; function AttrRow({ def, value, onChange, }: { def: AttributeDef; value: number; onChange: (v: number) => void; }) { const isInt = def.type === 'int'; const min = def.min; const max = def.max; const step = def.step ?? (isInt ? 1 : 0.01); const showSlider = min !== undefined && max !== undefined; return (
{showSlider && ( { const v = parseFloat(e.target.value); onChange(isInt ? Math.round(v) : v); }} style={{ flex: 1 }} /> )} { const v = parseFloat(e.target.value); if (!Number.isFinite(v)) return; onChange(isInt ? Math.round(v) : v); }} style={{ width: 76, padding: '2px 4px', fontSize: 11, background: '#1f1f1f', color: '#e0e0e0', border: '1px solid #444', borderRadius: 2, }} />
); }