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:
Martino Ferrari
2026-06-24 11:18:00 +02:00
parent b82e8852b3
commit 603574f86f
3 changed files with 146 additions and 17 deletions
+6 -3
View File
@@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph, Folder } from './lib/types'; import type { Interface, Widget, PlotLayout, StateVar, LogicGraph, Folder } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml'; import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate'; import { initLocalState } from './lib/localstate';
import { ensureArrayDecls } from './lib/logic';
import SignalTree from './SignalTree'; import SignalTree from './SignalTree';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas'; import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas'; import PlotPanelCanvas from './PlotPanelCanvas';
@@ -251,9 +252,11 @@ export default function EditMode({ initial, onDone }: Props) {
}, []); }, []);
const handleStateVarsChange = useCallback((statevars: StateVar[]) => { const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
setIface(f => ({ ...f, statevars })); setIface(f => {
initLocalState(ensureArrayDecls(f.logic, statevars));
return { ...f, statevars };
});
setDirty(true); setDirty(true);
initLocalState(statevars);
}, []); }, []);
const handleLogicChange = useCallback((logic: LogicGraph) => { 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 // Instantiate local state variables when the edited panel loads/changes so
// they can be previewed live in the canvas just like real signals. // they can be previewed live in the canvas just like real signals.
useEffect(() => { useEffect(() => {
initLocalState(iface.statevars); initLocalState(ensureArrayDecls(iface.logic, iface.statevars ?? []));
}, [iface.id]); }, [iface.id]);
// ── Keyboard shortcuts ───────────────────────────────────────────────────── // ── Keyboard shortcuts ─────────────────────────────────────────────────────
+122 -5
View File
@@ -86,6 +86,11 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } }, { 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.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', 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> = { const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -114,6 +119,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.config.write': 'Write config', 'action.config.write': 'Write config',
'action.config.create': 'Create config', 'action.config.create': 'Create config',
'action.config.snapshot': 'Snapshot 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; const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -1055,6 +1065,50 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</div> </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' && ( {selected.kind === 'action.log' && (
<Fragment> <Fragment>
<div class="wizard-field"> <div class="wizard-field">
@@ -1404,6 +1458,11 @@ function nodeSummary(n: LogicNode): string {
return `export ${names || '?'} → csv`; 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.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.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`; 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 [open, setOpen] = useState(false);
const [name, setName] = useState(''); 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'); 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() { function add() {
const n = name.trim(); const n = name.trim();
if (!n) return; 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(''); setName('');
setInitial('0'); setInitial('0');
setType('number');
setElem('number');
setSizing('dynamic');
setCapacity('100');
setJsonErr('');
setOpen(false);
} }
return ( return (
@@ -1446,7 +1537,7 @@ function LocalVars({ statevars, onChange }: {
{statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>} {statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
{statevars.map(v => ( {statevars.map(v => (
<div key={v.name} class="flow-localvar-row"> <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" <button class="flow-node-del" title="Remove"
onClick={() => onChange(statevars.filter(x => x.name !== v.name))}></button> onClick={() => onChange(statevars.filter(x => x.name !== v.name))}></button>
</div> </div>
@@ -1459,15 +1550,41 @@ function LocalVars({ statevars, onChange }: {
<input class="prop-input" value={name} placeholder="name" <input class="prop-input" value={name} placeholder="name"
onInput={(e) => setName((e.target as HTMLInputElement).value)} /> onInput={(e) => setName((e.target as HTMLInputElement).value)} />
<select class="prop-select" value={type} <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="number">number</option>
<option value="bool">bool</option> <option value="bool">bool</option>
<option value="string">string</option> <option value="string">string</option>
<option value="array">array</option>
</select> </select>
{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" <input class="prop-input" value={initial} placeholder="initial"
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} /> onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
)}
<div class="flow-localvar-actions"> <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> <button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
</div> </div>
</div> </div>
+15 -6
View File
@@ -19,19 +19,28 @@ export interface NodeDebugState {
/** Per-node debug state, keyed by node id. */ /** Per-node debug state, keyed by node id. */
export type DebugSnapshot = Map<string, NodeDebugState>; 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. */ /** Round-trips a value into a compact label for the on-node badge. */
export function formatBadge(s: NodeDebugState | undefined): string { export function formatBadge(s: NodeDebugState | undefined): string {
if (!s) return ''; if (!s) return '';
if (s.error) return '!'; if (s.error) return '!';
const v = s.value; const v = s.value;
if (v == null) return '—'; 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 (typeof v === 'boolean') return v ? 'true' : 'false';
if (Array.isArray(v) || typeof v === 'number') return fmtBadge(v);
const str = String(v); const str = String(v);
return str.length > 10 ? str.slice(0, 9) + '…' : str; return str.length > 10 ? str.slice(0, 9) + '…' : str;
} }