diff --git a/src/blocks/mrc_class_method_def.ts b/src/blocks/mrc_class_method_def.ts index b4696dfc..e67d5d4a 100644 --- a/src/blocks/mrc_class_method_def.ts +++ b/src/blocks/mrc_class_method_def.ts @@ -22,16 +22,20 @@ import * as Blockly from 'blockly'; import { MRC_STYLE_CLASS_BLOCKS } from '../themes/styles'; import { createFieldNonEditableText } from '../fields/FieldNonEditableText' +import { createFieldFlydown } from '../fields/field_flydown'; import * as ChangeFramework from './utils/change_framework' import { getLegalName } from './utils/python'; import { Order } from 'blockly/python'; import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; import { renameMethodCallers, mutateMethodCallers } from './mrc_call_python_function' +import { findConnectedBlocksOfType } from './utils/find_connected_blocks'; +import { BLOCK_NAME as MRC_GET_PARAMETER_BLOCK_NAME } from './mrc_get_parameter'; + export const BLOCK_NAME = 'mrc_class_method_def'; -const MUTATOR_BLOCK_NAME = 'methods_mutatorarg'; -const PARAM_CONTAINER_BLOCK_NAME = 'method_param_container'; +export const MUTATOR_BLOCK_NAME = 'methods_mutatorarg'; +const PARAM_CONTAINER_BLOCK_NAME = 'method_param_container'; export type Parameter = { name: string, @@ -87,8 +91,7 @@ const CLASS_METHOD_DEF = { */ init: function (this: ClassMethodDefBlock): void { this.appendDummyInput("TITLE") - .appendField('', 'NAME') - .appendField('', 'PARAMS'); + .appendField('', 'NAME'); this.setOutput(false); this.setStyle(MRC_STYLE_CLASS_BLOCKS); this.appendStatementInput('STACK').appendField(''); @@ -147,11 +150,11 @@ const CLASS_METHOD_DEF = { updateBlock_: function (this: ClassMethodDefBlock): void { const name = this.getFieldValue('NAME'); const input = this.getInput('TITLE'); - if (!input){ + if (!input) { return; } input.removeField('NAME'); - + if (this.mrcCanChangeSignature) { const nameField = new Blockly.FieldTextInput(name); input.insertFieldAt(0, nameField, 'NAME'); @@ -161,7 +164,7 @@ const CLASS_METHOD_DEF = { else { input.insertFieldAt(0, createFieldNonEditableText(name), 'NAME'); //Case because a current bug in blockly where it won't allow passing null to Blockly.Block.setMutator makes it necessary. - (this as Blockly.BlockSvg).setMutator( null ); + (this as Blockly.BlockSvg).setMutator(null); } this.mrcUpdateParams(); }, @@ -170,10 +173,15 @@ const CLASS_METHOD_DEF = { this.mrcParameters = []; let paramBlock = containerBlock.getInputTargetBlock('STACK'); - while (paramBlock && !paramBlock.isInsertionMarker()) { - const param : Parameter = { - name : paramBlock.getFieldValue('NAME'), - type : '' + while (paramBlock) { + const param: Parameter = { + name: paramBlock.getFieldValue('NAME'), + type: '' + } + if (paramBlock.originalName) { + // This is a mutator arg block, so we can get the original name. + this.mrcRenameParameter(paramBlock.originalName, param.name); + paramBlock.originalName = param.name; } this.mrcParameters.push(param); paramBlock = @@ -194,47 +202,60 @@ const CLASS_METHOD_DEF = { for (let i = 0; i < this.mrcParameters.length; i++) { let itemBlock = workspace.newBlock(MUTATOR_BLOCK_NAME); (itemBlock as Blockly.BlockSvg).initSvg(); - itemBlock.setFieldValue(this.mrcParameters[i].name, 'NAME') + itemBlock.setFieldValue(this.mrcParameters[i].name, 'NAME'); + (itemBlock as MethodMutatorArgBlock).originalName = this.mrcParameters[i].name; connection!.connect(itemBlock.previousConnection!); connection = itemBlock.nextConnection; } return topBlock; }, - mrcUpdateParams: function (this : ClassMethodDefBlock) { - let paramString = ''; - if (this.mrcParameters.length > 0){ - this.mrcParameters.forEach((param) => { - if (paramString != '') { - paramString += ', '; + mrcRenameParameter: function (this: ClassMethodDefBlock, oldName: string, newName: string) { + let nextBlock = this.getInputTargetBlock('STACK'); + + if(nextBlock){ + findConnectedBlocksOfType(nextBlock, MRC_GET_PARAMETER_BLOCK_NAME).forEach((block) => { + if (block.getFieldValue('PARAMETER_NAME') === oldName) { + block.setFieldValue(newName, 'PARAMETER_NAME'); } - paramString += param.name; }); - paramString = Blockly.Msg['PROCEDURES_BEFORE_PARAMS'] + ' ' + paramString; } - // The params field is deterministic based on the mutation, - // no need to fire a change event. - Blockly.Events.disable(); - try { - this.setFieldValue(paramString, 'PARAMS'); - } finally { - Blockly.Events.enable(); + }, + mrcUpdateParams: function (this: ClassMethodDefBlock) { + if (this.mrcParameters.length > 0) { + let input = this.getInput('TITLE'); + if (input) { + this.removeParameterFields(input); + this.mrcParameters.forEach((param) => { + const paramName = 'PARAM_' + param.name; + input.appendField(createFieldFlydown(param.name, false), paramName); + }); + } } }, + removeParameterFields: function (input: Blockly.Input) { + const fieldsToRemove = input.fieldRow + .filter(field => field.name?.startsWith('PARAM_')) + .map(field => field.name!); + + fieldsToRemove.forEach(fieldName => { + input.removeField(fieldName); + }); + }, mrcNameFieldValidator(this: ClassMethodDefBlock, nameField: Blockly.FieldTextInput, name: string): string { - // When the user changes the method name on the block, clear the mrcPythonMethodName field. - this.mrcPythonMethodName = ''; - - // Strip leading and trailing whitespace. - name = name.trim(); - - const legalName = findLegalMethodName(name, this); - const oldName = nameField.getValue(); - if (oldName && oldName !== name && oldName !== legalName) { - // Rename any callers. - renameMethodCallers(this.workspace, oldName, legalName); - } - return legalName; + // When the user changes the method name on the block, clear the mrcPythonMethodName field. + this.mrcPythonMethodName = ''; + + // Strip leading and trailing whitespace. + name = name.trim(); + + const legalName = findLegalMethodName(name, this); + const oldName = nameField.getValue(); + if (oldName && oldName !== name && oldName !== legalName) { + // Rename any callers. + renameMethodCallers(this.workspace, oldName, legalName); + } + return legalName; }, }; @@ -248,21 +269,21 @@ const CLASS_METHOD_DEF = { * @returns Non-colliding name. */ function findLegalMethodName(name: string, block: ClassMethodDefBlock): string { - if (block.isInFlyout) { - // Flyouts can have multiple methods called 'my_method'. - return name; - } - name = name || 'unnamed'; - while (isMethodNameUsed(name, block.workspace, block)) { - // Collision with another method. - const r = name.match(/^(.*?)(\d+)$/); - if (!r) { - name += '2'; - } else { - name = r[1] + (parseInt(r[2]) + 1); + if (block.isInFlyout) { + // Flyouts can have multiple methods called 'my_method'. + return name; } - } - return name; + name = name || 'unnamed'; + while (isMethodNameUsed(name, block.workspace, block)) { + // Collision with another method. + const r = name.match(/^(.*?)(\d+)$/); + if (!r) { + name += '2'; + } else { + name = r[1] + (parseInt(r[2]) + 1); + } + } + return name; } /** @@ -276,25 +297,25 @@ function findLegalMethodName(name: string, block: ClassMethodDefBlock): string { */ function isMethodNameUsed( name: string, workspace: Blockly.Workspace, opt_exclude?: Blockly.Block): boolean { - const nameLowerCase = name.toLowerCase(); - for (const block of workspace.getBlocksByType('mrc_class_method_def')) { - if (block === opt_exclude) { - continue; - } - if (nameLowerCase === block.getFieldValue('NAME').toLowerCase()) { - return true; - } - const classMethodDefBlock = block as ClassMethodDefBlock; - if (classMethodDefBlock.mrcPythonMethodName && - nameLowerCase === classMethodDefBlock.mrcPythonMethodName.toLowerCase()) { - return true; + const nameLowerCase = name.toLowerCase(); + for (const block of workspace.getBlocksByType('mrc_class_method_def')) { + if (block === opt_exclude) { + continue; + } + if (nameLowerCase === block.getFieldValue('NAME').toLowerCase()) { + return true; + } + const classMethodDefBlock = block as ClassMethodDefBlock; + if (classMethodDefBlock.mrcPythonMethodName && + nameLowerCase === classMethodDefBlock.mrcPythonMethodName.toLowerCase()) { + return true; + } } - } - return false; + return false; } const METHOD_PARAM_CONTAINER = { - init: function (this : Blockly.Block) { + init: function (this: Blockly.Block) { this.appendDummyInput("TITLE").appendField('Parameters'); this.appendStatementInput('STACK'); this.setStyle(MRC_STYLE_CLASS_BLOCKS); @@ -303,12 +324,13 @@ const METHOD_PARAM_CONTAINER = { }; type MethodMutatorArgBlock = Blockly.Block & MethodMutatorArgMixin & Blockly.BlockSvg; -interface MethodMutatorArgMixin extends MethodMutatorArgMixinType{ - +interface MethodMutatorArgMixin extends MethodMutatorArgMixinType { + originalName: string, } + type MethodMutatorArgMixinType = typeof METHODS_MUTATORARG; -function setName(block: Blockly.BlockSvg){ +function setName(block: Blockly.BlockSvg) { const parentBlock = ChangeFramework.getParentOfType(block, PARAM_CONTAINER_BLOCK_NAME); if (parentBlock) { const variableBlocks = parentBlock!.getDescendants(true) @@ -318,37 +340,39 @@ function setName(block: Blockly.BlockSvg){ otherNames.push(variableBlock.getFieldValue('NAME')); } }); - const currentName = block.getFieldValue('NAME'); + const currentName = block.getFieldValue('NAME'); block.setFieldValue(getLegalName(currentName, otherNames), 'NAME'); updateMutatorFlyout(block.workspace); } } const METHODS_MUTATORARG = { - init: function (this : MethodMutatorArgBlock) { + init: function (this: MethodMutatorArgBlock) { this.appendDummyInput() .appendField(new Blockly.FieldTextInput(Blockly.Procedures.DEFAULT_ARG), 'NAME'); this.setPreviousStatement(true); this.setNextStatement(true); this.setStyle(MRC_STYLE_CLASS_BLOCKS); + this.originalName = ''; this.contextMenu = false; ChangeFramework.registerCallback(MUTATOR_BLOCK_NAME, [Blockly.Events.BLOCK_MOVE, Blockly.Events.BLOCK_CHANGE], this.onBlockChanged); }, onBlockChanged: function (block: Blockly.BlockSvg, blockEvent: Blockly.Events.BlockBase) { - if (blockEvent.type == Blockly.Events.BLOCK_MOVE){ + if (blockEvent.type == Blockly.Events.BLOCK_MOVE) { let blockMoveEvent = blockEvent as Blockly.Events.BlockMove; if (blockMoveEvent.reason?.includes('connect')) { - setName(block); + setName(block); } } - else{ - if(blockEvent.type == Blockly.Events.BLOCK_CHANGE){ + else { + if (blockEvent.type == Blockly.Events.BLOCK_CHANGE) { setName(block); } - } + } }, } + /** * Updates the procedure mutator's flyout so that the arg block is not a * duplicate of another arg. @@ -357,24 +381,24 @@ const METHODS_MUTATORARG = { * is what is being updated. */ function updateMutatorFlyout(workspace: Blockly.WorkspaceSvg) { - const usedNames = []; - const blocks = workspace.getBlocksByType(MUTATOR_BLOCK_NAME, false); - for (let i = 0, block; (block = blocks[i]); i++) { - usedNames.push(block.getFieldValue('NAME')); - } - const argValue = Blockly.Variables.generateUniqueNameFromOptions( - Blockly.Procedures.DEFAULT_ARG, - usedNames, - ); - const jsonBlock = { - kind: 'block', - type: MUTATOR_BLOCK_NAME, - fields: { - NAME: argValue, - }, - }; - - workspace.updateToolbox({contents:[jsonBlock]}); + const usedNames = []; + const blocks = workspace.getBlocksByType(MUTATOR_BLOCK_NAME, false); + for (let i = 0, block; (block = blocks[i]); i++) { + usedNames.push(block.getFieldValue('NAME')); + } + const argValue = Blockly.Variables.generateUniqueNameFromOptions( + Blockly.Procedures.DEFAULT_ARG, + usedNames, + ); + const jsonBlock = { + kind: 'block', + type: MUTATOR_BLOCK_NAME, + fields: { + NAME: argValue, + }, + }; + + workspace.updateToolbox({ contents: [jsonBlock] }); } @@ -386,37 +410,37 @@ function updateMutatorFlyout(workspace: Blockly.WorkspaceSvg) { * @internal */ export function mutatorOpenListener(e: Blockly.Events.Abstract) { - if (e.type != Blockly.Events.BUBBLE_OPEN){ - return; - } + if (e.type != Blockly.Events.BUBBLE_OPEN) { + return; + } const bubbleEvent = e as Blockly.Events.BubbleOpen; if ( - !(bubbleEvent.bubbleType === 'mutator' && bubbleEvent.isOpen) || - !bubbleEvent.blockId + !(bubbleEvent.bubbleType === 'mutator' && bubbleEvent.isOpen) || + !bubbleEvent.blockId ) { - return; + return; } const workspaceId = bubbleEvent.workspaceId; const block = Blockly.common - .getWorkspaceById(workspaceId)! - .getBlockById(bubbleEvent.blockId) as Blockly.BlockSvg; + .getWorkspaceById(workspaceId)! + .getBlockById(bubbleEvent.blockId) as Blockly.BlockSvg; if (block.type !== BLOCK_NAME) { - return; + return; } const workspace = ( - block.getIcon(Blockly.icons.MutatorIcon.TYPE) as Blockly.icons.MutatorIcon + block.getIcon(Blockly.icons.MutatorIcon.TYPE) as Blockly.icons.MutatorIcon ).getWorkspace()!; updateMutatorFlyout(workspace); ChangeFramework.setup(workspace); - } +} -export const setup = function() { - Blockly.Blocks[BLOCK_NAME] = CLASS_METHOD_DEF; - Blockly.Blocks[MUTATOR_BLOCK_NAME] = METHODS_MUTATORARG; - Blockly.Blocks[PARAM_CONTAINER_BLOCK_NAME] = METHOD_PARAM_CONTAINER; +export const setup = function () { + Blockly.Blocks[BLOCK_NAME] = CLASS_METHOD_DEF; + Blockly.Blocks[MUTATOR_BLOCK_NAME] = METHODS_MUTATORARG; + Blockly.Blocks[PARAM_CONTAINER_BLOCK_NAME] = METHOD_PARAM_CONTAINER; }; export const pythonFromBlock = function ( @@ -457,11 +481,11 @@ export const pythonFromBlock = function ( // After executing the function body, revisit this block for the return. xfix2 = xfix1; } - if(block.mrcPythonMethodName == '__init__'){ + if (block.mrcPythonMethodName == '__init__') { let class_specific = generator.getClassSpecificForInit(); branch = generator.INDENT + 'super().__init__(' + class_specific + ')\n' + generator.defineClassVariables() + branch; - } + } if (returnValue) { returnValue = generator.INDENT + 'return ' + returnValue + '\n'; } else if (!branch) { diff --git a/src/blocks/mrc_event.ts b/src/blocks/mrc_event.ts new file mode 100644 index 00000000..d9bdc89b --- /dev/null +++ b/src/blocks/mrc_event.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Creates an event that can be fired + * @author alan@porpoiseful.com (Alan Smith) + */ +import * as Blockly from 'blockly'; +import { Order } from 'blockly/python'; + +import { MRC_STYLE_EVENTS } from '../themes/styles' +import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; +import { MUTATOR_BLOCK_NAME } from './mrc_class_method_def' + +export const BLOCK_NAME = 'mrc_event'; +export const OUTPUT_NAME = 'mrc_event'; + +export type Parameter = { + name: string, + type?: string, +}; + +type EventExtraState = { + params?: Parameter[], +} + +type EventBlock = Blockly.Block & EventMixin & Blockly.BlockSvg; + +interface EventMixin extends EventMixinType { + mrcParams: Parameter[], +} +type EventMixinType = typeof EVENT; + +const EVENT = { + /** + * Block initialization. + */ + init: function (this: EventBlock): void { + this.setStyle(MRC_STYLE_EVENTS); + this.appendDummyInput() + .appendField(new Blockly.FieldTextInput('my_event'), 'NAME'); + this.setPreviousStatement(true, OUTPUT_NAME); + this.setNextStatement(true, OUTPUT_NAME); + this.setMutator(new Blockly.icons.MutatorIcon([MUTATOR_BLOCK_NAME], this)); + }, + + /** + * Returns the state of this block as a JSON serializable object. + */ + saveExtraState: function (this: EventBlock): EventExtraState { + const extraState: EventExtraState = { + }; + extraState.params = []; + if (this.mrcParams) { + this.mrcParams.forEach((arg) => { + extraState.params!.push({ + 'name': arg.name, + 'type': arg.type, + }); + }); + } + return extraState; + }, + /** + * Applies the given state to this block. + */ + loadExtraState: function (this: EventBlock, extraState: EventExtraState): void { + this.mrcParams = []; + + if (extraState.params) { + extraState.params.forEach((arg) => { + this.mrcParams.push({ + 'name': arg.name, + 'type': arg.type, + }); + }); + } + this.mrcParams = extraState.params ? extraState.params : []; + this.updateBlock_(); + }, + /** + * Update the block to reflect the newly loaded extra state. + */ + updateBlock_: function (this: EventBlock): void { + } +} + +export const setup = function () { + Blockly.Blocks[BLOCK_NAME] = EVENT; +} + +export const pythonFromBlock = function ( + block: EventBlock, + generator: ExtendedPythonGenerator, +) { + //TODO (Alan): What should this do here?? + return ''; +} diff --git a/src/blocks/mrc_get_parameter.ts b/src/blocks/mrc_get_parameter.ts new file mode 100644 index 00000000..a3784ae7 --- /dev/null +++ b/src/blocks/mrc_get_parameter.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview This is a block that allows your code to use a parameter + * that is passed to a method. + * @author alan@porpoiseful.com (Alan Smith) + */ +import * as Blockly from 'blockly'; +import {Order} from 'blockly/python'; + +import {ExtendedPythonGenerator} from '../editor/extended_python_generator'; +import {createFieldNonEditableText} from '../fields/FieldNonEditableText'; +import {MRC_STYLE_VARIABLES} from '../themes/styles'; +import {BLOCK_NAME as MRC_CLASS_METHOD_DEF, ClassMethodDefBlock} from './mrc_class_method_def'; +import * as ChangeFramework from './utils/change_framework'; + + +export const BLOCK_NAME = 'mrc_get_parameter'; +export const OUTPUT_NAME = 'mrc_get_parameter_output'; + + +type GetParameterBlock = Blockly.Block & Blockly.BlockSvg & GetParameterMixin; + +interface GetParameterMixin extends GetParameterMixinType {} + +type GetParameterMixinType = typeof GET_PARAMETER_BLOCK; + +const GET_PARAMETER_BLOCK = { + parameterType: '', // Later this will be set to the type of the parameter, e.g. 'string', 'number', etc. + /** + * Block initialization. + */ + init: function(this: GetParameterBlock): void { + this.setStyle(MRC_STYLE_VARIABLES); + this.appendDummyInput() + .appendField('parameter') + .appendField(createFieldNonEditableText('parameter'), 'PARAMETER_NAME'); + + this.setOutput(true, [OUTPUT_NAME, this.parameterType]); + ChangeFramework.registerCallback(BLOCK_NAME, [Blockly.Events.BLOCK_MOVE], this.onBlockChanged); + }, + setNameAndType: function(this: GetParameterBlock, name: string, type: string): void { + this.setFieldValue(name, 'PARAMETER_NAME'); + this.parameterType = type; + this.setOutput(true, [OUTPUT_NAME, type]); + }, + + onBlockChanged(block: Blockly.BlockSvg, blockEvent: Blockly.Events.BlockBase): void { + const blockBlock = block as Blockly.Block; + + if (blockEvent.type === Blockly.Events.BLOCK_MOVE) { + const parent = ChangeFramework.getParentOfType(block, MRC_CLASS_METHOD_DEF); + + if (parent) { + // It is a class method definition, so we see if this variable is in it. + const classMethodDefBlock = parent as ClassMethodDefBlock; + for (const parameter of classMethodDefBlock.mrcParameters) { + if (parameter.name === blockBlock.getFieldValue('PARAMETER_NAME')) { + // If it is, we allow it to stay. + blockBlock.setWarningText(null); + return; + } + } + } + // If we end up here it shouldn't be allowed + block.unplug(true); + blockBlock.setWarningText('Parameters can only go in their method\'s block.'); + } + }, +}; + +export const setup = function() { + Blockly.Blocks[BLOCK_NAME] = GET_PARAMETER_BLOCK; +}; + +export const pythonFromBlock = function( + block: GetParameterBlock, + _generator: ExtendedPythonGenerator, +) { + // TODO (Alan) : Specify the type here as well + const code = block.getFieldValue('PARAMETER_NAME'); + + return [code, Order.ATOMIC]; +}; diff --git a/src/blocks/mrc_mechanism_component_holder.ts b/src/blocks/mrc_mechanism_component_holder.ts index b72b1810..372fb439 100644 --- a/src/blocks/mrc_mechanism_component_holder.ts +++ b/src/blocks/mrc_mechanism_component_holder.ts @@ -28,8 +28,9 @@ import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; import { OUTPUT_NAME as MECHANISM_OUTPUT } from './mrc_mechanism'; import { BLOCK_NAME as MRC_MECHANISM_NAME } from './mrc_mechanism'; import { BLOCK_NAME as MRC_COMPONENT_NAME } from './mrc_component'; - import { OUTPUT_NAME as COMPONENT_OUTPUT } from './mrc_component'; +import { BLOCK_NAME as MRC_EVENT_NAME } from './mrc_event'; +import { OUTPUT_NAME as EVENT_OUTPUT } from './mrc_event'; export const BLOCK_NAME = 'mrc_mechanism_component_holder'; @@ -69,12 +70,14 @@ const MECHANISM_COMPONENT_HOLDER = { this.setInputsInline(false); this.appendStatementInput('MECHANISMS').setCheck(MECHANISM_OUTPUT).appendField('Mechanisms'); this.appendStatementInput('COMPONENTS').setCheck(COMPONENT_OUTPUT).appendField('Components'); + this.appendStatementInput('EVENTS').setCheck(EVENT_OUTPUT).appendField('Events'); + this.setOutput(false); this.setStyle(MRC_STYLE_MECHANISMS); ChangeFramework.registerCallback(MRC_COMPONENT_NAME, [Blockly.Events.BLOCK_MOVE, Blockly.Events.BLOCK_CHANGE], this.onBlockChanged); ChangeFramework.registerCallback(MRC_MECHANISM_NAME, [Blockly.Events.BLOCK_MOVE, Blockly.Events.BLOCK_CHANGE], this.onBlockChanged); - + ChangeFramework.registerCallback(MRC_EVENT_NAME, [Blockly.Events.BLOCK_MOVE, Blockly.Events.BLOCK_CHANGE], this.onBlockChanged); }, saveExtraState: function (this: MechanismComponentHolderBlock): MechanismComponentHolderExtraState { const extraState: MechanismComponentHolderExtraState = { @@ -140,7 +143,7 @@ function pythonFromBlockInRobot(block: MechanismComponentHolderBlock, generator: if (body != '') { code += body; } - + generator.addClassMethodDefinition('define_hardware', code); } diff --git a/src/blocks/setup_custom_blocks.ts b/src/blocks/setup_custom_blocks.ts index 645e888c..be5c4583 100644 --- a/src/blocks/setup_custom_blocks.ts +++ b/src/blocks/setup_custom_blocks.ts @@ -13,6 +13,8 @@ import * as Component from './mrc_component'; import * as MechanismContainerHolder from './mrc_mechanism_component_holder'; import * as Port from './mrc_port'; import * as OpModeDetails from './mrc_opmode_details'; +import * as Event from './mrc_event'; +import * as GetParameter from './mrc_get_parameter'; const customBlocks = [ CallPythonFunction, @@ -28,7 +30,9 @@ const customBlocks = [ Component, MechanismContainerHolder, Port, - OpModeDetails + OpModeDetails, + Event, + GetParameter ]; export const setup = function(forBlock: any) { diff --git a/src/blocks/utils/find_connected_blocks.ts b/src/blocks/utils/find_connected_blocks.ts new file mode 100644 index 00000000..b1299463 --- /dev/null +++ b/src/blocks/utils/find_connected_blocks.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Allow registering for specific callbacks + * @author alan@porpoiseful.com (Alan Smith) + */ +import * as Blockly from 'blockly'; + +export function findConnectedBlocksOfType(block: Blockly.Block, targetType: string): Blockly.Block[] { + const foundBlocks: Blockly.Block[] = []; + const visited = new Set(); // Prevent infinite loops + + function searchRecursive(currentBlock: Blockly.Block): void { + if (visited.has(currentBlock.id)) return; + visited.add(currentBlock.id); + + // Check if current block matches target type + if (currentBlock.type === targetType) { + foundBlocks.push(currentBlock); + } + + // Search through all inputs + currentBlock.inputList.forEach(input => { + if (input.connection && input.connection.isConnected()) { + const connectedBlock = input.connection.targetBlock(); + if (connectedBlock) { + searchRecursive(connectedBlock); + } + } + }); + } + + searchRecursive(block); + return foundBlocks; +} \ No newline at end of file diff --git a/src/fields/FieldNonEditableText.ts b/src/fields/FieldNonEditableText.ts index df4e9679..f8816564 100644 --- a/src/fields/FieldNonEditableText.ts +++ b/src/fields/FieldNonEditableText.ts @@ -24,7 +24,7 @@ import * as Blockly from 'blockly/core'; class FieldNonEditableText extends Blockly.FieldTextInput { constructor(value: string) { super(value); - this.CURSOR = ''; + this.EDITABLE = false; // This field is not editable } protected override showEditor_() { diff --git a/src/fields/field_flydown.ts b/src/fields/field_flydown.ts new file mode 100644 index 00000000..47e19757 --- /dev/null +++ b/src/fields/field_flydown.ts @@ -0,0 +1,364 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Create a field that has a flydown workspace + * @author alan@porpoiseful.com (Alan Smith) + * + * Heavily inspired by + * https://github.com/mit-cml/blockly-plugins/blob/main/block-lexical-variables/src/fields/field_flydown.js + */ +import * as Blockly from 'blockly'; + +enum FlydownLocation { + DISPLAY_BELOW = 'displayBelow', + DISPLAY_ABOVE = 'displayAbove', + DISPLAY_LEFT = 'displayLeft', + DISPLAY_RIGHT = 'displayRight', +} +class CustomFlyout extends Blockly.VerticalFlyout { + protected x: number = 0; + protected y: number = 0; + + public setPosition(x: number, y: number): void { + this.x = x; + this.y = y; + this.position(); + } + + override position() { + if (!this.isVisible() || !this.targetWorkspace!.isVisible()) { + return; + } + + this.height_ = this.getHeight(); + + const edgeWidth = this.width_ - this.CORNER_RADIUS; + const edgeHeight = this.height_ - 2 * this.CORNER_RADIUS; + + this.mySetBackgroundPath(edgeWidth, edgeHeight); + + const x = this.getX(); + const y = this.getY(); + + this.positionAt_(this.width_, this.height_, x, y); + } + override getHeight(): number { + let flydownWorkspace = this.getWorkspace(); + if (!flydownWorkspace) { + return 0; + } + const blocks = flydownWorkspace.getAllBlocks(); + let height = 0; + blocks.forEach((block : Blockly.BlockSvg) => { + const blockHeight = block.getHeightWidth().height; + height += blockHeight + this.GAP_Y; // Add some padding between blocks + }); + + return height; + } + + + /** + * Create and set the path for the visible boundaries of the flyout. + * + * @param width The width of the flyout, not including the rounded corners. + * @param height The height of the flyout, not including rounded corners. + */ + private mySetBackgroundPath(width: number, height: number) { + const atRight = false; + const totalWidth = width + this.CORNER_RADIUS; + + // Decide whether to start on the left or right. + const path: Array = [ + 'M ' + (atRight ? totalWidth : 0) + ',0', + ]; + // Top. + path.push('h', atRight ? -width : width); + // Rounded corner. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + atRight ? 0 : 1, + atRight ? -this.CORNER_RADIUS : this.CORNER_RADIUS, + this.CORNER_RADIUS, + ); + // Side closest to workspace. + path.push('v', Math.max(0, height)); + // Rounded corner. + path.push( + 'a', + this.CORNER_RADIUS, + this.CORNER_RADIUS, + 0, + 0, + atRight ? 0 : 1, + atRight ? this.CORNER_RADIUS : -this.CORNER_RADIUS, + this.CORNER_RADIUS, + ); + // Bottom. + path.push('h', atRight ? width : -width); + path.push('z'); + this.svgBackground_!.setAttribute('d', path.join(' ')); + } + + /** + * Calculates the x coordinate for the flyout position. + * + * @returns X coordinate. + */ + getX(): number { + return this.x; + } + + /** + * Calculates the y coordinate for the flyout position. + * + * @returns Y coordinate. + */ + getY(): number { + return this.y; + } +} + +export class FieldFlydown extends Blockly.FieldTextInput { + /** + * Milliseconds to wait before showing flydown after mouseover event on flydown + * field. + * @type {number} + * @const + */ + static TIME_OUT = 500; + + + private displayLocation_: FlydownLocation; + protected fieldCSSClassName: string = 'blocklyFlydownField'; + + private flydown_: CustomFlyout | null = null; + private boundMouseOverHandler_: (e: Event) => void; + private boundMouseOutHandler_: (e: Event) => void; + private showTimeout_: number | null = null; + private hideTimeout_: number | null = null; + + constructor(value: string, isEditable: boolean, displayLocation: FlydownLocation = FlydownLocation.DISPLAY_BELOW) { + super(value); + this.EDITABLE = isEditable; + this.displayLocation_ = displayLocation; + + // Bind the handlers + this.boundMouseOverHandler_ = this.onMouseOver_.bind(this); + this.boundMouseOutHandler_ = this.onMouseOut_.bind(this); + } + + protected override showEditor_() { + if (!this.EDITABLE) { + return; + } + super.showEditor_(); + } + override init(): void { + super.init(); + // Blockly.utils.dom.addClass(this.fieldGroup_, this.fieldCSSClassName); + } + + public override initView() { + super.initView(); + + // Add event listeners instead of using mouseOverWrapper_ + if (this.getClickTarget_()) { + this.getClickTarget_()!.addEventListener('mouseover', this.boundMouseOverHandler_); + this.getClickTarget_()!.addEventListener('mouseout', this.boundMouseOutHandler_); + } + } + + private onMouseOver_(e: Event) { + // Clear any pending hide timeout + if (this.hideTimeout_) { + clearTimeout(this.hideTimeout_); + this.hideTimeout_ = null; + } + + // Add small delay to prevent flickering + this.showTimeout_ = window.setTimeout(() => { + this.showFlydown_(); + }, 250); // 250ms delay + + // This event has been handled. No need to bubble up to the document. + e.stopPropagation(); + } + + private onMouseOut_(e: Event) { + // Clear any pending show timeout + if (this.showTimeout_) { + clearTimeout(this.showTimeout_); + this.showTimeout_ = null; + } + + // Add delay before hiding to allow moving to flydown + this.hideTimeout_ = window.setTimeout(() => { + this.hideFlydown_(); + }, FieldFlydown.TIME_OUT); + } + private createFlydown_(mainWorkspace: Blockly.WorkspaceSvg) { + if (this.flydown_) return; + + try { + // Use the workspace's flyout as a template + const existingFlyout = mainWorkspace.getFlyout?.(); + + if (existingFlyout) { + // Clone the existing flyout's options + this.flydown_ = new CustomFlyout(existingFlyout.getWorkspace().options); + } else { + // Fallback to creating a new flyout if no existing one is found + this.flydown_ = new CustomFlyout(mainWorkspace.options); + } + Blockly.utils.dom.insertAfter( + this.flydown_.createDom("svg")!, + mainWorkspace.getParentSvg(), + ); + + + this.flydown_.init(mainWorkspace); + + const fieldElement = this.getClickTarget_(); + if (!fieldElement) return; + + const fieldRect = fieldElement.getBoundingClientRect(); + const workspaceRect = mainWorkspace.getParentSvg().getBoundingClientRect(); + + const x = fieldRect.right - workspaceRect.left; + const y = fieldRect.top - workspaceRect.top; + + + // Set flydown position + this.flydown_.setPosition(x, y); + // Create the flydown without explicit DOM manipulation + this.flydown_.setVisible(true); + + } catch (error) { + console.error('Failed to create flydown:', error); + } + } + + private showFlydown_() { + if (this.flydown_ && this.flydown_.isVisible()) { + return; // Already showing + } + + const workspace = this.getSourceBlock()?.workspace; + if (!workspace) return; + + const mainWorkspace = workspace.isFlyout ? + Blockly.getMainWorkspace() as Blockly.WorkspaceSvg : workspace as Blockly.WorkspaceSvg; + + // Create flydown if it doesn't exist + if (!this.flydown_) { + this.createFlydown_(mainWorkspace); + } + if (this.flydown_) { + const fieldElement = this.getClickTarget_(); + + const fieldRect = fieldElement!.getBoundingClientRect(); + const workspaceRect = mainWorkspace.getParentSvg().getBoundingClientRect(); + + const x = fieldRect.right - workspaceRect.left; + const y = fieldRect.top - workspaceRect.top; + + // Set flydown position + this.flydown_.setPosition(x, y); + // Show the flydown with your blocks + this.flydown_.show(this.getBlocksForFlydown_()); + + // Add hover listeners to flydown to keep it open + this.addFlydownHoverListeners_(); + } + } + private getBlocksForFlydown_() { + const name = this.getText(); + return { + contents: [ + { + kind: 'block', + type: 'mrc_get_parameter', + fields: { + PARAMETER_NAME: name, + }, + }, + ] + }; + } + + + private addFlydownHoverListeners_() { + if (!this.flydown_) return; + + const flydownSvg = this.flydown_.getWorkspace()?.getParentSvg(); + if (flydownSvg) { + flydownSvg.addEventListener('mouseenter', () => { + if (this.hideTimeout_) { + clearTimeout(this.hideTimeout_); + this.hideTimeout_ = null; + } + }); + + flydownSvg.addEventListener('mouseleave', () => { + this.hideTimeout_ = window.setTimeout(() => { + this.hideFlydown_(); + }, FieldFlydown.TIME_OUT); + }); + } + } + + private hideFlydown_() { + if (this.flydown_) { + this.flydown_.hide(); + } + } + + + override dispose() { + // Clear timeouts + if (this.showTimeout_) { + clearTimeout(this.showTimeout_); + } + if (this.hideTimeout_) { + clearTimeout(this.hideTimeout_); + } + + // Remove event listeners + const clickTarget = this.getClickTarget_(); + if (clickTarget) { + clickTarget.removeEventListener('mouseenter', this.boundMouseOverHandler_); + clickTarget.removeEventListener('mouseleave', this.boundMouseOutHandler_); + } + + if (this.flydown_) { + this.flydown_.dispose(); + this.flydown_ = null; + } + super.dispose(); + } + +} + +export function createFieldFlydown(label: string, isEditable: boolean): Blockly.Field { + return new FieldFlydown(label, isEditable); +} \ No newline at end of file diff --git a/src/themes/styles.ts b/src/themes/styles.ts index ef86d0cc..428b6775 100644 --- a/src/themes/styles.ts +++ b/src/themes/styles.ts @@ -30,6 +30,7 @@ export const MRC_STYLE_CLASS_BLOCKS = 'mrc_style_class_blocks'; export const MRC_CATEGORY_STYLE_METHODS = 'mrc_category_style_methods'; export const MRC_STYLE_MECHANISMS = 'mrc_style_mechanisms'; export const MRC_STYLE_COMPONENTS = 'mrc_style_components'; +export const MRC_STYLE_EVENTS = 'mrc_style_events'; export const MRC_STYLE_PORTS = 'mrc_style_ports'; export const add_mrc_styles = function (theme: Blockly.Theme): Blockly.Theme { @@ -39,6 +40,12 @@ export const add_mrc_styles = function (theme: Blockly.Theme): Blockly.Theme { colourTertiary: "#664984", hat: "" }); + theme.setBlockStyle(MRC_STYLE_EVENTS, { + colourPrimary: "#805ba5", + colourSecondary: "#e6deed", + colourTertiary: "#664984", + hat: "" + }); theme.setBlockStyle(MRC_STYLE_ENUM, { colourPrimary: "#5ba5a5", colourSecondary: "#deeded", diff --git a/src/toolbox/event_category.ts b/src/toolbox/event_category.ts new file mode 100644 index 00000000..3f4b198f --- /dev/null +++ b/src/toolbox/event_category.ts @@ -0,0 +1,15 @@ +export const category = +{ + kind: 'category', + name: 'Event', + contents: [ + { + kind: 'label', + text: 'New Event', + }, + { + kind: 'block', + type: 'mrc_event', + } + ] +} \ No newline at end of file diff --git a/src/toolbox/methods_category.ts b/src/toolbox/methods_category.ts index 30097f3a..d41ced35 100644 --- a/src/toolbox/methods_category.ts +++ b/src/toolbox/methods_category.ts @@ -87,10 +87,6 @@ export class MethodsCategory { // Add a block that lets the user define a new method. contents.push( - { - kind: 'block', - type: 'mrc_mechanism_container_holder', - }, { kind: 'label', text: 'Custom Methods',