diff --git a/customWords.txt b/customWords.txt index 8218b0c..bb53339 100644 --- a/customWords.txt +++ b/customWords.txt @@ -10,3 +10,4 @@ railsl ebnf editorsprache setzanweisungen +zelos \ No newline at end of file diff --git a/frontend/blocks.js b/frontend/blocks.js index 16217fe..d52d118 100644 --- a/frontend/blocks.js +++ b/frontend/blocks.js @@ -66,7 +66,7 @@ const blockDefinitionsJson = [ } ], output: 'CSetVector', - colour: 25, + colour: 0, tooltip: '%{BKY_RAILBLOCKS_STOP_TOOLTIP}' }, // Track-Statement Vector Direction case. @@ -91,7 +91,7 @@ const blockDefinitionsJson = [ ], output: 'CSetVector', tooltip: '%{BKY_RAILBLOCKS_DIR_TOOLTIP}', - colour: 25 + colour: 0 }, // Contact-Wait-Statement // Wait until a segment switch is reached or passed. @@ -100,13 +100,10 @@ const blockDefinitionsJson = [ message0: '%{BKY_RAILBLOCKS_CONTACT_WAIT_TEXT}', args0: [ { - type: 'field_dropdown', - options: [ - ['%{BKY_RAILBLOCKS_CONTACT_WAIT_REACHED}', 'ITEM1'], - ['%{BKY_RAILBLOCKS_CONTACT_WAIT_PASSED}', 'ITEM2'] - ], - name: 'CONTACT' - }, + type: "input_value", + name: "track_sensor_value", + check: "Boolean" + }, { type: 'field_dropdown', options: [ @@ -121,7 +118,7 @@ const blockDefinitionsJson = [ name: 'SEGMENT' } ], - colour: 240, + colour: 210, tooltip: '%{BKY_RAILBLOCKS_CONTACT_WAIT_TOOLTIP}', previousStatement: 'CStatement', nextStatement: 'CStatement' @@ -138,7 +135,7 @@ const blockDefinitionsJson = [ name: 'DURATION' } ], - colour: 240, + colour: 210, tooltip: '%{BKY_RAILBLOCKS_TIME_WAIT_TOOLTIP}', previousStatement: 'CStatement', nextStatement: 'CStatement' @@ -158,7 +155,7 @@ const blockDefinitionsJson = [ name: 'CROSSING_STATUS' } ], - colour: 280, + colour: 60, tooltip: '%{BKY_RAILBLOCKS_CROSSING_TOOLTIP}', previousStatement: 'CStatement', nextStatement: 'CStatement' @@ -188,6 +185,11 @@ const blockDefinitionsJson = [ check: 'CStatement', name: 'COND_BLOCK0' }, + { + type: "input_value", + name: "track_sensor_value", + check: "Boolean" + }, { type: 'field_dropdown', options: [ @@ -205,7 +207,12 @@ const blockDefinitionsJson = [ type: 'input_statement', check: 'CStatement', name: 'COND_BLOCK1' - } + }, + { + type: "input_value", + name: "track_sensor_value", + check: "Boolean" + } ], colour: 300, tooltip: '%{BKY_RAILBLOCKS_CONDITIONAL_TOOLTIP}', @@ -233,6 +240,65 @@ const blockDefinitionsJson = [ tooltip: '%{BKY_RAILBLOCKS_PARALLEL_TOOLTIP}', previousStatement: 'CStatement', nextStatement: 'CStatement' + }, + // Reached track sensor value + // Shows that we reached the track sensor. + { + type: "reached", + message0: "%{BKY_RAILBLOCKS_CONTACT_WAIT_REACHED}", + output: "Boolean", + colour: 155, + tooltip: "Returns true if reached" + }, + // Passed track sensor value + // Shows that we passed the track sensor. + { + type: "passed", + message0: "%{BKY_RAILBLOCKS_CONTACT_WAIT_PASSED}", + output: "Boolean", + colour: 155, + tooltip: "Returns true if passed" + }, + // Integer range + // Is a range of integer numbers from start to end inclusive + { + type: "int_range", + message0: "%{BKY_RAILBLOCKS_INT_RANGE_TEXT}", + args0: [ + { + type: "field_number", + name: "START", + value: 0, + min: 0, + precision: 1 + }, + { + type: "field_number", + name: "END", + value: 2 + } + ], + output: "int_range", + extensions: ["dynamic_int_range_validator"], + colour: 80, + tooltip: "%{BKY_RAILBLOCKS_INT_RANGE_TOOLTIP}" + }, + // Integer + // Is just a simple number for shadow fields + { + type: "int_number", + message0: "%1", + args0: [ + { + type: "field_number", + name: "NUM", + value: 1, + min: 0, + precision: 1 + } + ], + output: "Number", + extensions: ["dynamic_int_number_validator"] } ] @@ -264,11 +330,11 @@ function createToolbox (labels) { }, { kind: 'block', - type: 'CrossingStatement' + type: 'LightStatement', }, { kind: 'block', - type: 'LightStatement' + type: 'CrossingStatement' }, { kind: 'sep', @@ -305,7 +371,31 @@ function createToolbox (labels) { { kind: 'block', type: 'LoopStatement' - } + }, + { + kind: 'sep', + gap: 60 + }, + { + kind: 'label', + text: labels.sensorValues + }, + { + kind: 'block', + type: 'reached' + }, + { + kind: 'block', + type: 'passed' + }, + { + kind: 'label', + text: labels.numbers + }, + { + kind: 'block', + type: 'int_range' + }, ] } } diff --git a/frontend/dynamic_blocks.js b/frontend/dynamic_blocks.js index e4ca37b..cf4eb54 100644 --- a/frontend/dynamic_blocks.js +++ b/frontend/dynamic_blocks.js @@ -36,7 +36,7 @@ Blockly.Blocks.ParallelStatementD = { this.setPreviousStatement('CStatement') this.setNextStatement('CStatement') - this.setColour(190) + this.setColour(280) this.setTooltip(Blockly.Msg.RAILBLOCKS_PARALLEL_TOOLTIP) // We need at least two inputs, so we cheat in one that will always be there. @@ -87,13 +87,30 @@ Blockly.Blocks.ParallelStatementD = { // Small helper function that adds another block in a dynamic branch statement. // Remember to clean up inputs, when deleting. function buildAnotherCondRow(index, block){ - block.appendDummyInput(`COND_BLOCK${index}_TEXT`) - .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_START) - .appendField(new Blockly.FieldDropdown([[Blockly.Msg.RAILBLOCKS_CONDITIONAL_FIRST, 'ITEM1'], [Blockly.Msg.RAILBLOCKS_CONDITIONAL_SECOND, 'ITEM2']]), `CONTACT${index}`) - .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_MIDDLE) - .appendField(new Blockly.FieldDropdown(segName), `SEGMENT${index}`) - .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_END) - block.appendStatementInput(`COND_BLOCK${index}`) + const input = block.appendValueInput(`COND_BOOL${index}`) + .setCheck('Boolean') + + if (index !== 0) { + input.appendField(new Blockly.FieldLabel(' ')) // empty placeholder + } + + input + .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_START) + .appendField( + new Blockly.FieldDropdown([ + [Blockly.Msg.RAILBLOCKS_CONDITIONAL_FIRST, 'ITEM1'], + [Blockly.Msg.RAILBLOCKS_CONDITIONAL_SECOND, 'ITEM2'] + ]), + `CONTACT${index}` + ) + .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_MIDDLE) + .appendField(new Blockly.FieldDropdown(segName), `SEGMENT${index}`) + .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_END_1) + +block.appendDummyInput(`COND_DUMMY${index}`) + .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TEXT_END_2) + +block.appendStatementInput(`COND_BLOCK${index}`) } // Dynamic branch statement @@ -102,7 +119,6 @@ Blockly.Blocks.ConditionalStatementD = { // Append a named input which contains the +/- buttons and the text. this.appendDummyInput('BRANCH_ROOT') .appendField(new FieldPlusMinus(), 'PM_FIELD') - .appendField(Blockly.Msg.RAILBLOCKS_CONDITIONAL_TITLE_TEXT) // Only allow this block to follow statements and only allow statements to be appended to this. this.setPreviousStatement('CStatement') @@ -140,8 +156,9 @@ Blockly.Blocks.ConditionalStatementD = { while (diff !== 0) { // Case when inputs have to be removed. if (diff > 0) { + this.removeInput(`COND_BOOL${this.currInputs}`) + this.removeInput(`COND_DUMMY${this.currInputs}`) this.removeInput(`COND_BLOCK${this.currInputs}`) - this.removeInput(`COND_BLOCK${this.currInputs}_TEXT`) this.currInputs-- // Case when inputs have to be appended. } else if (diff < 0) { @@ -169,7 +186,7 @@ Blockly.Blocks.TrackStatement = { this.setPreviousStatement('CStatement') this.setNextStatement('CStatement') - this.setColour(25) + this.setColour(0) this.setTooltip(Blockly.Msg.RAILBLOCKS_TRACK_TOOLTIP) this.inputCount = 1 @@ -242,7 +259,7 @@ Blockly.Blocks.TrackStatementALT = { this.setPreviousStatement('CStatement') this.setNextStatement('CStatement') this.setInputsInline(false) - this.setColour(25) + this.setColour(0) this.setTooltip(Blockly.Msg.RAILBLOCKS_TRACK_TOOLTIP) this.inputCount = 1 @@ -299,7 +316,7 @@ Blockly.Blocks.PointStatement = { .appendField(Blockly.Msg.RAILBLOCKS_POINT_TEXT_START, 'SET_POINT_FIELD') this.setPreviousStatement('CStatement') this.setNextStatement('CStatement') - this.setColour(0) + this.setColour(20) this.setTooltip(Blockly.Msg.RAILBLOCKS_POINT_TOOLTIP) this.inputCount = 1 @@ -311,45 +328,39 @@ Blockly.Blocks.PointStatement = { domToMutation, updateShape: function () { - // get current status for recreation after +- const branchOption = this.getFieldValue('BRANCH_OPTION') - const values = [] - for (let i = 0; i < this.inputCount; i++) { - values.push( - this.getField('NUMBER' + i) - ? this.getField('NUMBER' + i).getValue() - : '0' - ) - } - - const dummyInput = this.getInput('SET_POINT') - const toRemove = [] + let currentCount = 0 + while (this.getInput('NUMBER_INPUT_' + currentCount)) { + currentCount++ + } - for (let i = 0; i < dummyInput.fieldRow.length; i++) { - const name = dummyInput.fieldRow[i].name - if (!name || name[0] === 'N' || name[0] === 'B') { toRemove.push(name) } - } - for (let i = 0; i < toRemove.length; i++) { - dummyInput.removeField(toRemove[i]) - } + // Remove the trailing dummy so new number inputs can stay before it. + if (this.getInput('POINT_END')) { + this.removeInput('POINT_END') + } - for (let i = 0; i < this.inputCount; i++) { - if (i === 0) { - dummyInput.appendField(new Blockly.FieldNumber( - 0, 0, 29 - ), 'NUMBER' + i) - } else { - dummyInput.appendField(',') - .appendField(new Blockly.FieldNumber( - 0, 0, 29 - ), 'NUMBER' + i) + while (currentCount > this.inputCount) { + this.removeInput('NUMBER_INPUT_' + (currentCount - 1)) + currentCount-- } - this.setFieldValue(values[i], 'NUMBER' + i) - } + while (currentCount < this.inputCount) { + const input = this.appendValueInput('NUMBER_INPUT_' + currentCount) + .setCheck(['Number', 'int_range']) + + if (currentCount !== 0) { + input.appendField(',') + } + + // Only inject shadows when the input is newly created. + ensureShadow(this, 'NUMBER_INPUT_' + currentCount) + currentCount++ + } + // Append the last portion of this block. - dummyInput.appendField(Blockly.Msg.RAILBLOCKS_POINT_TEXT_END) + this.appendDummyInput('POINT_END') + .appendField(Blockly.Msg.RAILBLOCKS_POINT_TEXT_END) .appendField(new Blockly.FieldDropdown( [ [Blockly.Msg.RAILBLOCKS_POINT_STRAIGHT, 'ITEM1'], @@ -370,7 +381,7 @@ Blockly.Blocks.LightStatement = { .appendField(Blockly.Msg.RAILBLOCKS_LIGHTS_TEXT_START, 'SET_LIGHT_FIELD') this.setPreviousStatement('CStatement') this.setNextStatement('CStatement') - this.setColour(60) + this.setColour(40) this.setTooltip(Blockly.Msg.RAILBLOCKS_LIGHTS_TOOLTIP) this.inputCount = 1 @@ -381,45 +392,39 @@ Blockly.Blocks.LightStatement = { domToMutation, - updateShape: function () { - // get current status for recreation after +- - const lightStatus = this.getFieldValue('LIGHT_STATUS') - - const values = [] - for (let i = 0; i < this.inputCount; i++) { - values.push( - this.getField('NUMBER' + i) - ? this.getField('NUMBER' + i).getValue() - : '0' - ) - } + updateShape: function () { + const lightStatus = this.getFieldValue('LIGHT_STATUS') - const valueInput = this.getInput('SET_LIGHT') - const toRemove = [] + let currentCount = 0 + while (this.getInput('NUMBER_INPUT_' + currentCount)) { + currentCount++ + } - for (let i = 0; i < valueInput.fieldRow.length; i++) { - const name = valueInput.fieldRow[i].name - if (!name || name[0] === 'N' || name[0] === 'L') { toRemove.push(name) } - } - for (let i = 0; i < toRemove.length; i++) { - valueInput.removeField(toRemove[i]) - } + // Remove the trailing dummy so new number inputs can stay before it. + if (this.getInput('LIGHT_END')) { + this.removeInput('LIGHT_END') + } - for (let i = 0; i < this.inputCount; i++) { - if (i === 0) { - valueInput.appendField(new Blockly.FieldNumber( - 0, 0, 23 - ), 'NUMBER' + i) - } else { - valueInput.appendField(',') - .appendField(new Blockly.FieldNumber( - 0, 0, 23 - ), 'NUMBER' + i) + while (currentCount > this.inputCount) { + this.removeInput('NUMBER_INPUT_' + (currentCount - 1)) + currentCount-- } - this.setFieldValue(values[i], 'NUMBER' + i) + + while (currentCount < this.inputCount) { + const input = this.appendValueInput('NUMBER_INPUT_' + currentCount) + .setCheck(['Number', 'int_range']) + + if (currentCount !== 0) { + input.appendField(',') + } + + // Only inject shadows when the input is newly created. + ensureShadow(this, 'NUMBER_INPUT_' + currentCount) + currentCount++ } - valueInput.appendField(Blockly.Msg.RAILBLOCKS_LIGHTS_TEXT_END) + this.appendDummyInput('LIGHT_END') + .appendField(Blockly.Msg.RAILBLOCKS_LIGHTS_TEXT_END) .appendField(new Blockly.FieldDropdown( [ [Blockly.Msg.RAILBLOCKS_LIGHTS_ON, 'ITEM1'], @@ -430,5 +435,18 @@ Blockly.Blocks.LightStatement = { if (lightStatus) { this.setFieldValue(lightStatus, 'LIGHT_STATUS') } - } + } +} + +// Injects a int_number shadow into an input if nothing is connected yet +export function ensureShadow(block, inputName) { + const input = block.getInput(inputName) + if (!input || input.connection.targetBlock()) return + + const shadowBlock = block.workspace.newBlock('int_number') + shadowBlock.initSvg?.() + shadowBlock.setShadow(true) + shadowBlock.getField('NUM').setValue(0) + shadowBlock.render?.() + input.connection.connect(shadowBlock.outputConnection) } diff --git a/frontend/fields.js b/frontend/fields.js index 40ea492..76cc062 100644 --- a/frontend/fields.js +++ b/frontend/fields.js @@ -20,6 +20,7 @@ import * as Blockly from 'blockly/core' import { BlockSvg } from 'blockly/core' +import { ensureShadow } from './dynamic_blocks' /** * Class for field that adds a Plus and Minus button that increment/decrement @@ -192,6 +193,7 @@ export class FieldPlusMinus extends Blockly.Field { increase_count () { this.sourceBlock_.inputCount++ this.sourceBlock_.updateShape() + ensureShadow(this.sourceBlock_, 'NUMBER_INPUT_' + (this.sourceBlock_.inputCount - 1)) } /** diff --git a/frontend/generator.js b/frontend/generator.js index 9f0c2d5..77e808a 100644 --- a/frontend/generator.js +++ b/frontend/generator.js @@ -18,6 +18,7 @@ import * as Blockly from 'blockly/core' +import 'blockly/javascript' import { segNameMap } from './consts' // Enum internally used by Blockly. @@ -132,18 +133,42 @@ generator.forBlock.TrackStatementALT = (block, gen) => { return NEXT_COMMAND ? COMMAND + '\n' + gen.blockToCode(NEXT_COMMAND) : COMMAND } +// return a list of numbers from start to end inclusive +generator.forBlock.int_range = (block) => { + const start = parseInt(block.getFieldValue('START')) || 0 + const end = parseInt(block.getFieldValue('END')) || 0 + + const numbers = [] + if (start > end) { + for (let i = start; i >= end; i--) { + numbers.push(i) + } + } else { + for (let i = start; i <= end; i++) { + numbers.push(i) + } + } + + return [numbers.join(', '), Order.ATOMIC] +} + +// return an integer number +generator.forBlock.int_number = (block) => { + return [block.getFieldValue('NUM') || '0', Order.ATOMIC] +} + generator.forBlock.PointStatement = (block, gen) => { let COMMAND = 'Set point ' const tracks = [] for (let i = 0; i < block.inputCount; i++) { - tracks.push(block.getFieldValue('NUMBER' + i)) + tracks.push(gen.valueToCode(block, 'NUMBER_INPUT_' + i, Order.ATOMIC)) } // Same thing as before. const BRANCH_OPTION = ['straight', 'branch'][block.getFieldValue('BRANCH_OPTION')[4] - 1] - COMMAND += tracks.toString() + ' to ' + BRANCH_OPTION + '.' + COMMAND += tracks.join(', ') + ' to ' + BRANCH_OPTION + '.' const NEXT_COMMAND = block.getNextBlock() return NEXT_COMMAND ? COMMAND + '\n' + gen.blockToCode(NEXT_COMMAND) : COMMAND @@ -154,23 +179,39 @@ generator.forBlock.LightStatement = (block, gen) => { const tracks = [] for (let i = 0; i < block.inputCount; i++) { - tracks.push(block.getFieldValue('NUMBER' + i)) + tracks.push(gen.valueToCode(block, 'NUMBER_INPUT_' + i, Order.ATOMIC)) } // Same thing as before. const LIGHT_STATUS = ['on', 'off'][block.getFieldValue('LIGHT_STATUS')[4] - 1] - COMMAND += tracks.toString() + ' ' + LIGHT_STATUS + '.' + COMMAND += tracks.join(', ') + ' ' + LIGHT_STATUS + '.' const NEXT_COMMAND = block.getNextBlock() return NEXT_COMMAND ? COMMAND + '\n' + gen.blockToCode(NEXT_COMMAND) : COMMAND } +// return either reached or Reach depending on the block because of railsl +generator.forBlock.reached = (block) => { + if (block.parentBlock_ && block.parentBlock_.type === "ConditionalStatementD") { + return ['reached', Order.ATOMIC] + } + return ['Reach', Order.ATOMIC] +} + +// return either passed or Pass depending on the block because of railsl +generator.forBlock.passed = (block) => { + if (block.parentBlock_ && block.parentBlock_.type === "ConditionalStatementD") { + return ['passed', Order.ATOMIC] + } + return ['Pass', Order.ATOMIC] +} + generator.forBlock.ContactWaitStatement = (block, gen) => { const NUMBER = ['first', 'second'][block.getFieldValue('NUMBER')[4] - 1] - const CONTACT = ['Reach', 'Pass'][block.getFieldValue('CONTACT')[4] - 1] + const TRACK_SENSOR_VALUE = gen.valueToCode(block, 'track_sensor_value', Order.ATOMIC) const SEGMENT = segNameMap.get(block.getFieldValue('SEGMENT')) - const COMMAND = CONTACT + ' ' + NUMBER + ' contact of ' + SEGMENT + '.' + const COMMAND = TRACK_SENSOR_VALUE + ' ' + NUMBER + ' contact of ' + SEGMENT + '.' const NEXT_COMMAND = block.getNextBlock() return NEXT_COMMAND ? COMMAND + '\n' + gen.blockToCode(NEXT_COMMAND) : COMMAND @@ -240,13 +281,16 @@ generator.forBlock.ConditionalStatementD = (block, gen) => { const COND_BLOCK = gen.statementToCode(block, 'COND_BLOCK' + i) const CONTACT = ['first', 'second'][block.getFieldValue('CONTACT' + i)[4] - 1] const SEGMENT = segNameMap.get(block.getFieldValue('SEGMENT' + i)) + const TRACK_SENSOR_VALUE = gen.valueToCode(block, 'COND_BOOL' + i, Order.ATOMIC) COMMAND += 'If ' + CONTACT + ' contact of ' + SEGMENT + - ' is reached first, do\n' + + ' is ' + + TRACK_SENSOR_VALUE + + ' first, do\n' + 'Start:\n' + COND_BLOCK + '\nEnd.\n' @@ -263,6 +307,7 @@ generator.forBlock.ConditionalStatement = (block, gen) => { const CONTACT1 = ['first', 'second'][block.getFieldValue('CONTACT1')[4] - 1] const SEGMENT0 = segNameMap.get(block.getFieldValue('SEGMENT0')) const SEGMENT1 = segNameMap.get(block.getFieldValue('SEGMENT1')) + const TRACK_SENSOR_VALUE = gen.valueToCode(block, 'track_sensor_value', Order.ATOMIC) const COND_BLOCK0 = gen.statementToCode(block, 'COND_BLOCK0') const COND_BLOCK1 = gen.statementToCode(block, 'COND_BLOCK1') @@ -274,7 +319,9 @@ generator.forBlock.ConditionalStatement = (block, gen) => { CONTACT0 + ' contact of ' + SEGMENT0 + - ' is reached first, do\n' + + ' is ' + + TRACK_SENSOR_VALUE + + ' first, do\n' + 'Start:\n' + COND_BLOCK0 + '\nEnd.\n' + diff --git a/frontend/main.js b/frontend/main.js index 08c90f2..5d0ef2e 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -19,8 +19,9 @@ import * as Blockly from 'blockly/core' -import './renderer.js' +import './zelos_renderer.js' import './dynamic_blocks.js' +import './validators.js' import { blockDefinitionsJson, createToolbox } from './blocks.js' import { compile } from './generator.js' import { getLabel, getToolBoxLabels, getHtmlLabels, getStoredLanguage, applyLanguage, LANGUAGE_STORAGE_KEY } from './localization.js' @@ -89,7 +90,7 @@ const workspace = Blockly.inject('blocklyDiv', { theme, // Load the custom renderer defined in renderer.js // Should not make a big difference but is advised to be loaded for extensions. - renderer: 'thrasos_extended', + renderer: 'zelos_renderer', move: { scrollbars: { horizontal: true, @@ -115,6 +116,7 @@ function restoreWorkspaceState (workspace) { const state = JSON.parse(savedWorkspace) Blockly.serialization.workspaces.load(state, workspace) window.localStorage.removeItem(workspaceStorageKey) + workspace.render() return true } catch (error) { window.localStorage.removeItem(workspaceStorageKey) @@ -210,8 +212,15 @@ function removeBlockWarning (block, warningKey) { function markUnusedBlocks (workspace) { // Mark all unused blocks workspace.getAllBlocks().forEach(block => { + // skip for shadow blocks; only show for parents + if (block.shadow) return + if ((block.type === 'int_range' || block.type === 'reached' || block.type === 'passed') && block.parentBlock_ !== null) { + removeBlockWarning(block, 'unused') + block.unused = false + } + // getRootBlock() returns the topmost block in a stack. - if (!block.unused && block.getRootBlock().id !== 'ROOT') { + else if (!block.unused && block.getRootBlock().id !== 'ROOT') { addBlockWarning(block, 'unused', Blockly.Msg.RAILBLOCKS_WARNING_UNUSED) block.unused = true } else if (block.unused && block.getRootBlock().id === 'ROOT') { @@ -221,6 +230,30 @@ function markUnusedBlocks (workspace) { }) } +/** + * Warns the user by indicating all ConditionalStatementD blocks that have a passed inside them. + * @param {Blockly.WorkspaceSvg} workspace The workspace to scan. + */ +function markPassedConditionalStatement (workspace) { + workspace.getAllBlocks().forEach(block => { + // is "passed" block in ConditionalStatementD? + if (block.type === 'passed' && block.parentBlock_?.type === 'ConditionalStatementD') { + const parent = block.parentBlock_ + if (!parent.passed) { + parent.passed = true + addBlockWarning(parent, 'passed', Blockly.Msg.RAILBLOCKS_WARNING_PASSED) + } + } else if (block.passed) { + // remove passed when child block no longer exists + const hasPassed = block.getChildren().some(child => child.type === 'passed') + block.passed = hasPassed + if (!hasPassed) { + removeBlockWarning(block, 'passed') + } + } + }) +} + /** * Helper function that checks if a block with input statements contains or is a loop statement. * @param {Blockly.Block} block Block to be searched in. @@ -302,12 +335,17 @@ workspace.addChangeListener((event) => { // Send all code to the output div. compile(workspace) - if (event.type === Blockly.Events.BLOCK_MOVE) { + if (event.type === Blockly.Events.BLOCK_MOVE || event.type === Blockly.Events.FINISHED_LOADING) { // Mark blocks not inside the program block. markUnusedBlocks(workspace) // Mark Branch and Parallel blocks if they contain a loop. markWarnings(workspace) } + if (event.type === Blockly.Events.BLOCK_DRAG) { + // Mark ConditionalStatementD blocks that have a "passed" block in themself + markPassedConditionalStatement(workspace) + } + // Mark blocks that have unconnected connections. markUnconnectedBlocks(workspace) @@ -459,6 +497,7 @@ document.getElementById('file_load').addEventListener('change', (event) => { // (toString because type safety) const state = JSON.parse(fr.result.toString()) Blockly.serialization.workspaces.load(state, workspace) + workspace.render() } // ... and load it. fr.readAsText(files[0]) diff --git a/frontend/validators.js b/frontend/validators.js new file mode 100644 index 0000000..4ec15cb --- /dev/null +++ b/frontend/validators.js @@ -0,0 +1,85 @@ +/* + * RailBlocks - A Blockly RailSL Implementation + * + * https://github.com/kieler/RailBlocks + * + * Copyright 2025-2026 by + * + Tokessa Hamann and + * + Henri Heyden and + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made + * available under the terms of the MIT License which + * is available at https://opensource.org/license/MIT. + * + * SPDX-License-Identifier: MIT + */ + +import * as Blockly from 'blockly' + +// constraints for number of lights and number of switch points +const light_constraints = [0, 23] +const switch_constraints = [0, 29] + +// Map of parent block type to constraint range +const parentConstraints = { + PointStatement: switch_constraints, + LightStatement: light_constraints +} +const defaultConstraints = [-Infinity, Infinity] // no constraint by default + +function getConstraintsFor (block) { + const parentType = block.parentBlock_?.type + return parentConstraints[parentType] || defaultConstraints +} + +// Reusable validator factory +function makeRangeValidator (block) { + return function (newValue) { + const [min, max] = getConstraintsFor(block) + const value = Number(newValue) + + if (value < min || value > max) { + return String(Math.min(Math.max(value, min), max)) // clamp into range + } + + return newValue + } +} + +function revalidateField (block, name) { + const field = block.getField(name) + if (field) { + // Re-running setValue with the field's own current value forces the validator to execute again + field.setValue(field.getValue()) + } +} + + +// validator for int_number +Blockly.Extensions.register('dynamic_int_number_validator', function () { + const field = this.getField('NUM') + if (field) { + field.setValidator(makeRangeValidator(this)) + } +}) + + +// validator for int_range +Blockly.Extensions.register('dynamic_int_range_validator', function () { + const block = this + ;['START', 'END'].forEach(name => { + const field = block.getField(name) + if (field) { + field.setValidator(makeRangeValidator(block)) + } + }) + + // when block is moved into a new parent, re-evaluate + block.workspace.addChangeListener(function (event) { + if (event.type !== Blockly.Events.BLOCK_MOVE) return + ;['START', 'END'].forEach(name => revalidateField(block, name)) + }) +}) diff --git a/frontend/zelos_renderer.js b/frontend/zelos_renderer.js new file mode 100644 index 0000000..aeeb303 --- /dev/null +++ b/frontend/zelos_renderer.js @@ -0,0 +1,142 @@ +/* + * RailBlocks - A Blockly RailSL Implementation + * + * https://github.com/kieler/RailBlocks + * + * Copyright 2026 by + * + Tokessa Hamann and + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made + * available under the terms of the MIT License which + * is available at https://opensource.org/license/MIT. + * + * SPDX-License-Identifier: MIT + */ + +import * as Blockly from 'blockly/core' + +// CUSTOM BUILD RENDERER TO IMPLEMENT EASY NEW CONNECTION SHAPES + +/** + * Class extending the Zelos renderer constants to define custom shapes in and add them to connection checks. + */ +class ZelosExtendedConstantProvider extends Blockly.zelos.Renderer { + constructor(name) { + super(name); + } + + makeConstants_() { + return new CustomConstants() + } +} + +class CustomConstants extends Blockly.zelos.ConstantProvider { + init() { + super.init() + + const maxWidth = this.MAX_DYNAMIC_CONNECTION_SHAPE_WIDTH; + + function lineTo(dx, dy) { + return ' l ' + dx + ',' + dy + ' '; + } + + + // creates Hexagon Shape (currently not in use) + // eslint-disable-next-line no-unused-vars + function makeMainPathHex(height, up, right) { + const halfHeight = height / 2; + const width = halfHeight > maxWidth ? maxWidth : halfHeight; + const forward = up ? -1 : 1; + const direction = right ? -1 : 1; + + const cornerSize = width / 2; // diagonal corner cut size + const dyCorner = (forward * height) / 4; + const dyFlat = (forward * height) / 2 - dyCorner; + + return ( + lineTo(-direction * cornerSize, dyCorner) + + lineTo(-direction * (width - cornerSize), dyFlat) + + lineTo(direction * (width - cornerSize), dyFlat) + + lineTo(direction * cornerSize, dyCorner) + ); + } + + // Creates Octagon shape + function makeMainPathOct(height, up, right) { + const halfHeight = height / 2; + const width = halfHeight > maxWidth ? maxWidth : halfHeight; + const forward = up ? -1 : 1; + const direction = right ? -1 : 1; + + // Split the half-height into 3 parts: corner / straight / corner + const cornerHeight = halfHeight / 2; + const straightHeight = halfHeight; + const cornerWidth = width / 2; + + const dyCorner = forward * cornerHeight; + const dyStraight = forward * straightHeight; + + return ( + // top/bottom flat horizontal edge + lineTo(-direction * (width - cornerWidth), 0) + + // diagonal corner + lineTo(-direction * cornerWidth, dyCorner) + + // vertical edge + lineTo(0, dyStraight) + + // diagonal corner (mirrors the first) + lineTo(direction * cornerWidth, dyCorner) + + // bottom/top flat horizontal edge + lineTo(direction * (width - cornerWidth), 0) + ); + } + + this.C_SET_VECTOR_SHAPE = { + type: this.SHAPES.HEXAGONAL, + isDynamic: true, + width(height) { + const halfHeight = height / 2; + return halfHeight > maxWidth ? maxWidth : halfHeight; + }, + height(height) { + return height; + }, + connectionOffsetY(connectionHeight) { + return connectionHeight / 2; + }, + connectionOffsetX(connectionWidth) { + return -connectionWidth; + }, + pathDown(height) { + return makeMainPathOct(height, false, false); + }, + pathUp(height) { + return makeMainPathOct(height, true, false); + }, + pathRightDown(height) { + return makeMainPathOct(height, false, true); + }, + pathRightUp(height) { + return makeMainPathOct(height, false, true); + }, + } + } + + shapeFor(connection) { + let check = connection.getCheck() + + if (!check && connection.targetConnection) { + check = connection.targetConnection.getCheck() + } + + if (check && check.includes('CSetVector')) return this.C_SET_VECTOR_SHAPE + + return super.shapeFor(connection) + + } +} + +Blockly.blockRendering.register('zelos_renderer', ZelosExtendedConstantProvider); + diff --git a/locales/de/de_locale.js b/locales/de/de_locale.js index 6927ae0..f1fa0ea 100644 --- a/locales/de/de_locale.js +++ b/locales/de/de_locale.js @@ -40,6 +40,8 @@ export const de_locale = { toolboxLabels: { setStatements: 'Setzanweisungen', waitStatements: 'Warteanweisungen', - controlFlow: 'Kontrollfluss' + controlFlow: 'Kontrollfluss', + sensorValues: 'Gleissensorwerte', + numbers: 'Nummern', } }; diff --git a/locales/de/de_tokens.js b/locales/de/de_tokens.js index 0f34655..fede577 100644 --- a/locales/de/de_tokens.js +++ b/locales/de/de_tokens.js @@ -39,6 +39,9 @@ export const de_tokens = { RAILBLOCKS_CONTACT_WAIT_FIRST: 'erster', RAILBLOCKS_CONTACT_WAIT_SECOND: 'zweiter', + RAILBLOCKS_INT_RANGE_TEXT: '%1 bis %2', + RAILBLOCKS_INT_RANGE_TOOLTIP: 'Erstellt eine Reihe von Zahlen.', + RAILBLOCKS_TIME_WAIT_TEXT: 'Warte für %1 Sekunde(n)', RAILBLOCKS_TIME_WAIT_TOOLTIP: 'Tue nichts für eine bestimmte Zeit.', @@ -53,7 +56,8 @@ export const de_tokens = { RAILBLOCKS_CONDITIONAL_SECOND: 'zweiter', RAILBLOCKS_CONDITIONAL_TEXT_START: 'Wenn', RAILBLOCKS_CONDITIONAL_TEXT_MIDDLE: 'Kontakt von', - RAILBLOCKS_CONDITIONAL_TEXT_END: 'zuerst ausgelöst wurde', + RAILBLOCKS_CONDITIONAL_TEXT_END_1: 'zuerst', + RAILBLOCKS_CONDITIONAL_TEXT_END_2: 'wurde', RAILBLOCKS_CONDITIONAL_TITLE_TEXT: 'Entweder Oder', RAILBLOCKS_PARALLEL_TEXT: 'Parallel', @@ -79,4 +83,5 @@ export const de_tokens = { RAILBLOCKS_WARNING_UNREACHABLE_WEAK: 'Blöcke nach diesem können vielleicht nicht erreicht werden, da sich eine Wiederholung innerhalb dieses Blocks befindet.', RAILBLOCKS_WARNING_EMPTY_INPUT: 'Dieser Block hat leere Felder und wird einen Syntaxfehler verursachen!', RAILBLOCKS_WARNING_UNUSED: 'Unbenutzter Block', + RAILBLOCKS_WARNING_PASSED: 'Der "passiert" Block kann momentan nicht in diesem Block genutzt werden.', } diff --git a/locales/en/en_locale.js b/locales/en/en_locale.js index 4347367..e29104a 100644 --- a/locales/en/en_locale.js +++ b/locales/en/en_locale.js @@ -40,6 +40,8 @@ export const en_locale = { toolboxLabels: { setStatements: 'Set Statements', waitStatements: 'Wait Statements', - controlFlow: 'Control Flow' + controlFlow: 'Control Flow', + sensorValues: 'Track Sensor Values', + numbers: 'Numbers', } }; diff --git a/locales/en/en_tokens.js b/locales/en/en_tokens.js index eb49cdd..7cd26d7 100644 --- a/locales/en/en_tokens.js +++ b/locales/en/en_tokens.js @@ -39,6 +39,9 @@ export const en_tokens = { RAILBLOCKS_CONTACT_WAIT_FIRST: 'first', RAILBLOCKS_CONTACT_WAIT_SECOND: 'second', + RAILBLOCKS_INT_RANGE_TEXT: '%1 to %2', + RAILBLOCKS_INT_RANGE_TOOLTIP: 'Creates a range of numbers.', + RAILBLOCKS_TIME_WAIT_TEXT: 'Wait for %1 second(s)', RAILBLOCKS_TIME_WAIT_TOOLTIP: 'Do nothing for a certain amount of time.', @@ -47,13 +50,14 @@ export const en_tokens = { RAILBLOCKS_CROSSING_OPEN: 'Open', RAILBLOCKS_CROSSING_CLOSE: 'Close', - RAILBLOCKS_CONDITIONAL_TEXT: 'If %1 contact of %2 is reached first\n%3else if %4 contact of %5 is reached first', + RAILBLOCKS_CONDITIONAL_TEXT: 'If %1 contact of %2 is %3 first\n else if %4 contact of %5 is %6 first', RAILBLOCKS_CONDITIONAL_TOOLTIP: 'Execute different blocks depending on which track is reached first by a train.', RAILBLOCKS_CONDITIONAL_FIRST: 'first', RAILBLOCKS_CONDITIONAL_SECOND: 'second', RAILBLOCKS_CONDITIONAL_TEXT_START: 'If', RAILBLOCKS_CONDITIONAL_TEXT_MIDDLE: 'contact of', - RAILBLOCKS_CONDITIONAL_TEXT_END: 'is reached first', + RAILBLOCKS_CONDITIONAL_TEXT_END_1: 'is', + RAILBLOCKS_CONDITIONAL_TEXT_END_2: 'first', RAILBLOCKS_CONDITIONAL_TITLE_TEXT: 'Branch', RAILBLOCKS_PARALLEL_TEXT: 'Parallel', @@ -79,4 +83,5 @@ export const en_tokens = { RAILBLOCKS_WARNING_UNREACHABLE_WEAK: 'Blocks after this may not be reached because of a loop inside this.', RAILBLOCKS_WARNING_EMPTY_INPUT: 'This block has empty inputs and will cause a syntax error!', RAILBLOCKS_WARNING_UNUSED: 'Unused block', + RAILBLOCKS_WARNING_PASSED: 'The "passed" block can currently not be used in this block.', } diff --git a/package-lock.json b/package-lock.json index 779c0d2..21b8943 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2393,14 +2393,14 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.8.4", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -4320,9 +4320,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4746,9 +4746,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -4824,6 +4824,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, + "license": "MIT", "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", @@ -5056,9 +5057,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0"