feat(logic): array local declaration form + array nodes + array debug badges
- LocalVars: add array type option with elem/sizing/capacity sub-fields and JSON literal initial value with inline parse validation - Palette + KIND_LABEL: register action.array.push/set/remove/pop/clear - Inspector: array-node cases with array-local select, index and expr fields - nodeLabel: compact summaries for all five array node kinds - flowDebug: export fmtBadge() for compact array display [a,b,c,…](n=N); formatBadge now delegates to fmtBadge for numbers and arrays - EditMode: wrap both initLocalState calls with ensureArrayDecls so array locals referenced only by logic nodes are auto-declared in preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph, Folder } from './lib/types';
|
||||
import { serializeInterface, parseInterface } from './lib/xml';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import { ensureArrayDecls } from './lib/logic';
|
||||
import SignalTree from './SignalTree';
|
||||
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
|
||||
import PlotPanelCanvas from './PlotPanelCanvas';
|
||||
@@ -251,9 +252,11 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
}, []);
|
||||
|
||||
const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
|
||||
setIface(f => ({ ...f, statevars }));
|
||||
setIface(f => {
|
||||
initLocalState(ensureArrayDecls(f.logic, statevars));
|
||||
return { ...f, statevars };
|
||||
});
|
||||
setDirty(true);
|
||||
initLocalState(statevars);
|
||||
}, []);
|
||||
|
||||
const handleLogicChange = useCallback((logic: LogicGraph) => {
|
||||
@@ -264,7 +267,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
// Instantiate local state variables when the edited panel loads/changes so
|
||||
// they can be previewed live in the canvas just like real signals.
|
||||
useEffect(() => {
|
||||
initLocalState(iface.statevars);
|
||||
initLocalState(ensureArrayDecls(iface.logic, iface.statevars ?? []));
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
+125
-8
@@ -86,6 +86,11 @@ const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } },
|
||||
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
|
||||
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', target: '' } },
|
||||
{ kind: 'action.array.push', label: 'Array push', params: { array: '', expr: '' } },
|
||||
{ kind: 'action.array.set', label: 'Array set', params: { array: '', index: '', expr: '' } },
|
||||
{ kind: 'action.array.remove', label: 'Array remove', params: { array: '', index: '' } },
|
||||
{ kind: 'action.array.pop', label: 'Array pop', params: { array: '' } },
|
||||
{ kind: 'action.array.clear', label: 'Array clear', params: { array: '' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
@@ -114,6 +119,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'action.config.write': 'Write config',
|
||||
'action.config.create': 'Create config',
|
||||
'action.config.snapshot': 'Snapshot config',
|
||||
'action.array.push': 'Array push',
|
||||
'action.array.set': 'Array set',
|
||||
'action.array.remove': 'Array remove',
|
||||
'action.array.pop': 'Array pop',
|
||||
'action.array.clear': 'Array clear',
|
||||
};
|
||||
|
||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||
@@ -1055,6 +1065,50 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set' ||
|
||||
selected.kind === 'action.array.remove' || selected.kind === 'action.array.pop' ||
|
||||
selected.kind === 'action.array.clear') && (() => {
|
||||
const arrayLocals = (statevars ?? []).filter(v => v.type === 'array');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Array (local variable)</label>
|
||||
<select class="prop-select" value={selected.params.array ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { array: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">— select array —</option>
|
||||
{arrayLocals.map(v => <option key={v.name} value={v.name}>{v.name}</option>)}
|
||||
</select>
|
||||
{arrayLocals.length === 0 && (
|
||||
<p class="hint">No array locals declared yet — add one in the Local vars panel.</p>
|
||||
)}
|
||||
</div>
|
||||
{(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && (
|
||||
<div class="wizard-field">
|
||||
<label>Index / path (comma-separated)</label>
|
||||
<input class="prop-input" value={selected.params.index ?? ''}
|
||||
placeholder="e.g. 0 or 2,1"
|
||||
onInput={(e) => patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Single integer for a flat array; comma-separated integers for a nested array.</p>
|
||||
</div>
|
||||
)}
|
||||
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && (
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint={selected.kind === 'action.array.push'
|
||||
? 'Value to append to the end of the array.'
|
||||
: 'Value to store at the given index / path.'} />
|
||||
)}
|
||||
{(selected.kind === 'action.array.pop' || selected.kind === 'action.array.clear') && (
|
||||
<p class="hint">{selected.kind === 'action.array.pop'
|
||||
? 'Removes and returns the last element of the array.'
|
||||
: 'Removes all elements from the array.'}</p>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'action.log' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
@@ -1403,7 +1457,12 @@ function nodeSummary(n: LogicNode): string {
|
||||
const names = cols.map(c => c.label || c.array).join(', ');
|
||||
return `export ${names || '?'} → csv`;
|
||||
}
|
||||
case 'action.clear': return `clear ${n.params.array || '?'}`;
|
||||
case 'action.clear': return `clear ${n.params.array || '?'}`;
|
||||
case 'action.array.push': return `push ${n.params.expr || ''} → ${n.params.array || '?'}`;
|
||||
case 'action.array.set': return `${n.params.array || '?'}[${n.params.index || '?'}] = ${n.params.expr || ''}`;
|
||||
case 'action.array.remove': return `remove ${n.params.array || '?'}[${n.params.index || '?'}]`;
|
||||
case 'action.array.pop': return `pop ${n.params.array || '?'}`;
|
||||
case 'action.array.clear': return `clear ${n.params.array || '?'}`;
|
||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||
case 'action.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
|
||||
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
|
||||
@@ -1429,15 +1488,47 @@ function LocalVars({ statevars, onChange }: {
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'number' | 'bool' | 'string'>('number');
|
||||
const [type, setType] = useState<'number' | 'bool' | 'string' | 'array'>('number');
|
||||
const [initial, setInitial] = useState('0');
|
||||
// array-specific fields
|
||||
const [elem, setElem] = useState<'number' | 'bool' | 'array'>('number');
|
||||
const [sizing, setSizing] = useState<'dynamic' | 'capped' | 'fixed'>('dynamic');
|
||||
const [capacity, setCapacity] = useState('100');
|
||||
const [jsonErr, setJsonErr] = useState('');
|
||||
|
||||
function handleInitialChange(v: string) {
|
||||
setInitial(v);
|
||||
if (type === 'array') {
|
||||
try { JSON.parse(v); setJsonErr(''); }
|
||||
catch (e: any) { setJsonErr(e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
function handleTypeChange(t: 'number' | 'bool' | 'string' | 'array') {
|
||||
setType(t);
|
||||
setInitial(t === 'array' ? '[]' : t === 'bool' ? 'false' : t === 'string' ? '' : '0');
|
||||
setJsonErr('');
|
||||
}
|
||||
|
||||
function add() {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
onChange([...statevars.filter(v => v.name !== n), { name: n, type, initial }]);
|
||||
if (type === 'array' && jsonErr) return;
|
||||
const base: StateVar = { name: n, type, initial };
|
||||
if (type === 'array') {
|
||||
base.elem = elem;
|
||||
base.sizing = sizing;
|
||||
if (sizing !== 'dynamic') base.capacity = parseInt(capacity, 10) || 100;
|
||||
}
|
||||
onChange([...statevars.filter(v => v.name !== n), base]);
|
||||
setName('');
|
||||
setInitial('0');
|
||||
setType('number');
|
||||
setElem('number');
|
||||
setSizing('dynamic');
|
||||
setCapacity('100');
|
||||
setJsonErr('');
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1446,7 +1537,7 @@ function LocalVars({ statevars, onChange }: {
|
||||
{statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
|
||||
{statevars.map(v => (
|
||||
<div key={v.name} class="flow-localvar-row">
|
||||
<span class="flow-localvar-name" title={`${v.type ?? 'number'} = ${v.initial}`}>{v.name}</span>
|
||||
<span class="flow-localvar-name" title={`${v.type ?? 'number'}${v.type === 'array' ? `<${v.elem ?? 'number'}> ${v.sizing ?? 'dynamic'}` : ''} = ${v.initial}`}>{v.name}</span>
|
||||
<button class="flow-node-del" title="Remove"
|
||||
onClick={() => onChange(statevars.filter(x => x.name !== v.name))}>✕</button>
|
||||
</div>
|
||||
@@ -1459,15 +1550,41 @@ function LocalVars({ statevars, onChange }: {
|
||||
<input class="prop-input" value={name} placeholder="name"
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
<select class="prop-select" value={type}
|
||||
onChange={(e) => setType((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string')}>
|
||||
onChange={(e) => handleTypeChange((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string' | 'array')}>
|
||||
<option value="number">number</option>
|
||||
<option value="bool">bool</option>
|
||||
<option value="string">string</option>
|
||||
<option value="array">array</option>
|
||||
</select>
|
||||
<input class="prop-input" value={initial} placeholder="initial"
|
||||
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
|
||||
{type === 'array' && (
|
||||
<Fragment>
|
||||
<select class="prop-select" value={elem}
|
||||
onChange={(e) => setElem((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'array')}>
|
||||
<option value="number">elem: number</option>
|
||||
<option value="bool">elem: bool</option>
|
||||
<option value="array">elem: array</option>
|
||||
</select>
|
||||
<select class="prop-select" value={sizing}
|
||||
onChange={(e) => setSizing((e.target as HTMLSelectElement).value as 'dynamic' | 'capped' | 'fixed')}>
|
||||
<option value="dynamic">sizing: dynamic</option>
|
||||
<option value="capped">sizing: capped</option>
|
||||
<option value="fixed">sizing: fixed</option>
|
||||
</select>
|
||||
{sizing !== 'dynamic' && (
|
||||
<input class="prop-input" type="number" value={capacity} placeholder="capacity"
|
||||
onInput={(e) => setCapacity((e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
<input class={`prop-input${jsonErr ? ' prop-input-error' : ''}`} value={initial} placeholder="initial (JSON)"
|
||||
onInput={(e) => handleInitialChange((e.target as HTMLInputElement).value)} />
|
||||
{jsonErr && <p class="wizard-error">{jsonErr}</p>}
|
||||
</Fragment>
|
||||
)}
|
||||
{type !== 'array' && (
|
||||
<input class="prop-input" value={initial} placeholder="initial"
|
||||
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
<div class="flow-localvar-actions">
|
||||
<button class="panel-btn" onClick={add}>Add</button>
|
||||
<button class="panel-btn" onClick={add} disabled={!name.trim() || (type === 'array' && !!jsonErr)}>Add</button>
|
||||
<button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,19 +19,28 @@ export interface NodeDebugState {
|
||||
/** Per-node debug state, keyed by node id. */
|
||||
export type DebugSnapshot = Map<string, NodeDebugState>;
|
||||
|
||||
/** Compact string for a raw value — used in badges and tooltips. */
|
||||
export function fmtBadge(v: unknown): string {
|
||||
if (Array.isArray(v)) {
|
||||
const head = v.slice(0, 3).map(x => Array.isArray(x) ? '[…]' : String(x)).join(', ');
|
||||
return `[${head}${v.length > 3 ? ', …' : ''}](n=${v.length})`;
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
if (!isFinite(v)) return String(v);
|
||||
if (Number.isInteger(v)) return String(v);
|
||||
return String(+v.toFixed(4));
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** Round-trips a value into a compact label for the on-node badge. */
|
||||
export function formatBadge(s: NodeDebugState | undefined): string {
|
||||
if (!s) return '';
|
||||
if (s.error) return '!';
|
||||
const v = s.value;
|
||||
if (v == null) return '—';
|
||||
if (Array.isArray(v)) return `[${v.length}]`;
|
||||
if (typeof v === 'number') {
|
||||
if (!isFinite(v)) return String(v);
|
||||
if (Number.isInteger(v)) return String(v);
|
||||
return v.toPrecision(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||||
if (Array.isArray(v) || typeof v === 'number') return fmtBadge(v);
|
||||
const str = String(v);
|
||||
return str.length > 10 ? str.slice(0, 9) + '…' : str;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user