420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
/**
|
|
* 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<string, number>;
|
|
};
|
|
onClose: () => void;
|
|
onSave: (data: {
|
|
chipName: string;
|
|
sourceC: string;
|
|
chipJson: string;
|
|
wasmBase64: string;
|
|
attrs: Record<string, number>;
|
|
}) => 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<Tab>(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<ChipCompileResult | null>(null);
|
|
const [attrs, setAttrs] = useState<Record<string, number>>(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<string, ChipExample[]>();
|
|
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 (
|
|
<div style={overlayStyle} onClick={onClose}>
|
|
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
|
|
<div style={headerStyle}>
|
|
<strong style={{ flex: 1 }}>{t('editor.customChip.title', { chipName })}</strong>
|
|
<button style={closeBtn} onClick={onClose}>✕</button>
|
|
</div>
|
|
|
|
<div style={tabsRowStyle}>
|
|
<button
|
|
style={{ ...tabBtn, ...(tab === 'examples' ? tabActive : null) }}
|
|
onClick={() => setTab('examples')}
|
|
>
|
|
{t('editor.customChip.tabExamples')}
|
|
</button>
|
|
<button
|
|
style={{ ...tabBtn, ...(tab === 'editor' ? tabActive : null) }}
|
|
onClick={() => setTab('editor')}
|
|
>
|
|
{t('editor.customChip.tabEditor')}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={bodyStyle}>
|
|
{tab === 'examples' && (
|
|
<div style={{ overflow: 'auto', padding: 12 }}>
|
|
<button style={blankBtn} onClick={() => loadExample(BLANK_CHIP)}>
|
|
+ Start from blank
|
|
</button>
|
|
{grouped.map(([cat, list]) => (
|
|
<div key={cat} style={{ marginTop: 18 }}>
|
|
<div style={categoryStyle}>{cat.toUpperCase()}</div>
|
|
<div style={gridStyle}>
|
|
{list.map((ex) => (
|
|
<button
|
|
key={ex.id}
|
|
style={cardBtn}
|
|
onClick={() => loadExample(ex)}
|
|
title={ex.description}
|
|
>
|
|
<div style={cardName}>{ex.name}</div>
|
|
<div style={cardDesc}>{ex.description}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'editor' && (
|
|
<div style={editorLayout}>
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
<div style={editorLabel}>chip.c</div>
|
|
<div style={{ flex: 1 }}>
|
|
<Editor
|
|
height="100%"
|
|
language="cpp"
|
|
theme="vs-dark"
|
|
value={sourceC}
|
|
onChange={(v) => { setSourceC(v ?? ''); setWasmBase64(''); }}
|
|
options={{
|
|
minimap: { enabled: false },
|
|
fontSize: 12,
|
|
automaticLayout: true,
|
|
scrollBeyondLastLine: false,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ width: 360, display: 'flex', flexDirection: 'column' }}>
|
|
<div style={editorLabel}>chip.json</div>
|
|
<div style={{ flex: 1, minHeight: 200 }}>
|
|
<Editor
|
|
height="100%"
|
|
language="json"
|
|
theme="vs-dark"
|
|
value={chipJson}
|
|
onChange={(v) => setChipJson(v ?? '')}
|
|
options={{
|
|
minimap: { enabled: false },
|
|
fontSize: 12,
|
|
automaticLayout: true,
|
|
}}
|
|
/>
|
|
</div>
|
|
{attrDefs.length > 0 && (
|
|
<div style={attrPanelStyle}>
|
|
<div style={attrPanelHeader}>{t('editor.customChip.attributes')}</div>
|
|
{attrDefs.map((a) => (
|
|
<AttrRow
|
|
key={a.name}
|
|
def={a}
|
|
value={attrs[a.name] ?? a.default ?? 0}
|
|
onChange={(v) => setAttrs((prev) => ({ ...prev, [a.name]: v }))}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{tab === 'editor' && (
|
|
<div style={resultPanelStyle}>
|
|
{compiling && <span style={{ color: '#ffa500' }}>{t('editor.customChip.compiling')}</span>}
|
|
{!compiling && result && result.success && (
|
|
<span style={{ color: '#22c55e' }}>
|
|
{t('editor.customChip.compiledOk', { kb: (result.byte_size / 1024).toFixed(1) })}
|
|
</span>
|
|
)}
|
|
{!compiling && result && !result.success && (
|
|
<pre style={errorPreStyle}>{result.error || t('editor.customChip.compileFailed')}{'\n'}{result.stderr}</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={footerStyle}>
|
|
{tab === 'editor' && (
|
|
<button style={compileBtn} onClick={doCompile} disabled={compiling}>
|
|
{compiling ? t('editor.customChip.compiling') : t('editor.customChip.compile')}
|
|
</button>
|
|
)}
|
|
<div style={{ flex: 1 }} />
|
|
<button style={cancelBtn} onClick={onClose}>{t('editor.customChip.cancel')}</button>
|
|
<button style={canSave ? saveBtn : saveBtnDisabled} disabled={!canSave} onClick={doSave}>
|
|
{canSave ? t('editor.customChip.savePlace') : t('editor.customChip.compileFirst')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 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 (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
<label style={{ flex: '0 0 96px', fontSize: 11, color: '#bbb' }}>{def.label || def.name}</label>
|
|
{showSlider && (
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => {
|
|
const v = parseFloat(e.target.value);
|
|
onChange(isInt ? Math.round(v) : v);
|
|
}}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
)}
|
|
<input
|
|
type="number"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={Number.isFinite(value) ? value : 0}
|
|
onChange={(e) => {
|
|
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,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|