From 603574f86f65d58ecc2bc0d4e6d6b5aa4d4996c6 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 24 Jun 2026 11:18:00 +0200 Subject: [PATCH] feat(logic): array local declaration form + array nodes + array debug badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/EditMode.tsx | 9 ++- web/src/LogicEditor.tsx | 133 ++++++++++++++++++++++++++++++++++++--- web/src/lib/flowDebug.ts | 21 +++++-- 3 files changed, 146 insertions(+), 17 deletions(-) diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx index 45a9f97..0a721c1 100644 --- a/web/src/EditMode.tsx +++ b/web/src/EditMode.tsx @@ -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 ───────────────────────────────────────────────────── diff --git a/web/src/LogicEditor.tsx b/web/src/LogicEditor.tsx index 9083a94..25478d2 100644 --- a/web/src/LogicEditor.tsx +++ b/web/src/LogicEditor.tsx @@ -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 = { @@ -114,6 +119,11 @@ const KIND_LABEL: Record = { '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, )} + {(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 ( + +
+ + + {arrayLocals.length === 0 && ( +

No array locals declared yet — add one in the Local vars panel.

+ )} +
+ {(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && ( +
+ + patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} /> +

Single integer for a flat array; comma-separated integers for a nested array.

+
+ )} + {(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && ( + 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') && ( +

{selected.kind === 'action.array.pop' + ? 'Removes and returns the last element of the array.' + : 'Removes all elements from the array.'}

+ )} +
+ ); + })()} + {selected.kind === 'action.log' && (
@@ -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 &&
None yet.
} {statevars.map(v => (
- {v.name} + ${v.sizing ?? 'dynamic'}` : ''} = ${v.initial}`}>{v.name}
@@ -1459,15 +1550,41 @@ function LocalVars({ statevars, onChange }: { setName((e.target as HTMLInputElement).value)} /> - setInitial((e.target as HTMLInputElement).value)} /> + {type === 'array' && ( + + + + {sizing !== 'dynamic' && ( + setCapacity((e.target as HTMLInputElement).value)} /> + )} + handleInitialChange((e.target as HTMLInputElement).value)} /> + {jsonErr &&

{jsonErr}

} +
+ )} + {type !== 'array' && ( + setInitial((e.target as HTMLInputElement).value)} /> + )}
- +
diff --git a/web/src/lib/flowDebug.ts b/web/src/lib/flowDebug.ts index 50b9ed3..f1007ff 100644 --- a/web/src/lib/flowDebug.ts +++ b/web/src/lib/flowDebug.ts @@ -19,19 +19,28 @@ export interface NodeDebugState { /** Per-node debug state, keyed by node id. */ export type DebugSnapshot = Map; +/** 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; }