From 7e8de97e15516577d6dcae0bb8e69f3bdf91dfab Mon Sep 17 00:00:00 2001 From: Matthew Reed Date: Fri, 28 Aug 2026 20:36:55 +1200 Subject: [PATCH] =?UTF-8?q?feat:=20Execute=20element=20=E2=80=94=20inline?= =?UTF-8?q?=20Structured=20Text=20in=20Ladder=20and=20FBD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Execute" (ST Block) element that holds a raw ST snippet inside an LD rung or an FBD canvas, edited in place or in an expand modal, with live strucpp diagnostics and inline debug value badges. Emission is EN-gated with ENO passthrough, and PLCopen import/export follows what CODESYS writes: carrying its source in an . Also fixes, found while testing: - CODESYS LD export threw on a contact or coil fed from a block-shaped source with no variant - imported rungs reached the editor grouped by XML element type, so inserting an element wired it to the wrong predecessor - imported rails were named so the rung layout could not find them - imported rungs kept the exporter's cumulative Y offset - "Export to CODESYS XML" produced old-editor XML - the debugger's range selector painted through open modals --- .../__tests__/execute-element.test.ts | 293 ++++++++++++++++ .../st-transpiler/walker/README.md | 16 + .../st-transpiler/walker/connection-types.ts | 3 + .../transpilers/st-transpiler/walker/ld.ts | 91 +++++ .../st-transpiler/walker/narrow.ts | 21 ++ .../transpilers/st-transpiler/walker/types.ts | 2 +- .../assets/icons/project/ladder/Execute.tsx | 50 +++ .../graphical-editor/fbd/buildNodes.tsx | 72 ++++ .../_atoms/graphical-editor/fbd/execute.tsx | 142 ++++++++ .../_atoms/graphical-editor/fbd/index.ts | 3 + .../graphical-editor/fbd/utils/constants.tsx | 11 + .../graphical-editor/fbd/utils/types.ts | 12 + .../graphical-editor/ladder/buildNodes.tsx | 76 +++++ .../graphical-editor/ladder/execute.tsx | 165 +++++++++ .../_atoms/graphical-editor/ladder/index.ts | 2 + .../graphical-editor/ladder/node-builders.ts | 14 + .../ladder/utils/constants.tsx | 25 ++ .../graphical-editor/ladder/utils/types.ts | 12 + .../graphical-editor/st-code-field/index.tsx | 298 +++++++++++++++++ .../graphical/elements/fbd/execute/index.tsx | 86 +++++ .../elements/ladder/execute/index.tsx | 101 ++++++ .../editor/graphical/ladder/index.tsx | 12 + .../[workspace]/editor/monaco/index.tsx | 159 ++------- .../graphical-editor/fbd/fbd-utils/nodes.ts | 5 + .../_molecules/graphical-editor/fbd/index.tsx | 32 +- .../rung/__tests__/execute-power-flow.test.ts | 76 +++++ .../graphical-editor/ladder/rung/body.tsx | 187 +---------- .../rung/ladder-utils/debug-power-flow.ts | 190 +++++++++++ .../rung/ladder-utils/elements/index.ts | 8 +- .../ladder/rung/ladder-utils/nodes.ts | 8 + .../workspace-activity-bar/fbd/execute.tsx | 14 + .../workspace-activity-bar/ladder/execute.tsx | 14 + .../components/_organisms/debugger/index.tsx | 6 +- .../workspace-activity-bar/fbd-toolbox.tsx | 4 + .../workspace-activity-bar/ladder-toolbox.tsx | 4 + .../_templates/accelerator-handler.tsx | 18 +- .../hooks/use-st-debug-decorations.ts | 192 +++++++++++ .../st-lsp/__tests__/execute-sync.test.ts | 214 ++++++++++++ src/frontend/services/st-lsp/boot.ts | 7 + src/frontend/services/st-lsp/execute-sync.ts | 214 ++++++++++++ src/frontend/services/st-lsp/types.ts | 12 +- .../store/__tests__/fbd-types.test.ts | 1 + .../store/__tests__/ladder-types.test.ts | 2 +- .../store/__tests__/modal-slice.test.ts | 2 + src/frontend/store/slices/fbd/types.ts | 1 + src/frontend/store/slices/ladder/types.ts | 2 +- src/frontend/store/slices/modal/slice.ts | 2 + src/frontend/store/slices/modal/types.ts | 7 + .../PLC/__tests__/execute-st-uri.test.ts | 57 ++++ .../pou-signature-serializer.test.ts | 50 +++ src/frontend/utils/PLC/execute-plcopen.ts | 134 ++++++++ src/frontend/utils/PLC/execute-st-uri.ts | 46 +++ .../utils/PLC/pou-signature-serializer.ts | 30 ++ .../language/__tests__/fbd-execute.test.ts | 68 ++++ .../language/__tests__/ladder-execute.test.ts | 89 +++++ .../xml-generator/codesys/language/fbd-xml.ts | 97 +++++- .../codesys/language/ladder-xml.ts | 105 +++++- .../language/__tests__/fbd-execute.test.ts | 106 ++++++ .../language/__tests__/ladder-execute.test.ts | 116 +++++++ .../old-editor/language/fbd-xml.ts | 87 ++++- .../old-editor/language/ladder-xml.ts | 76 +++++ .../__tests__/execute-plcopen.test.ts | 298 +++++++++++++++++ .../__tests__/fixtures/codesys-execute.xml | 306 +++++++++++++++++ .../__tests__/fixtures/openplc-execute.xml | 315 ++++++++++++++++++ .../__tests__/parse-plcopen-xml.test.ts | 2 +- src/frontend/utils/PLC/xml-parser/index.ts | 7 +- .../language/__tests__/fbd-xml.test.ts | 29 ++ .../language/__tests__/ladder-xml.test.ts | 221 +++++++++++- .../utils/PLC/xml-parser/language/fbd-xml.ts | 147 +++++++- .../utils/PLC/xml-parser/language/geometry.ts | 9 + .../PLC/xml-parser/language/ladder-xml.ts | 278 +++++++++++++++- .../PLC/xml-parser/parse-xml-document.ts | 80 +++++ src/frontend/utils/PLC/xml-parser/pou-xml.ts | 10 +- .../__tests__/debug-polling-filter.test.ts | 108 ++++++ src/frontend/utils/debug-polling-filter.ts | 47 ++- src/main/menu.ts | 12 + src/main/modules/ipc/renderer.ts | 1 + .../__tests__/accelerator-adapter.test.ts | 48 ++- .../adapters/editor/accelerator-adapter.ts | 10 +- .../shared/ports/accelerator-port.ts | 15 +- 80 files changed, 5492 insertions(+), 400 deletions(-) create mode 100644 src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts create mode 100644 src/frontend/assets/icons/project/ladder/Execute.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx create mode 100644 src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/graphical/elements/fbd/execute/index.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/execute/index.tsx create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/__tests__/execute-power-flow.test.ts create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/debug-power-flow.ts create mode 100644 src/frontend/components/_molecules/workspace-activity-bar/fbd/execute.tsx create mode 100644 src/frontend/components/_molecules/workspace-activity-bar/ladder/execute.tsx create mode 100644 src/frontend/hooks/use-st-debug-decorations.ts create mode 100644 src/frontend/services/st-lsp/__tests__/execute-sync.test.ts create mode 100644 src/frontend/services/st-lsp/execute-sync.ts create mode 100644 src/frontend/utils/PLC/__tests__/execute-st-uri.test.ts create mode 100644 src/frontend/utils/PLC/execute-plcopen.ts create mode 100644 src/frontend/utils/PLC/execute-st-uri.ts create mode 100644 src/frontend/utils/PLC/xml-generator/codesys/language/__tests__/fbd-execute.test.ts create mode 100644 src/frontend/utils/PLC/xml-generator/codesys/language/__tests__/ladder-execute.test.ts create mode 100644 src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/fbd-execute.test.ts create mode 100644 src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-execute.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/execute-plcopen.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/fixtures/codesys-execute.xml create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/fixtures/openplc-execute.xml diff --git a/src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts b/src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts new file mode 100644 index 000000000..62cf8c6c0 --- /dev/null +++ b/src/backend/shared/transpilers/st-transpiler/__tests__/execute-element.test.ts @@ -0,0 +1,293 @@ +import { emitFbdBody } from '@root/backend/shared/transpilers/st-transpiler/walker/fbd' +import { emitLdBody } from '@root/backend/shared/transpilers/st-transpiler/walker/ld' +import type { RFBody, RFEdge, RFNode, RFRung } from '@root/backend/shared/transpilers/st-transpiler/walker/types' + +// Coverage for the Execute ("ST Block") element — a graphical box holding a +// raw ST snippet, gated by whatever rung condition reaches its EN input and +// passing power straight through on ENO. +// +// Semantics are pinned against a real CODESYS V3.5 SP22 PLCopen export +// (`` + a `.../plcopenxml/stcode` addData). The +// decisive detail there: a coil downstream of an EXECUTE block references the +// block's localId with NO `formalParameter` qualifier — plain rung +// continuation — so `contact -> EXECUTE -> coil` yields `coil := contact`. + +let edgeId = 0 +const e = (source: string, target: string): RFEdge => ({ id: `e${edgeId++}`, source, target }) + +const rail = (id: string, variant: 'left' | 'right', x: number): RFNode => ({ + id, + type: 'powerRail', + position: { x, y: 30 }, + data: { variant }, +}) +const contact = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'contact', + position: { x, y: 38 }, + data: { variant: 'default', variable: { name } }, +}) +const coil = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'coil', + position: { x, y: 38 }, + data: { variant: 'default', variable: { name }, executionOrder: 0 }, +}) +const execute = (id: string, code: string, x: number, executionOrder = 0): RFNode => ({ + id, + type: 'execute', + position: { x, y: 38 }, + data: { code, executionOrder }, +}) +const inVar = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'input-variable', + position: { x, y: 38 }, + data: { variant: 'input-variable', variable: { name } }, +}) +const outVar = (id: string, name: string, x: number): RFNode => ({ + id, + type: 'output-variable', + position: { x, y: 38 }, + data: { variant: 'output-variable', variable: { name }, executionOrder: 0 }, +}) + +const ldBody = (nodes: RFNode[], edges: RFEdge[]): RFBody => ({ + rungs: [{ reactFlowViewport: [800, 200], nodes, edges }], +}) +const fbdRung = (nodes: RFNode[], edges: RFEdge[]): RFRung => ({ nodes, edges }) + +beforeEach(() => { + edgeId = 0 +}) + +describe('Execute element — rung gating', () => { + it('gates the snippet on the rung condition and passes power through to the coil', () => { + // The exact topology from the CODESYS export: + // leftPowerRail -> contact(myContact) -> EXECUTE -> coil(myCoil) + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'myContact', 68), + execute('X', '// Comment in ST Block\nmyNewValue := myValue + 10;\n', 200), + coil('K', 'myCoil', 400), + rail('R', 'right', 600), + ], + [e('L', 'C'), e('C', 'X'), e('X', 'K'), e('K', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + ' IF myContact THEN\n' + + ' // Comment in ST Block\n' + + ' myNewValue := myValue + 10;\n' + + ' END_IF;\n' + + ' myCoil := myContact;\n', + ) + }) + + it('emits the snippet bare when the box sits directly on the left rail', () => { + // A trivially-true condition would only produce `IF TRUE THEN`, which is + // noise. The body runs every scan either way. + const body = ldBody( + [rail('L', 'left', 0), execute('X', 'counter := counter + 1;', 200), rail('R', 'right', 400)], + [e('L', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + expect(bodySt).toBe('\n counter := counter + 1;\n') + }) + + it('emits the snippet bare in FBD when EN is left unwired', () => { + const { bodySt, warnings } = emitFbdBody({ + rung: fbdRung([execute('X', 'myNewVarFBD := myNewVarFBD + 222;', 0)], []), + }) + + expect(warnings).toEqual([]) + expect(bodySt).toBe('\n myNewVarFBD := myNewVarFBD + 222;\n') + }) + + it('combines a multi-contact rung condition into one IF', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C1', 'a', 68), + contact('C2', 'b', 140), + execute('X', 'total := total + 1;', 260), + rail('R', 'right', 500), + ], + [e('L', 'C1'), e('C1', 'C2'), e('C2', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + // `b AND a`, not `a AND b` — `visitContact` emits each contact's own + // variable ahead of its upstream chain, so the term nearest the sink leads. + // That is the walker's established convention (it matches the python + // oracle) and is unchanged by the Execute element; asserted here only to + // pin that Execute consumes the condition like any other sink. + expect(bodySt).toBe('\n IF b AND a THEN\n total := total + 1;\n END_IF;\n') + }) +}) + +describe('Execute element — snippet re-indentation', () => { + it('preserves relative nesting and blank lines inside the generated IF', () => { + const code = ['IF a < 10 THEN', ' b := 1;', '', ' c := 2;', 'END_IF;'].join('\n') + const body = ldBody( + [rail('L', 'left', 0), contact('C', 'gate', 68), execute('X', code, 200), rail('R', 'right', 500)], + [e('L', 'C'), e('C', 'X'), e('X', 'R')], + ) + + const { bodySt } = emitLdBody(body) + + expect(bodySt).toBe( + '\n' + + ' IF gate THEN\n' + + ' IF a < 10 THEN\n' + + ' b := 1;\n' + + '\n' + // blank lines stay blank, not trailing whitespace + ' c := 2;\n' + + ' END_IF;\n' + + ' END_IF;\n', + ) + }) + + it('strips a common leading margin so an indented snippet is not double-indented', () => { + const code = [' x := 1;', ' y := 2;'].join('\n') + const body = ldBody( + [rail('L', 'left', 0), execute('X', code, 200), rail('R', 'right', 400)], + [e('L', 'X'), e('X', 'R')], + ) + + expect(emitLdBody(body).bodySt).toBe('\n x := 1;\n y := 2;\n') + }) + + it('normalises CRLF endings and trims surrounding blank lines', () => { + // CODESYS writes a trailing newline for LD payloads but not FBD ones, so + // emission must not depend on either being present. + const body = ldBody( + [rail('L', 'left', 0), execute('X', '\r\n\r\nx := 1;\r\ny := 2;\r\n\r\n', 200), rail('R', 'right', 400)], + [e('L', 'X'), e('X', 'R')], + ) + + expect(emitLdBody(body).bodySt).toBe('\n x := 1;\n y := 2;\n') + }) +}) + +describe('Execute element — ENO passthrough', () => { + it('does not wrap a downstream FBD assignment in an ENO check', () => { + // `getUsedEnoForNode` wraps an outVariable in `IF .ENO THEN` when + // its single upstream is a *block* with EN wired. An Execute node must not + // trigger that: there is no `_TMP_..._ENO` for EXECUTE, so the downstream + // condition has to rebuild from the rung instead. + const { bodySt, warnings } = emitFbdBody({ + rung: fbdRung( + [inVar('IV', 'flag', 0), execute('X', 'side := side + 1;', 200), outVar('OV', 'result', 400)], + [e('IV', 'X'), e('X', 'OV')], + ), + }) + + expect(warnings).toEqual([]) + expect(bodySt).toBe('\n IF flag THEN\n side := side + 1;\n END_IF;\n result := flag;\n') + expect(bodySt).not.toContain('ENO') + }) + + it('feeds a chain of two Execute boxes from the same rung condition', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'gate', 68), + execute('X1', 'first := 1;', 200), + execute('X2', 'second := 2;', 400), + coil('K', 'done', 600), + rail('R', 'right', 800), + ], + [e('L', 'C'), e('C', 'X1'), e('X1', 'X2'), e('X2', 'K'), e('K', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual([]) + expect(bodySt).toBe( + '\n' + + ' IF gate THEN\n first := 1;\n END_IF;\n' + + ' IF gate THEN\n second := 2;\n END_IF;\n' + + ' done := gate;\n', + ) + }) +}) + +describe('Execute element — ordering', () => { + it('honours an explicit executionOrder ahead of the positional sweep', () => { + // X2 sits to the LEFT of X1 but carries the higher order, so position + // alone would emit it first; the explicit order must win. + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'gate', 68), + execute('X1', 'first := 1;', 400, 1), + execute('X2', 'second := 2;', 200, 2), + rail('R', 'right', 800), + ], + [e('L', 'C'), e('C', 'X1'), e('C', 'X2')], + ) + + const { bodySt } = emitLdBody(body) + + expect(bodySt).toBe('\n IF gate THEN\n first := 1;\n END_IF;\n IF gate THEN\n second := 2;\n END_IF;\n') + }) + + it('falls back to left-to-right position when no order is set', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + contact('C', 'gate', 68), + execute('XB', 'b := 1;', 400), + execute('XA', 'a := 1;', 200), + rail('R', 'right', 800), + ], + [e('L', 'C'), e('C', 'XA'), e('C', 'XB')], + ) + + expect(emitLdBody(body).bodySt).toBe( + '\n IF gate THEN\n a := 1;\n END_IF;\n IF gate THEN\n b := 1;\n END_IF;\n', + ) + }) +}) + +describe('Execute element — malformed input', () => { + it('warns and emits nothing for an empty snippet', () => { + const body = ldBody( + [rail('L', 'left', 0), contact('C', 'gate', 68), execute('X', ' \n\n ', 200), rail('R', 'right', 400)], + [e('L', 'C'), e('C', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual(['Execute block "X" is empty.']) + // No hollow `IF gate THEN END_IF;` + expect(bodySt).toBe('\n') + }) + + it('warns when the node payload has no code field', () => { + const body = ldBody( + [ + rail('L', 'left', 0), + { id: 'X', type: 'execute', position: { x: 200, y: 38 }, data: { executionOrder: 0 } }, + rail('R', 'right', 400), + ], + [e('L', 'X'), e('X', 'R')], + ) + + const { bodySt, warnings } = emitLdBody(body) + + expect(warnings).toEqual(['execute node "X" has unrecognised data shape']) + expect(bodySt).toBe('\n') + }) +}) diff --git a/src/backend/shared/transpilers/st-transpiler/walker/README.md b/src/backend/shared/transpilers/st-transpiler/walker/README.md index d6f932e55..62d853cbc 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/README.md +++ b/src/backend/shared/transpilers/st-transpiler/walker/README.md @@ -42,3 +42,19 @@ and the oracle is a walker bug, never an oracle bug — see the fixture corpus under `xml2st/fixtures/` and the test harness under `xml2st/shared-backend/transpilers/generate-st-from-react-flow/tests/` for the validation loop. + +### Exception: the `execute` node + +The `execute` ("Execute" / "ST Block") element is a **deliberate +superset** of the python generator's vocabulary — it has no oracle +equivalent, so the byte-parity rule above does not apply to it. Its +semantics are instead pinned against a real CODESYS PLCopen export +(`` carrying its source in a +`.../plcopenxml/stcode` `addData`), which establishes that: + +- the snippet runs gated by whatever rung condition reaches `EN`, and +- `ENO` is a plain power passthrough — a coil downstream of an + EXECUTE block references the block with no `formalParameter` + qualifier, so `contact → EXECUTE → coil` yields `coil := contact`. + +See `__tests__/execute-element.test.ts`. diff --git a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts index ce67686b7..05e7e1001 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/connection-types.ts @@ -120,9 +120,12 @@ export function computeConnectionTypes(body: RFBody, ctx: TypeContext): Map 0 && lines[lines.length - 1].trim() === '') lines.pop() + while (lines.length > 0 && lines[0].trim() === '') lines.shift() + if (lines.length === 0) return [] + + let margin = Infinity + for (const line of lines) { + if (line.trim() === '') continue + const leading = line.length - line.trimStart().length + if (leading < margin) margin = leading + } + if (!Number.isFinite(margin)) margin = 0 + + return lines.map((line) => (line.trim() === '' ? '\n' : `${indent}${line.slice(margin)}\n`)) +} + +/** + * Emit an Execute ("ST Block") element: the user's raw ST snippet, gated by + * the rung condition reaching its `EN` input. + * + * Gating is skipped when that condition is trivially true — no incoming edge + * (FBD with `EN` unwired) or a single path resolving to `TRUE` (an LD box on + * the left rail). Both mean "runs every scan", and emitting the body bare + * keeps the generated ST readable instead of burying it in `IF TRUE THEN`. + * + * The snippet is emitted verbatim apart from re-indentation; strucpp is what + * judges its validity. + */ +function emitExecuteNode(state: WalkerState, node: RFNode): void { + const data = asExecuteData(node.data) + if (data === null) { + state.warnings.push(`execute node "${node.id}" has unrecognised data shape`) + return + } + + // Bail before emitting anything — an empty box would otherwise + // produce a hollow `IF cond THEN END_IF;`, which is legal ST but + // pure noise in the generated output. + if (data.code.trim() === '') { + state.warnings.push(`Execute block "${node.id}" is empty.`) + return + } + + const info: Location = [state.tagName, 'execute', locId(node)] + const paths = pathsFromIncoming(state, node.id, /*order=*/ false) + const gated = paths.length > 0 && !(paths.length === 1 && paths[0].kind === 'true') + + if (gated) { + state.program.push([`${state.currentIndent}IF `, info]) + for (const chunk of pathsToChunks(paths)) state.program.push(chunk) + state.program.push([' THEN\n', []]) + state.currentIndent += ' ' + } + + for (const line of reindentSnippet(data.code, state.currentIndent)) state.program.push([line, info]) + + if (gated) { + state.currentIndent = state.currentIndent.slice(0, -2) + state.program.push([`${state.currentIndent}END_IF;\n`, []]) + } +} + /* ─────────────────────────── standalone block ───────────────────────────── */ function emitStandaloneBlock(state: WalkerState, node: RFNode): void { @@ -611,6 +694,14 @@ function visitUpstream(state: WalkerState, node: RFNode, edge: RFEdge, order: bo // behaviour: emit nothing for the coil, just propagate the // upstream signal. return visitCoilPassthrough(state, node, order) + case 'execute': + // ENO passthrough: an Execute box conducts rung power straight through, + // so `contact -> EXECUTE -> coil` yields `coil := contact` and the + // snippet emits separately as a sink. Confirmed against a CODESYS + // PLCopen export, where a downstream coil references the EXECUTE block + // with no `formalParameter` qualifier — plain rung continuation. Same + // algebra as a coil passthrough. + return visitCoilPassthrough(state, node, order) case 'continuation': return visitContinuation(state, node) case 'connector': diff --git a/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts b/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts index 127740cc7..8e825e2a1 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/narrow.ts @@ -78,6 +78,12 @@ export interface ParallelData { side: 'open' | 'close' } +export interface ExecuteData { + /** The user's raw Structured Text snippet. */ + code: string + executionOrder: number +} + function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } @@ -176,6 +182,21 @@ export function asParallelData(data: Record): ParallelData | nu return { side: t } } +/** + * Execute ("ST Block") payload — a graphical element holding a raw ST + * snippet. `code` is the only required field; a node whose `code` is + * not a string is malformed and the walker warns rather than guessing. + * + * The snippet is stored exactly as the user typed it (indentation and + * blank lines included); re-indentation happens at emission time, not + * here. + */ +export function asExecuteData(data: Record): ExecuteData | null { + const code = asString(data['code']) + if (code === null) return null + return { code, executionOrder: asNumber(data['executionOrder']) ?? 0 } +} + export function asBlockData(data: Record): BlockData | null { const variant = data['variant'] if (!isObject(variant)) return null diff --git a/src/backend/shared/transpilers/st-transpiler/walker/types.ts b/src/backend/shared/transpilers/st-transpiler/walker/types.ts index a2182413f..35bea7710 100644 --- a/src/backend/shared/transpilers/st-transpiler/walker/types.ts +++ b/src/backend/shared/transpilers/st-transpiler/walker/types.ts @@ -34,7 +34,7 @@ export interface RFNode { * boundary in `from-schema.ts` can pass the editor's `node.type: * string` straight through without a typecast. Known values the * walker dispatches on: `'powerRail' | 'contact' | 'coil' | 'block' - * | 'variable' | 'input-variable' | 'output-variable' | + * | 'execute' | 'variable' | 'input-variable' | 'output-variable' | * 'inout-variable' | 'parallel' | 'connector' | 'continuation'`. * Anything else is treated as a no-op sink. */ type: string diff --git a/src/frontend/assets/icons/project/ladder/Execute.tsx b/src/frontend/assets/icons/project/ladder/Execute.tsx new file mode 100644 index 000000000..110564e04 --- /dev/null +++ b/src/frontend/assets/icons/project/ladder/Execute.tsx @@ -0,0 +1,50 @@ +import { ComponentPropsWithoutRef } from 'react' + +import { cn } from '../../../../utils/cn' + +type IExecuteIconProps = ComponentPropsWithoutRef<'svg'> & { + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-12 h-12', +} + +/** + * Execute ("ST Block") toolbox icon — a rung-wired box holding lines of + * text, echoing how the element renders on the canvas. Matches the + * other ladder icons' rounded-square backdrop and `#B4D0FE` palette. + */ +export default function ExecuteIcon(props: IExecuteIconProps) { + const { className, size = 'sm', ...res } = props + + return ( + + + {/* rung wires into EN / out of ENO */} + + + {/* the box */} + + {/* lines of code */} + + + ) +} diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx index 5f69f9791..938a49a83 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/buildNodes.tsx @@ -11,6 +11,10 @@ import { DEFAULT_BLOCK_TYPE, DEFAULT_CONNECTION_CONNECTOR_X, DEFAULT_CONNECTION_CONNECTOR_Y, + DEFAULT_EXECUTE_CONNECTOR_X, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_HEIGHT, + DEFAULT_EXECUTE_WIDTH, DEFAULT_VARIABLE_CONNECTOR_X, DEFAULT_VARIABLE_CONNECTOR_Y, MINIMUM_ELEMENT_HEIGHT, @@ -24,6 +28,8 @@ import type { CommentNode, ConnectionBuilderProps, ConnectionNode, + ExecuteBuilderProps, + ExecuteNode, VariableBuilderProps, VariableNode, } from './utils/types' @@ -115,6 +121,72 @@ export const buildCommentNode = ({ id, position }: CommentBuilderProps): Comment } } +/** + * Build an FBD Execute ("ST Block") node. + * + * Same electrical shape as the ladder variant — `EN` target left, `ENO` + * source right — but free-positioned and resizable like the comment + * element, since FBD has no rung to lay it out. Leaving `EN` unwired + * means the snippet runs unconditionally; the walker's "no incoming + * paths" case handles that. + */ +export const buildExecuteNode = ({ id, position, code = '' }: ExecuteBuilderProps): ExecuteNode => { + // `style.top` places the handle's DOM element — React Flow draws edges to + // the DOM position, so without it the wire meets the box off the pin row. + const inputHandle = buildHandle({ + id: 'EN', + position: Position.Left, + type: 'target', + glbX: position.x, + glbY: position.y + DEFAULT_EXECUTE_CONNECTOR_Y, + relX: 0, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, left: 0 }, + }) + const outputHandle = buildHandle({ + id: 'ENO', + position: Position.Right, + type: 'source', + glbX: position.x + DEFAULT_EXECUTE_CONNECTOR_X, + glbY: position.y + DEFAULT_EXECUTE_CONNECTOR_Y, + relX: DEFAULT_EXECUTE_CONNECTOR_X, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, right: 0 }, + }) + + return { + id, + type: 'execute', + position, + width: DEFAULT_EXECUTE_WIDTH, + height: DEFAULT_EXECUTE_HEIGHT, + measured: { + width: DEFAULT_EXECUTE_WIDTH, + height: DEFAULT_EXECUTE_HEIGHT, + }, + data: { + handles: [inputHandle, outputHandle], + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId: generateNumericUUID(), + executionOrder: 0, + code, + // Shape-compatibility with `BasicNodeData`; an Execute box binds no + // single variable — it references whatever its code does. + variable: { id: '', name: '' }, + draggable: true, + selectable: true, + deletable: true, + }, + deletable: true, + selectable: true, + draggable: true, + selected: true, + } +} + export const buildConnectionNode = ({ id, position, variant, label }: ConnectionBuilderProps): ConnectionNode => { const inputHandle = variant === 'connector' diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx new file mode 100644 index 000000000..7554dfedf --- /dev/null +++ b/src/frontend/components/_atoms/graphical-editor/fbd/execute.tsx @@ -0,0 +1,142 @@ +import { NodeResizer } from '@xyflow/react' +import { memo, useCallback, useEffect, useState } from 'react' + +import { useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { useOpenPLCStore } from '../../../../store' +import { cn } from '../../../../utils/cn' +import { executeStDocumentUri } from '../../../../utils/PLC/execute-st-uri' +import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' +import { StCodeField } from '../st-code-field' +import { CustomHandle } from './handle' +import { + DEFAULT_EXECUTE_BODY_TOP, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_HEIGHT, + DEFAULT_EXECUTE_WIDTH, +} from './utils/constants' +import type { ExecuteProps } from './utils/types' + +export type { ExecuteNode } from './utils/types' + +/** + * Execute ("ST Block") for FBD — a resizable box holding a raw ST snippet, + * gated by whatever reaches `EN` and passing that signal through on `ENO`. + * + * Free-positioned and user-resizable like the comment element, since FBD has + * no rung to lay it out. An unwired `EN` means the snippet runs every scan. + */ +const ExecuteElement = (block: ExecuteProps) => { + const { id, data, selected, width, height } = block + const pouName = useBoundPou() + const updateNode = useOpenPLCStore((state) => state.fbdFlowActions.updateNode) + const updateModelFBD = useOpenPLCStore((state) => state.editorActions.updateModelFBD) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const isDebuggerVisible = useIsDebuggerVisible() + + // Only one Monaco surface may own the shared model URI at a time — see the + // ladder Execute node. + const expandedModal = useOpenPLCStore((state) => state.modals['execute-fbd-element']) + const isExpanded = expandedModal.open && (expandedModal.data as { id?: string } | null)?.id === id + + const [focused, setFocused] = useState(false) + + // Monaco's scroll/zoom gestures fight the canvas', so freeze pan and zoom + // while the field has focus — as the comment element does for its textarea. + useEffect(() => { + updateModelFBD({ canEditorZoom: !focused, canEditorPan: !focused }) + return () => updateModelFBD({ canEditorZoom: true, canEditorPan: true }) + }, [focused, updateModelFBD]) + + const handleCommit = useCallback( + (nextCode: string) => { + const { fbdFlows } = useOpenPLCStore.getState() + const node = fbdFlows.find((flow) => flow.name === pouName)?.rung.nodes.find((n) => n.id === id) + if (!node) return + if ((node.data as { code?: string }).code === nextCode) return + + updateNode({ + editorName: pouName, + nodeId: id, + node: { ...node, data: { ...node.data, code: nextCode } }, + }) + }, + [id, pouName, updateNode], + ) + + return ( + <> + {/* Bare, unstyled root — border and size live on the inner box, mirroring + `Block`. Bordering this element would shift every handle down by the + border width and step the wire into it. */} +
+
+
Execute
+ + + +
+ EN +
+
+ ENO +
+ +
setFocused(true)} + onBlurCapture={() => setFocused(false)} + > + +
+
+ + {data.handles.map((handle, index) => ( + + ))} +
+ + + ) +} + +const exportExecuteElement = memo(ExecuteElement) + +export { exportExecuteElement as ExecuteElement } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/index.ts b/src/frontend/components/_atoms/graphical-editor/fbd/index.ts index 80007b3ba..ba9448bcd 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/index.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/index.ts @@ -2,6 +2,7 @@ import * as blockNode from './block' import * as buildNodes from './buildNodes' import * as commentNode from './comment' import * as connectionNode from './connection' +import * as executeNode from './execute' import * as variableNode from './variable' export const customNodeTypes = { @@ -12,6 +13,7 @@ export const customNodeTypes = { connector: connectionNode.ConnectionElement, continuation: connectionNode.ConnectionElement, comment: commentNode.CommentElement, + execute: executeNode.ExecuteElement, } export type CustomFbdNodeTypes = keyof typeof customNodeTypes @@ -20,4 +22,5 @@ export const nodesBuilder = { variable: buildNodes.buildVariableNode, connection: buildNodes.buildConnectionNode, comment: buildNodes.buildCommentNode, + execute: buildNodes.buildExecuteNode, } diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx index 242c69e17..7e9d33dda 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/constants.tsx @@ -44,3 +44,14 @@ export const DEFAULT_VARIABLE_CONNECTOR_X = DEFAULT_VARIABLE_WIDTH export const DEFAULT_VARIABLE_CONNECTOR_Y = DEFAULT_VARIABLE_HEIGHT / 2 export { FBD_VARIABLE_NODE_TYPES } from '../../../../../utils/graphical/types' + +// execute ("ST Block") + +// Same connector geometry as an FBD block, so wires meet the box on the +// block pin row rather than at an arbitrary height of its own. +export const DEFAULT_EXECUTE_WIDTH = 260 +export const DEFAULT_EXECUTE_HEIGHT = 120 +export const DEFAULT_EXECUTE_CONNECTOR_Y = DEFAULT_BLOCK_CONNECTOR_Y +export const DEFAULT_EXECUTE_CONNECTOR_X = DEFAULT_EXECUTE_WIDTH +/** Top of the code area — clears the title row and the EN/ENO pin row. */ +export const DEFAULT_EXECUTE_BODY_TOP = DEFAULT_EXECUTE_CONNECTOR_Y + 16 diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts b/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts index d7a899711..69237b427 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/types.ts @@ -68,3 +68,15 @@ export type VariableProps = NodeProps export type VariableBuilderProps = BuilderBasicProps & { variant: 'input-variable' | 'output-variable' | 'inout-variable' } + +// execute ("ST Block") + +/** + * A box holding a raw Structured Text snippet. `EN` gates execution; + * `ENO` passes the same signal through. Unlike the ladder variant this + * one is free-positioned and user-resizable, matching the FBD comment + * element. + */ +export type ExecuteNode = Node +export type ExecuteProps = NodeProps +export type ExecuteBuilderProps = BuilderBasicProps & { code?: string } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx index ac79fc34d..fbaf113cf 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx @@ -11,6 +11,8 @@ import { DEFAULT_CONTACT_BLOCK_HEIGHT, DEFAULT_CONTACT_BLOCK_WIDTH, DEFAULT_CONTACT_CONNECTOR_Y, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_WIDTH, DEFAULT_PARALLEL_CONNECTOR_Y, DEFAULT_PARALLEL_HEIGHT, DEFAULT_PARALLEL_WIDTH, @@ -24,12 +26,14 @@ import { DEFAULT_VARIABLE_CONNECTOR_Y, DEFAULT_VARIABLE_HEIGHT, DEFAULT_VARIABLE_WIDTH, + executeHeight, } from './utils/constants' import type { BlockBuilderProps, BlockVariant, CoilBuilderProps, ContactBuilderProps, + ExecuteBuilderProps, LadderBlockConnectedVariables, ParallelBuilderProps, ParallelNode, @@ -165,6 +169,78 @@ export const buildCoilNode = ({ id, posX, posY, handleX, handleY, variant }: Coi } } +/** + * Build an Execute ("ST Block") node — a box carrying a raw ST snippet. + * + * Electrically a coil: one `EN` target on the left, one `ENO` source on + * the right, both anchored to the header strip's vertical centre so the + * rung wire enters at a fixed height regardless of how many lines of + * code the box holds. Height is derived from the line count (clamped); + * past the clamp the box scrolls internally rather than growing the rung. + */ +export const buildExecuteNode = ({ id, posX, posY, handleX, handleY, code = '' }: ExecuteBuilderProps) => { + // `style.top` is what actually places the handle's DOM element, and React + // Flow draws every edge to the DOM position — `relY` alone is not enough. + // Omitting it left the handle at the element's default offset while the + // label sat on the pin row, which is what made the rung wire jog into the + // box. Mirrors `getBlockSize`'s handles exactly. + const inputHandle = buildHandle({ + id: 'EN', + position: Position.Left, + isConnectable: false, + type: 'target', + glbX: handleX, + glbY: handleY, + relX: 0, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, left: 0 }, + }) + const outputHandle = buildHandle({ + id: 'ENO', + position: Position.Right, + isConnectable: false, + type: 'source', + glbX: handleX + DEFAULT_EXECUTE_WIDTH, + glbY: handleY, + relX: DEFAULT_EXECUTE_WIDTH, + relY: DEFAULT_EXECUTE_CONNECTOR_Y, + style: { top: DEFAULT_EXECUTE_CONNECTOR_Y, right: 0 }, + }) + const handles = [inputHandle, outputHandle] + const height = executeHeight(code === '' ? 0 : code.split('\n').length) + + return { + id, + type: 'execute', + position: { x: posX, y: posY }, + data: { + handles, + code, + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId: generateNumericUUID(), + // Carried for shape-compatibility with `BasicNodeData`; an Execute + // box binds no single variable — it references whatever its code does. + variable: { name: '' }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + }, + width: DEFAULT_EXECUTE_WIDTH, + height, + measured: { + width: DEFAULT_EXECUTE_WIDTH, + height, + }, + draggable: true, + selectable: true, + selected: true, + } +} + export const buildContactNode = ({ id, posX, posY, handleX, handleY, variant }: ContactBuilderProps) => { const inputHandle = buildHandle({ id: 'input', diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx new file mode 100644 index 000000000..a33853d36 --- /dev/null +++ b/src/frontend/components/_atoms/graphical-editor/ladder/execute.tsx @@ -0,0 +1,165 @@ +import { memo, useCallback } from 'react' + +import { useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { useOpenPLCStore } from '../../../../store' +import { cn } from '../../../../utils/cn' +import { executeStDocumentUri } from '../../../../utils/PLC/execute-st-uri' +import { useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context' +import { StCodeField } from '../st-code-field' +import { CustomHandle } from './handle' +import { getLadderPouVariablesRungNodeAndEdges } from './utils' +import { + DEFAULT_EXECUTE_BODY_TOP, + DEFAULT_EXECUTE_CONNECTOR_Y, + DEFAULT_EXECUTE_HEIGHT, + DEFAULT_EXECUTE_WIDTH, + executeHeight, +} from './utils/constants' +import type { ExecuteProps } from './utils/types' + +export type { ExecuteNode } from './utils/types' + +/** + * Execute ("ST Block") — a ladder element holding a raw ST snippet. + * + * Rendered as a standard ladder block: the same chrome as `BlockNodeVisual` on + * the same `DEFAULT_BLOCK_CONNECTOR_Y` geometry, so the layout aligns it like + * any block and the rung wire runs straight through. + * + * `EN` / `ENO` are always shown — execution control is what gates the snippet. + * Electrically the box is a coil: ENO is EN, so power conducts through to + * whatever follows. + */ +const Execute = (block: ExecuteProps) => { + const { id, data, selected, width, height } = block + const pouName = useBoundPou() + const updateNode = useOpenPLCStore((state) => state.ladderFlowActions.updateNode) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const isDebuggerVisible = useIsDebuggerVisible() + + // The modal edits the same document URI, so diagnostics attach to whichever + // surface is up. Two Monaco editors on one model path fight over it, so only + // one may be live at a time; the modal wins. + const expandedModal = useOpenPLCStore((state) => state.modals['execute-ladder-element']) + const isExpanded = expandedModal.open && (expandedModal.data as { id?: string } | null)?.id === id + + // Grow / shrink as the user types. The code is only written back on blur, so + // without this the box keeps its committed height while the text runs past + // the bottom. Transient — resizing must not mark the POU dirty on its own. + const handleLineCountChange = useCallback( + (lineCount: number) => { + const nextHeight = executeHeight(lineCount) + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: id, + }) + if (!rung || !node || node.height === nextHeight) return + + updateNode({ + editorName: pouName, + rungId: rung.id, + nodeId: id, + node: { + ...node, + height: nextHeight, + measured: { width: node.width ?? DEFAULT_EXECUTE_WIDTH, height: nextHeight }, + }, + transient: true, + }) + }, + [id, pouName, updateNode], + ) + + const handleCommit = useCallback( + (nextCode: string) => { + // Re-read from the store: the rung id isn't on the node, and it may have + // been re-laid-out since this callback was made. + const { project, ladderFlows } = useOpenPLCStore.getState() + const { rung, node } = getLadderPouVariablesRungNodeAndEdges(pouName, project.data.pous, ladderFlows, { + nodeId: id, + }) + if (!rung || !node) return + if ((node.data as { code?: string }).code === nextCode) return + + const nextHeight = executeHeight(nextCode === '' ? 0 : nextCode.split('\n').length) + updateNode({ + editorName: pouName, + rungId: rung.id, + nodeId: node.id, + node: { + ...node, + height: nextHeight, + measured: { width: node.width ?? DEFAULT_EXECUTE_WIDTH, height: nextHeight }, + data: { ...node.data, code: nextCode }, + }, + }) + }, + [id, pouName, updateNode], + ) + + return ( + // Bare, unstyled root — border and size live on the inner box, mirroring + // `Block`. Load-bearing: handles position with `top: ` against + // the nearest positioned ancestor, so bordering this element would shift + // every handle down by the border width and step the wire into the box. +
+
+ {/* Title, centred like a block's type name. */} +
Execute
+ + + + {/* EN / ENO inset at the connector row, as BlockNodeVisual does it. */} +
+ EN +
+
+ ENO +
+ +
+ +
+
+ + {data.handles.map((handle, index) => ( + + ))} +
+ ) +} + +export default memo(Execute) +export { Execute } diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/index.ts b/src/frontend/components/_atoms/graphical-editor/ladder/index.ts index 729c7d2b0..006a99f09 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/index.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/index.ts @@ -1,6 +1,7 @@ import * as blockNode from './block' import * as coilNode from './coil' import * as contactNode from './contact' +import * as executeNode from './execute' import * as mockNode from './mock-node' import * as parallelNode from './parallel' import * as placeholderNode from './placeholder' @@ -16,6 +17,7 @@ export const customNodeTypes = { block: blockNode.Block, coil: coilNode.Coil, contact: contactNode.Contact, + execute: executeNode.Execute, parallel: parallelNode.Parallel, parallelPlaceholder: placeholderNode.Placeholder, placeholder: placeholderNode.Placeholder, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts b/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts index 06f5e0287..c94290b50 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/node-builders.ts @@ -62,6 +62,19 @@ export const defaultCustomNodesStyles: CustomLadderNodeTypes = { offsetY: 0, }, }, + execute: { + width: constants.DEFAULT_EXECUTE_WIDTH, + // Nominal only — an Execute node carries its own `height`, derived + // from its line count, and the layout reads `node.height` first. + height: constants.DEFAULT_EXECUTE_HEIGHT, + gap: 60, + verticalGap: 80, + handle: { + x: constants.DEFAULT_EXECUTE_CONNECTOR_X, + y: constants.DEFAULT_EXECUTE_CONNECTOR_Y, + offsetY: 0, + }, + }, parallel: { width: constants.DEFAULT_PARALLEL_WIDTH, height: constants.DEFAULT_PARALLEL_HEIGHT, @@ -134,6 +147,7 @@ export const nodesBuilder = { block: buildNodes.buildBlockNode, coil: buildNodes.buildCoilNode, contact: buildNodes.buildContactNode, + execute: buildNodes.buildExecuteNode, parallel: buildNodes.buildParallel, parallelPlaceholder: buildNodes.builderPlaceholderNode, placeholder: buildNodes.builderPlaceholderNode, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx index 7499db6ab..d17772b1d 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/constants.tsx @@ -186,6 +186,31 @@ export const GAP = 0 export const DEFAULT_PARALLEL_CONNECTOR_Y = DEFAULT_PARALLEL_HEIGHT / 2 +// execute ("ST Block") + +// Geometry is deliberately the SAME as a block's: `EN` / `ENO` sit on the +// first pin row, so the layout aligns this element exactly as it aligns a +// block and the rung wire runs straight into it instead of jogging. +export const DEFAULT_EXECUTE_WIDTH = 240 +export const DEFAULT_EXECUTE_CONNECTOR_Y = DEFAULT_BLOCK_CONNECTOR_Y +export const DEFAULT_EXECUTE_CONNECTOR_X = DEFAULT_EXECUTE_WIDTH + +/** Top of the code area — clears the title row and the EN/ENO pin row. */ +export const DEFAULT_EXECUTE_BODY_TOP = DEFAULT_EXECUTE_CONNECTOR_Y + 16 +export const DEFAULT_EXECUTE_LINE_HEIGHT = 18 +export const DEFAULT_EXECUTE_BODY_PADDING = 10 +/** An empty box still shows the "Enter ST code here" placeholder line. */ +export const DEFAULT_EXECUTE_MIN_LINES = 1 +/** Past this the code area scrolls instead of growing the rung. */ +export const DEFAULT_EXECUTE_MAX_LINES = 12 + +export const executeHeight = (lineCount: number): number => + DEFAULT_EXECUTE_BODY_TOP + + Math.min(Math.max(lineCount, DEFAULT_EXECUTE_MIN_LINES), DEFAULT_EXECUTE_MAX_LINES) * DEFAULT_EXECUTE_LINE_HEIGHT + + DEFAULT_EXECUTE_BODY_PADDING + +export const DEFAULT_EXECUTE_HEIGHT = executeHeight(DEFAULT_EXECUTE_MIN_LINES) + // placeholder export const DEFAULT_PLACEHOLDER_WIDTH = 10 diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts index 6a4567114..6cc13a10f 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts @@ -105,6 +105,18 @@ export type ContactType = { } } +// execute ("ST Block") + +/** + * A box holding a raw Structured Text snippet, gated by the rung + * condition reaching its `EN` handle and passing power through on + * `ENO`. `code` is stored exactly as the user typed it — indentation + * and blank lines included; the transpiler re-indents at emission. + */ +export type ExecuteNode = Node +export type ExecuteProps = NodeProps +export type ExecuteBuilderProps = BuilderBasicProps & { code?: string } + // mock export type MockNode = Node<{ label: string; handles: CustomHandleProps[] }, 'text'> diff --git a/src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx b/src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx new file mode 100644 index 000000000..c5065a895 --- /dev/null +++ b/src/frontend/components/_atoms/graphical-editor/st-code-field/index.tsx @@ -0,0 +1,298 @@ +import { Editor as PrimitiveEditor } from '@monaco-editor/react' +import * as monaco from 'monaco-editor' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useIsDebuggerVisible } from '../../../../hooks/use-debug-value' +import { useStDebugDecorations } from '../../../../hooks/use-st-debug-decorations' +import { getExecuteDraftApi } from '../../../../services/st-lsp/execute-sync' +import { useOpenPLCStore } from '../../../../store' +import { cn } from '../../../../utils/cn' +import { applyThemeNow, ensureOpenplcThemes } from '../../../_features/[workspace]/editor/monaco/theme-utils' + +/** + * Structured Text editing surface for the Execute ("ST Block") element in LD + * and FBD, and for its expand modal. + * + * Separate from the POU-level `MonacoEditor`, which is bound to a POU (store + * lookups, project sync, file watchers, AI hooks). A rung can hold several + * Execute boxes, so Monaco mounts lazily — the field renders plain text until + * activated. The model is created under `uri`, the same URI the ST LSP holds + * the snippet's document on, which is what makes diagnostics attach. + */ + +const PLACEHOLDER = 'Enter ST code here' + +export type StCodeFieldProps = { + /** Current snippet. Treated as the source of truth while unfocused. */ + value: string + /** Called on blur (and on unmount while dirty) with the edited text. */ + onCommit: (next: string) => void + /** + * LSP document URI for this snippet — also the Monaco model URI, which + * is what makes diagnostics attach. Must be unique per node. + */ + uri: string + /** + * Composite-key prefix for debug value badges, e.g. `MyProgram:`. + * Omit to disable badges (the modal in a non-debug session). + */ + debugPrefix?: string + /** + * When false the field stays in its cheap read-only presentation and + * never mounts Monaco. The node passes `selected`; the modal passes + * `true`. + */ + active?: boolean + /** + * `compact` — the in-rung box: no line numbers or gutter, small type, every + * pixel spent on code because the element is only ~200px wide. + * `full` — the expand modal: a proper editor, matching the POU-level ST + * editor's font size, gutter and padding. That is the whole point of + * expanding. + */ + variant?: 'compact' | 'full' + className?: string + /** + * Fires as the user types, with the snippet's current line count. The + * owning node uses it to grow/shrink itself live — without it the box + * keeps its committed height while the text runs past the bottom edge. + */ + onLineCountChange?: (lineCount: number) => void +} + +export const StCodeField = ({ + value, + onCommit, + uri, + debugPrefix, + active = false, + variant = 'compact', + className, + onLineCountChange, +}: StCodeFieldProps) => { + const containerRef = useRef(null) + const editorRef = useRef(null) + const monacoRef = useRef(null) + const shouldUseDarkMode = useOpenPLCStore((state) => state.workspace.systemConfigs.shouldUseDarkMode) + const isDebuggerVisible = useIsDebuggerVisible() + + // The debugger makes every code surface read-only — the running program is + // what it is; editing it here would be a lie. + const effectiveReadOnly = isDebuggerVisible + + // Monaco mounting is invisible to React — `onMount` only fills refs, so + // nothing re-renders. Without this flag the decoration scan below runs once + // against a null editor and never again. `MonacoEditor` guards the same trap. + const [editorMounted, setEditorMounted] = useState(false) + + // Local buffer so typing doesn't round-trip through the store on + // every keystroke. Re-synced from `value` whenever the field is not + // the thing driving the change. + const [draft, setDraft] = useState(value) + const draftRef = useRef(value) + const dirtyRef = useRef(false) + const lspTimerRef = useRef | null>(null) + + // Push the draft to the LSP as the user types. The store only sees the + // snippet on blur, so a store-driven sync would deliver diagnostics a commit + // late — by which point the field may be deselected and its model disposed, + // leaving markers nowhere to land. Debounced into one `didChange`. + const scheduleLspSync = useCallback( + (text: string) => { + if (lspTimerRef.current !== null) clearTimeout(lspTimerRef.current) + lspTimerRef.current = setTimeout(() => { + lspTimerRef.current = null + getExecuteDraftApi()?.syncDraft(uri, text) + }, 200) + }, + [uri], + ) + + useEffect( + () => () => { + if (lspTimerRef.current !== null) clearTimeout(lspTimerRef.current) + }, + [], + ) + + // Publish on mount so an already-broken snippet is underlined before a key + // is pressed. `force` is required: diagnostics are a one-shot notification, + // and mounting creates a NEW model (the expand modal builds a second one at + // the same URI) with usually-unchanged text, so without a forced re-analyse + // the worker stays silent. Keyed on `editorMounted` — the model does not + // exist until Monaco has mounted. + useEffect(() => { + if (!active || !editorMounted) return + getExecuteDraftApi()?.syncDraft(uri, draftRef.current, true) + }, [active, editorMounted, uri]) + + useEffect(() => { + if (dirtyRef.current) return + setDraft(value) + draftRef.current = value + }, [value]) + + const commit = useCallback(() => { + if (!dirtyRef.current) return + dirtyRef.current = false + if (draftRef.current === value) return + onCommit(draftRef.current) + }, [onCommit, value]) + + // Latest `commit` for callbacks registered once at mount, so the blur + // handler below never runs against a stale `value`. + const commitRef = useRef(commit) + commitRef.current = commit + + // Commit anything still buffered when the field goes away — a node + // deleted or a modal closed mid-edit must not silently lose the text. + // `commit` changes identity with `value`, so this cleanup also runs on an + // ordinary re-render; that is harmless because `commit` no-ops unless the + // buffer is dirty. + useEffect(() => commit, [commit]) + + useStDebugDecorations({ + editorRef, + monacoRef, + prefix: debugPrefix, + enabled: active && editorMounted && isDebuggerVisible && debugPrefix !== undefined, + modelVersion: draft, + }) + + const handleMount = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor, monacoInstance: typeof monaco) => { + editorRef.current = editor + monacoRef.current = monacoInstance + setEditorMounted(true) + ensureOpenplcThemes(monacoInstance) + applyThemeNow(monacoInstance, shouldUseDarkMode) + editor.onDidBlurEditorText(() => commitRef.current()) + }, + [shouldUseDarkMode], + ) + + useEffect(() => { + if (monacoRef.current) applyThemeNow(monacoRef.current, shouldUseDarkMode) + }, [shouldUseDarkMode]) + + // Blur (and commit) on a click anywhere outside the field. The rung's React + // Flow pane covers only the rung, so a click elsewhere on the page never + // reaches it and the editor would otherwise keep focus indefinitely. + useEffect(() => { + if (!active) return + const onPointerDown = (event: PointerEvent) => { + const container = containerRef.current + if (!container) return + const target = event.target + if (target instanceof Node && container.contains(target)) return + // Monaco's overlays (suggest widget, hovers) portal outside the + // container; blurring while one is open would fight the user. + if (target instanceof Element && target.closest('.monaco-editor')) return + editorRef.current?.getDomNode()?.blur() + commit() + } + document.addEventListener('pointerdown', onPointerDown, true) + return () => document.removeEventListener('pointerdown', onPointerDown, true) + }, [active, commit]) + + // Going inactive unmounts Monaco, leaving the refs on a disposed editor. + // Cleared in an effect rather than during render. + useEffect(() => { + if (active) return + editorRef.current = null + monacoRef.current = null + setEditorMounted(false) + }, [active]) + + if (!active) { + // Cheap presentation: no Monaco, no LSP document, no decorations. + return ( +
+        {draft === '' ? PLACEHOLDER : draft}
+      
+ ) + } + + return ( + // `nokey` opts these keystrokes out of @xyflow/react's window-level keydown + // listener, which treats Space as a canvas pan-modifier and preventDefaults + // it. xyflow exempts input/textarea/contenteditable, but Monaco's + // EditContext surface is a plain div — so without this, Space never reaches + // the editor. The POU-level editor carries the same marker. +
+ { + const text = next ?? '' + dirtyRef.current = true + draftRef.current = text + setDraft(text) + scheduleLspSync(text) + onLineCountChange?.(text === '' ? 0 : text.split('\n').length) + }} + onMount={handleMount} + options={{ + readOnly: effectiveReadOnly, + domReadOnly: effectiveReadOnly, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + // Classic hidden-