diff --git a/server_python_scripts/blocks_base_classes/opmode.py b/server_python_scripts/blocks_base_classes/opmode.py index de597e53..4ebd039d 100644 --- a/server_python_scripts/blocks_base_classes/opmode.py +++ b/server_python_scripts/blocks_base_classes/opmode.py @@ -7,6 +7,9 @@ def __init__(self, robot: RobotBase): def start(self) -> None: self.robot.start() def loop(self) -> None: + # Call steps method if it exists in the derived class + if hasattr(self, 'steps') and callable(self.steps): + self.steps() self.robot.update() def stop(self) -> None: self.robot.stop() diff --git a/src/blocks/mrc_class_method_def.ts b/src/blocks/mrc_class_method_def.ts index 0c6af826..d6b8126f 100644 --- a/src/blocks/mrc_class_method_def.ts +++ b/src/blocks/mrc_class_method_def.ts @@ -22,7 +22,7 @@ import * as Blockly from 'blockly'; import { MRC_STYLE_FUNCTIONS } from '../themes/styles'; import { createFieldNonEditableText } from '../fields/FieldNonEditableText' -import { createFieldFlydown } from '../fields/field_flydown'; +import { createParameterFieldFlydown } from '../fields/field_flydown'; import { Order } from 'blockly/python'; import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; import * as storageModule from '../storage/module'; @@ -62,7 +62,7 @@ interface ClassMethodDefMixin extends ClassMethodDefMixinType { } type ClassMethodDefMixinType = typeof CLASS_METHOD_DEF; -/** Extra state for serialising call_python_* blocks. */ +/** Extra state for serialising mrc_class_method_def blocks. */ type ClassMethodDefExtraState = { /** * The id that identifies this method definition. @@ -241,7 +241,7 @@ const CLASS_METHOD_DEF = { this.removeParameterFields(input); this.mrcParameters.forEach((param) => { const paramName = FIELD_PARAM_PREFIX + param.name; - input.appendField(createFieldFlydown(param.name, false), paramName); + input.appendField(createParameterFieldFlydown(param.name, false), paramName); }); } } diff --git a/src/blocks/mrc_event_handler.ts b/src/blocks/mrc_event_handler.ts index abb5b002..bd036491 100644 --- a/src/blocks/mrc_event_handler.ts +++ b/src/blocks/mrc_event_handler.ts @@ -27,7 +27,7 @@ import type { MessageInstance } from 'antd/es/message/interface'; import { Parameter } from './mrc_class_method_def'; import { Editor } from '../editor/editor'; import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; -import { createFieldFlydown } from '../fields/field_flydown'; +import { createParameterFieldFlydown } from '../fields/field_flydown'; import { createFieldNonEditableText } from '../fields/FieldNonEditableText'; import { MRC_STYLE_EVENT_HANDLER } from '../themes/styles'; import * as toolboxItems from '../toolbox/items'; @@ -146,7 +146,7 @@ const EVENT_HANDLER = { this.removeParameterFields(input); this.mrcParameters.forEach((param) => { const paramName = `PARAM_${param.name}`; - input.appendField(createFieldFlydown(param.name, false), paramName); + input.appendField(createParameterFieldFlydown(param.name, false), paramName); }); } } else { diff --git a/src/blocks/mrc_jump_to_step.ts b/src/blocks/mrc_jump_to_step.ts new file mode 100644 index 00000000..4bf409e1 --- /dev/null +++ b/src/blocks/mrc_jump_to_step.ts @@ -0,0 +1,120 @@ +/** + * @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 jump to a specific step. + * @author alan@porpoiseful.com (Alan Smith) + */ +import * as Blockly from 'blockly'; + +import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; +import { createFieldNonEditableText } from '../fields/FieldNonEditableText'; +import { MRC_STYLE_VARIABLES } from '../themes/styles'; +import { BLOCK_NAME as MRC_STEPS, StepsBlock } from './mrc_steps' + +export const BLOCK_NAME = 'mrc_jump_to_step'; + +const FIELD_STEP_NAME = 'STEP_NAME'; + +const WARNING_ID_NOT_IN_STEP = 'not in step'; + + +type JumpToStepBlock = Blockly.Block & Blockly.BlockSvg & JumpToStepMixin; + +interface JumpToStepMixin extends JumpToStepMixinType { + mrcHasWarning: boolean, +} + +type JumpToStepMixinType = typeof JUMP_TO_STEP_BLOCK; + +const JUMP_TO_STEP_BLOCK = { + /** + * Block initialization. + */ + init: function (this: JumpToStepBlock): void { + this.appendDummyInput() + .appendField(Blockly.Msg.JUMP_TO) + .appendField(createFieldNonEditableText(''), FIELD_STEP_NAME); + this.setPreviousStatement(true, null); + this.setInputsInline(true); + this.setStyle(MRC_STYLE_VARIABLES); + this.setTooltip(() => { + const stepName = this.getFieldValue(FIELD_STEP_NAME); + let tooltip = Blockly.Msg.JUMP_TO_STEP_TOOLTIP; + tooltip = tooltip.replace('{{stepName}}', stepName); + return tooltip; + }); + }, + /** + * mrcOnMove is called when a JumpToStepBlock is moved. + */ + mrcOnMove: function (this: JumpToStepBlock, _reason: string[]): void { + this.checkBlockPlacement(); + }, + mrcOnAncestorMove: function (this: JumpToStepBlock): void { + this.checkBlockPlacement(); + }, + checkBlockPlacement: function (this: JumpToStepBlock): void { + const legalStepNames: string[] = []; + + const rootBlock: Blockly.Block | null = this.getRootBlock(); + if (rootBlock.type === MRC_STEPS) { + // This block is within a steps block. + const stepsBlock = rootBlock as StepsBlock; + // Add the step names to legalStepNames. + legalStepNames.push(...stepsBlock.mrcGetStepNames()); + } + + if (legalStepNames.includes(this.getFieldValue(FIELD_STEP_NAME))) { + // If this blocks's step name is in legalStepNames, it's good. + this.setWarningText(null, WARNING_ID_NOT_IN_STEP); + this.mrcHasWarning = false; + } else { + // Otherwise, add a warning to this block. + if (!this.mrcHasWarning) { + this.setWarningText(Blockly.Msg.JUMP_CAN_ONLY_GO_IN_THEIR_STEPS_BLOCK, WARNING_ID_NOT_IN_STEP); + this.getIcon(Blockly.icons.IconType.WARNING)!.setBubbleVisible(true); + this.mrcHasWarning = true; + } + } + }, +}; + +export const setup = function () { + Blockly.Blocks[BLOCK_NAME] = JUMP_TO_STEP_BLOCK; +}; + +export const pythonFromBlock = function ( + block: JumpToStepBlock, + _generator: ExtendedPythonGenerator, +) { + let code = 'self._current_step = "' + + block.getFieldValue(FIELD_STEP_NAME) + '"\n'; + code += 'return\n'; + + return code; +}; + +export function renameSteps(workspace: Blockly.Workspace, mapOldStepNameToNewStepName: {[newStepName: string]: string}): void { + workspace.getBlocksByType(BLOCK_NAME, false).forEach((jumpBlock) => { + const stepName = jumpBlock.getFieldValue(FIELD_STEP_NAME); + if (stepName in mapOldStepNameToNewStepName) { + const newStepName = mapOldStepNameToNewStepName[stepName]; + jumpBlock.setFieldValue(newStepName, FIELD_STEP_NAME); + } + }); +} diff --git a/src/blocks/mrc_step_container.ts b/src/blocks/mrc_step_container.ts new file mode 100644 index 00000000..c8d74f0b --- /dev/null +++ b/src/blocks/mrc_step_container.ts @@ -0,0 +1,215 @@ +/** + * @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 Mutator for steps. + * @author alan@porpoiseful.com (Alan Smith) + */ +import * as Blockly from 'blockly'; +import { MRC_STYLE_CLASS_BLOCKS } from '../themes/styles'; + +export const STEP_CONTAINER_BLOCK_NAME = 'mrc_step_container'; +const STEP_ITEM_BLOCK_NAME = 'mrc_step_item'; + +export const setup = function () { + Blockly.Blocks[STEP_CONTAINER_BLOCK_NAME] = STEP_CONTAINER; + Blockly.Blocks[STEP_ITEM_BLOCK_NAME] = STEP_ITEM; +}; + +// The step container block. + +const INPUT_STACK = 'STACK'; + +export type StepContainerBlock = StepContainerMixin & Blockly.BlockSvg; +interface StepContainerMixin extends StepContainerMixinType { } +type StepContainerMixinType = typeof STEP_CONTAINER; + +const STEP_CONTAINER = { + init: function (this: StepContainerBlock) { + this.appendDummyInput().appendField(Blockly.Msg.STEPS); + this.appendStatementInput(INPUT_STACK); + this.setStyle(MRC_STYLE_CLASS_BLOCKS); + this.contextMenu = false; + }, + getStepItemBlocks: function (this: StepContainerBlock): StepItemBlock[] { + const stepItemBlocks: StepItemBlock[] = []; + let block = this.getInputTargetBlock(INPUT_STACK); + while (block && !block.isInsertionMarker()) { + if (block.type !== STEP_ITEM_BLOCK_NAME) { + throw new Error('getItemNames: block.type should be ' + STEP_ITEM_BLOCK_NAME); + } + stepItemBlocks.push(block as StepItemBlock); + block = block.nextConnection && block.nextConnection.targetBlock(); + } + return stepItemBlocks; + }, +}; + +// The step item block. + +const FIELD_NAME = 'NAME'; + +export type StepItemBlock = StepItemMixin & Blockly.BlockSvg; +interface StepItemMixin extends StepItemMixinType { + originalName: string, + conditionShadowState?: any; + conditionTargetConnection?: Blockly.Connection | null; + statementTargetConnection?: Blockly.Connection | null; +} + +type StepItemMixinType = typeof STEP_ITEM; + +const STEP_ITEM = { + init: function (this: StepItemBlock) { + this.appendDummyInput() + .appendField(new Blockly.FieldTextInput(''), FIELD_NAME); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setStyle(MRC_STYLE_CLASS_BLOCKS); + this.originalName = ''; + this.contextMenu = false; + }, + makeNameLegal: function (this: StepItemBlock): void { + const rootBlock: Blockly.Block | null = this.getRootBlock(); + if (rootBlock) { + const otherNames: string[] = [] + rootBlock!.getDescendants(true)?.forEach(itemBlock => { + if (itemBlock != this) { + otherNames.push(itemBlock.getFieldValue(FIELD_NAME)); + } + }); + let currentName = this.getFieldValue(FIELD_NAME); + while (otherNames.includes(currentName)) { + // Check if currentName ends with a number + const match = currentName.match(/^(.*?)(\d+)$/); + if (match) { + // If it ends with a number, increment it + const baseName = match[1]; + const number = parseInt(match[2], 10); + currentName = baseName + (number + 1); + } else { + // If it doesn't end with a number, append 2 + currentName = currentName + '2'; + } + } + this.setFieldValue(currentName, FIELD_NAME); + updateMutatorFlyout(this.workspace); + } + }, + getName: function (this: StepItemBlock): string { + return this.getFieldValue(FIELD_NAME); + }, + getOriginalName: function (this: StepItemBlock): string { + return this.originalName; + }, + setOriginalName: function (this: StepItemBlock, originalName: string): void { + this.originalName = originalName; + }, +} + +/** + * Updates the mutator's flyout so that it contains a single step item block + * whose name is not a duplicate of an existing step item. + * + * @param workspace The mutator's workspace. This workspace's flyout is what is being updated. + */ +function updateMutatorFlyout(workspace: Blockly.WorkspaceSvg) { + const usedNames: string[] = []; + workspace.getBlocksByType(STEP_ITEM_BLOCK_NAME, false).forEach(block => { + usedNames.push(block.getFieldValue(FIELD_NAME)); + }); + + // Find the first unused number starting from 0 + let counter = 0; + let uniqueName = counter.toString(); + while (usedNames.includes(uniqueName)) { + counter++; + uniqueName = counter.toString(); + } + + const jsonBlock = { + kind: 'block', + type: STEP_ITEM_BLOCK_NAME, + fields: { + NAME: uniqueName, + }, + }; + + workspace.updateToolbox({ contents: [jsonBlock] }); +} + +/** + * The blockly event listener function for the mutator's workspace. + */ +function onChange(mutatorWorkspace: Blockly.Workspace, event: Blockly.Events.Abstract) { + if (event.type === Blockly.Events.BLOCK_MOVE) { + const blockMoveEvent = event as Blockly.Events.BlockMove; + const reason: string[] = blockMoveEvent.reason ?? []; + if (reason.includes('connect') && blockMoveEvent.blockId) { + const block = mutatorWorkspace.getBlockById(blockMoveEvent.blockId); + if (block && block.type === STEP_ITEM_BLOCK_NAME) { + (block as StepItemBlock).makeNameLegal(); + } + } + } else if (event.type === Blockly.Events.BLOCK_CHANGE) { + const blockChangeEvent = event as Blockly.Events.BlockChange; + if (blockChangeEvent.blockId) { + const block = mutatorWorkspace.getBlockById(blockChangeEvent.blockId); + if (block && block.type === STEP_ITEM_BLOCK_NAME) { + (block as StepItemBlock).makeNameLegal(); + } + } + } +} + +/** + * Called for mrc_steps blocks when their mutator opesn. + * Triggers a flyout update and adds an event listener to the mutator workspace. + * + * @param block The block whose mutator is open. + */ +export function onMutatorOpen(block: Blockly.BlockSvg) { + const mutatorIcon = block.getIcon(Blockly.icons.MutatorIcon.TYPE) as Blockly.icons.MutatorIcon; + const mutatorWorkspace = mutatorIcon.getWorkspace()!; + updateMutatorFlyout(mutatorWorkspace); + mutatorWorkspace.addChangeListener(event => onChange(mutatorWorkspace, event)); +} + +/** + * Returns the MutatorIcon for the given block. + */ +export function getMutatorIcon(block: Blockly.BlockSvg): Blockly.icons.MutatorIcon { + return new Blockly.icons.MutatorIcon([STEP_ITEM_BLOCK_NAME], block); +} + +export function createMutatorBlocks(workspace: Blockly.Workspace, stepNames: string[]): StepContainerBlock { + // First create the container block. + const containerBlock = workspace.newBlock(STEP_CONTAINER_BLOCK_NAME) as StepContainerBlock; + containerBlock.initSvg(); + + // Then add one step item block for each step. + let connection = containerBlock!.getInput(INPUT_STACK)!.connection; + for (const stepName of stepNames) { + const itemBlock = workspace.newBlock(STEP_ITEM_BLOCK_NAME) as StepItemBlock; + itemBlock.initSvg(); + itemBlock.setFieldValue(stepName, FIELD_NAME); + itemBlock.originalName = stepName; + connection!.connect(itemBlock.previousConnection!); + connection = itemBlock.nextConnection; + } + return containerBlock; +} diff --git a/src/blocks/mrc_steps.ts b/src/blocks/mrc_steps.ts new file mode 100644 index 00000000..873b67b1 --- /dev/null +++ b/src/blocks/mrc_steps.ts @@ -0,0 +1,259 @@ +/** + * @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 Blocks for class method definition + * @author alan@porpoiseful.com (Alan Smith) + */ +import * as Blockly from 'blockly'; +import { Order } from 'blockly/python'; + +import { MRC_STYLE_STEPS } from '../themes/styles'; +import { ExtendedPythonGenerator } from '../editor/extended_python_generator'; +import { createStepFieldFlydown } from '../fields/field_flydown'; +import { renameSteps as updateJumpToStepBlocks } from './mrc_jump_to_step'; +import * as stepContainer from './mrc_step_container' +import { createBooleanShadowValue } from './utils/value'; +import * as toolboxItems from '../toolbox/items'; + +export const BLOCK_NAME = 'mrc_steps'; + +const INPUT_CONDITION_PREFIX = 'CONDITION_'; +const INPUT_STATEMENT_PREFIX = 'STATEMENT_'; + +/** Extra state for serialising mrc_steps blocks. */ +type StepsExtraState = { + /** + * The step names. + */ + stepNames: string[], +}; + +export type StepsBlock = Blockly.Block & StepsMixin & Blockly.BlockSvg; +interface StepsMixin extends StepsMixinType { + mrcStepNames: string[]; +} +type StepsMixinType = typeof STEPS; + +const STEPS = { + /** + * Block initialization. + */ + init: function (this: StepsBlock): void { + this.mrcStepNames = []; + this.appendDummyInput() + .appendField(Blockly.Msg.STEPS); + this.setInputsInline(false); + this.setStyle(MRC_STYLE_STEPS); + this.setMutator(stepContainer.getMutatorIcon(this)); + }, + saveExtraState: function (this: StepsBlock): StepsExtraState { + return { + stepNames: this.mrcStepNames, + }; + }, + loadExtraState: function (this: StepsBlock, state: StepsExtraState): void { + this.mrcStepNames = state.stepNames; + this.updateShape_(); + }, + /** + * Populate the mutator's dialog with this block's components. + */ + decompose: function (this: StepsBlock, workspace: Blockly.Workspace): stepContainer.StepContainerBlock { + const stepNames: string[] = []; + this.mrcStepNames.forEach(step => { + stepNames.push(step); + }); + return stepContainer.createMutatorBlocks(workspace, stepNames); + }, + /** + * Store condition and statement connections on the StepItemBlocks + */ + saveConnections: function (this: StepsBlock, containerBlock: stepContainer.StepContainerBlock) { + const stepItemBlocks: stepContainer.StepItemBlock[] = containerBlock.getStepItemBlocks(); + for (let i = 0; i < stepItemBlocks.length; i++) { + const stepItemBlock = stepItemBlocks[i]; + const conditionInput = this.getInput(INPUT_CONDITION_PREFIX + i); + stepItemBlock.conditionShadowState = + conditionInput && conditionInput.connection!.getShadowState(true); + stepItemBlock.conditionTargetConnection = + conditionInput && conditionInput.connection!.targetConnection; + const statementInput = this.getInput(INPUT_STATEMENT_PREFIX + i); + stepItemBlock.statementTargetConnection = + statementInput && statementInput.connection!.targetConnection; + } + }, + /** + * Reconfigure this block based on the mutator dialog's components. + */ + compose: function (this: StepsBlock, containerBlock: stepContainer.StepContainerBlock) { + const mapOldStepNameToNewStepName: {[newStepName: string]: string} = {}; + const conditionShadowStates: Array = []; + const conditionTargetConnections: Array = []; + const statementTargetConnections: Array = []; + + const stepItemBlocks: stepContainer.StepItemBlock[] = containerBlock.getStepItemBlocks(); + + // Iterate through the step item blocks to: + // - Update this.mrcStepNames + // - Keep track of steps that were renamed + // - Collect the condition and statement connections that were saved on the StepItemBlocks. + this.mrcStepNames = []; + stepItemBlocks.forEach((stepItemBlock) => { + const oldStepName = stepItemBlock.getOriginalName(); + const newStepName = stepItemBlock.getName(); + stepItemBlock.setOriginalName(newStepName); + this.mrcStepNames.push(newStepName); + if (oldStepName !== newStepName) { + mapOldStepNameToNewStepName[oldStepName] = newStepName; + } + conditionShadowStates.push(stepItemBlock.conditionShadowState); + conditionTargetConnections.push(stepItemBlock.conditionTargetConnection as Blockly.Connection | null); + statementTargetConnections.push(stepItemBlock.statementTargetConnection as Blockly.Connection | null); + }); + + this.updateShape_(); + + // Reconnect blocks. + for (let i = 0; i < this.mrcStepNames.length; i++) { + // Reconnect the condition. + conditionTargetConnections[i]?.reconnect(this, INPUT_CONDITION_PREFIX + i); + // Add the boolean shadow block to the condition input. This must be done after the condition + // has been reconnected. If it is done before the condition is reconnected, the shadow will + // become disconnected. + const conditionShadowState = conditionShadowStates[i] || createBooleanShadowValue(true).shadow; + const conditionInput = this.getInput(INPUT_CONDITION_PREFIX + i); + conditionInput?.connection?.setShadowState(conditionShadowState as any); + // Reconnect the statement. + statementTargetConnections[i]?.reconnect(this, INPUT_STATEMENT_PREFIX + i); + } + + if (Object.keys(mapOldStepNameToNewStepName).length) { + // Update jump blocks for any renamed steps. + updateJumpToStepBlocks(this.workspace, mapOldStepNameToNewStepName); + } + }, + /** + * mrcOnMutatorOpen is called when the mutator on an StepsBlock is opened. + */ + mrcOnMutatorOpen: function (this: StepsBlock): void { + stepContainer.onMutatorOpen(this); + }, + mrcUpdateStepName: function (this: StepsBlock, step: number, newName: string): string { + const oldName = this.mrcStepNames[step]; + const otherNames = this.mrcStepNames.filter((_name, index) => index !== step); + let currentName = newName; + + // Make name unique if it conflicts + while (otherNames.includes(currentName)) { + // Check if currentName ends with a number + const match = currentName.match(/^(.*?)(\d+)$/); + if (match) { + // If it ends with a number, increment it + const baseName = match[1]; + const number = parseInt(match[2], 10); + currentName = baseName + (number + 1); + } else { + // If it doesn't end with a number, append 2 + currentName = currentName + '2'; + } + } + this.mrcStepNames[step] = currentName; + + if (oldName !== currentName) { + // Update all mrc_jump_to_step blocks that reference the old name + const mapOldStepNameToNewStepName: {[newStepName: string]: string} = {}; + mapOldStepNameToNewStepName[oldName] = currentName; + updateJumpToStepBlocks(this.workspace, mapOldStepNameToNewStepName); + } + + return currentName; + }, + updateShape_: function (this: StepsBlock): void { + // Remove all inputs. + for (let i = 0; this.getInput(INPUT_CONDITION_PREFIX + i); i++) { + this.removeInput(INPUT_CONDITION_PREFIX + i); + this.removeInput(INPUT_STATEMENT_PREFIX + i); + } + // Add inputs for each step. + for (let i = 0; i < this.mrcStepNames.length; i++) { + const stepName = this.mrcStepNames[i]; + const fieldFlydown = createStepFieldFlydown(stepName, true); + fieldFlydown.setValidator(this.mrcUpdateStepName.bind(this, i)); + this.appendValueInput(INPUT_CONDITION_PREFIX + i) + .appendField(fieldFlydown) + .setCheck('Boolean') + .appendField(Blockly.Msg.REPEAT_UNTIL); + this.appendStatementInput(INPUT_STATEMENT_PREFIX + i); + } + }, + mrcGetStepNames: function (this: StepsBlock): string[] { + return this.mrcStepNames; + } +}; + +export const setup = function () { + Blockly.Blocks[BLOCK_NAME] = STEPS; +}; + +export function isStepsInWorkspace(workspace: Blockly.Workspace): boolean { + const blocks = workspace.getBlocksByType(BLOCK_NAME); + return blocks.length > 0; +}; + +export const pythonFromBlock = function ( + block: StepsBlock, + generator: ExtendedPythonGenerator, +) { + let code = 'def steps(self):\n'; + code += generator.INDENT + 'if not hasattr(self, "_initialized_steps"):\n'; + code += generator.INDENT.repeat(2) + 'self._current_step = "' + block.mrcStepNames[0] + '"\n'; + code += generator.INDENT.repeat(2) + 'self._initialized_steps = True\n\n'; + code += generator.INDENT + 'if self._current_step == None:\n'; + code += generator.INDENT.repeat(2) + 'return\n'; + + + code += generator.INDENT + 'match self._current_step:\n'; + block.mrcStepNames.forEach((stepName, index) => { + code += generator.INDENT.repeat(2) + `case "${stepName}":\n`; + const stepCode = generator.statementToCode(block, INPUT_STATEMENT_PREFIX + index); + if (stepCode !== '') { + code += generator.prefixLines(stepCode, generator.INDENT.repeat(2)); + } + const conditionCode = generator.valueToCode(block, INPUT_CONDITION_PREFIX + index, Order.NONE) || 'False'; + code += generator.INDENT.repeat(3) + 'if ' + conditionCode + ':\n'; + if (index === block.mrcStepNames.length - 1) { + code += generator.INDENT.repeat(4) + 'self._current_step = None\n'; + } else { + code += generator.INDENT.repeat(4) + 'self._current_step = "' + block.mrcStepNames[index + 1] + '"\n'; + } + }); + + generator.addClassMethodDefinition('steps', code); + + return '' +} + +export function createStepsBlock(): toolboxItems.Block { + const extraState: StepsExtraState = { + stepNames: ['0'], + }; + const fields: {[key: string]: any} = {}; + const inputs: {[key: string]: any} = {}; + inputs[INPUT_CONDITION_PREFIX + 0] = createBooleanShadowValue(true); + return new toolboxItems.Block(BLOCK_NAME, extraState, fields, inputs); +} diff --git a/src/blocks/setup_custom_blocks.ts b/src/blocks/setup_custom_blocks.ts index 7b990663..fecdb7bc 100644 --- a/src/blocks/setup_custom_blocks.ts +++ b/src/blocks/setup_custom_blocks.ts @@ -18,6 +18,9 @@ import * as OpModeDetails from './mrc_opmode_details'; import * as ParamContainer from './mrc_param_container' import * as Port from './mrc_port'; import * as SetPythonVariable from './mrc_set_python_variable'; +import * as Steps from './mrc_steps'; +import * as StepContainer from './mrc_step_container'; +import * as JumpToStep from './mrc_jump_to_step'; const customBlocks = [ CallPythonFunction, @@ -39,6 +42,9 @@ const customBlocks = [ ParamContainer, Port, SetPythonVariable, + Steps, + StepContainer, + JumpToStep ]; export const setup = function(forBlock: any) { diff --git a/src/blocks/tokens.ts b/src/blocks/tokens.ts index 3656664a..bcc7109f 100644 --- a/src/blocks/tokens.ts +++ b/src/blocks/tokens.ts @@ -129,6 +129,11 @@ export function customTokens(t: (key: string) => string): typeof Blockly.Msg { MORE_MECHANISM_METHODS_LABEL: t('BLOCKLY.MORE_MECHANISM_METHODS_LABEL'), MORE_OPMODE_METHODS_LABEL: t('BLOCKLY.MORE_OPMODE_METHODS_LABEL'), COMMENT_DEFAULT_TEXT: t('BLOCKLY.COMMENT_DEFAULT_TEXT'), + STEPS: t('BLOCKLY.STEPS'), + REPEAT_UNTIL: t('BLOCKLY.REPEAT_UNTIL'), + JUMP_TO: t('BLOCKLY.JUMP_TO'), + JUMP_TO_STEP_TOOLTIP: t('BLOCKLY.TOOLTIP.JUMP_TO_STEP'), + JUMP_CAN_ONLY_GO_IN_THEIR_STEPS_BLOCK: t('BLOCKLY.JUMP_CAN_ONLY_GO_IN_THEIR_STEPS_BLOCK'), } }; diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 053ce2ff..4f6f0b45 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -29,6 +29,7 @@ import * as storageNames from '../storage/names'; import * as storageProject from '../storage/project'; import * as eventHandler from '../blocks/mrc_event_handler'; import * as classMethodDef from '../blocks/mrc_class_method_def'; +import * as blockSteps from '../blocks/mrc_steps'; import * as mechanismComponentHolder from '../blocks/mrc_mechanism_component_holder'; import * as workspaces from '../blocks/utils/workspaces'; //import { testAllBlocksInToolbox } from '../toolbox/toolbox_tests'; @@ -381,6 +382,13 @@ export class Editor { this.blocklyWorkspace, methodNamesAlreadyOverridden); return methodNamesAlreadyOverridden; } + public isStepsInWorkspace(): boolean { + if (!this.blocklyWorkspace.rendered) { + // This editor has been abandoned. + throw new Error('this.blocklyWorkspace has been disposed.'); + } + return blockSteps.isStepsInWorkspace(this.blocklyWorkspace); + } public getEventsFromWorkspace(): storageModuleContent.Event[] { if (!this.blocklyWorkspace.rendered) { diff --git a/src/fields/field_flydown.ts b/src/fields/field_flydown.ts index cf75e2ff..f178d7ae 100644 --- a/src/fields/field_flydown.ts +++ b/src/fields/field_flydown.ts @@ -23,6 +23,8 @@ * https://github.com/mit-cml/blockly-plugins/blob/main/block-lexical-variables/src/fields/field_flydown.js */ import * as Blockly from 'blockly'; +import {BLOCK_NAME as JUMP_TO_STEP} from '../blocks/mrc_jump_to_step'; +import {BLOCK_NAME as GET_PARAMETER} from '../blocks/mrc_get_parameter'; enum FlydownLocation { DISPLAY_BELOW = 'displayBelow', @@ -179,10 +181,17 @@ export class FieldFlydown extends Blockly.FieldTextInput { private boundMouseOutHandler_: (e: Event) => void; private showTimeout_: number | null = null; private hideTimeout_: number | null = null; - - constructor(value: string, isEditable: boolean, displayLocation: FlydownLocation = FlydownLocation.DISPLAY_RIGHT) { + private createFlyoutDefinition_: (text: string) => Blockly.utils.toolbox.FlyoutDefinition; + + constructor( + value: string, + isEditable: boolean, + createFlyoutDefinition: (text: string) => Blockly.utils.toolbox.FlyoutDefinition, + displayLocation: FlydownLocation = FlydownLocation.DISPLAY_RIGHT + ) { super(value); this.EDITABLE = isEditable; + this.createFlyoutDefinition_ = createFlyoutDefinition; this.displayLocation_ = displayLocation; // Bind the handlers @@ -309,17 +318,7 @@ export class FieldFlydown extends Blockly.FieldTextInput { } private getBlocksForFlydown_() { const name = this.getText(); - return { - contents: [ - { - kind: 'block', - type: 'mrc_get_parameter', - fields: { - PARAMETER_NAME: name, - }, - }, - ] - }; + return this.createFlyoutDefinition_(name); } @@ -372,9 +371,48 @@ export class FieldFlydown extends Blockly.FieldTextInput { } super.dispose(); } +} + +function createParameterBlock(paramName: string): Blockly.utils.toolbox.FlyoutDefinition { + return { + contents: [ + { + kind: 'block', + type: GET_PARAMETER, + fields: { + PARAMETER_NAME: paramName, + }, + }, + ] + }; +} + +function createJumpToStepBlock(stepName: string): Blockly.utils.toolbox.FlyoutDefinition { + return { + contents: [ + { + kind: 'block', + type: JUMP_TO_STEP, + fields: { + STEP_NAME: stepName, + }, + }, + ] + }; +} + +export function createFieldFlydown( + label: string, + isEditable: boolean, + createFlyoutDefinition: (text: string) => Blockly.utils.toolbox.FlyoutDefinition +): Blockly.Field { + return new FieldFlydown(label, isEditable, createFlyoutDefinition); +} +export function createParameterFieldFlydown(paramName: string, isEditable: boolean): Blockly.Field { + return new FieldFlydown(paramName, isEditable, createParameterBlock); } -export function createFieldFlydown(label: string, isEditable: boolean): Blockly.Field { - return new FieldFlydown(label, isEditable); +export function createStepFieldFlydown(stepName: string, isEditable: boolean): Blockly.Field { + return new FieldFlydown(stepName, isEditable, createJumpToStepBlock); } \ No newline at end of file diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 2c514369..668e3647 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -125,6 +125,7 @@ "PARAMETER": "parameter", "PARAMETERS": "Parameters", "PARAMETERS_CAN_ONLY_GO_IN_THEIR_METHODS_BLOCK": "Parameters can only go in their method's block", + "JUMP_CAN_ONLY_GO_IN_THEIR_STEPS_BLOCK": "Jump can only go in their step's block", "EVENT_HANDLER_ALREADY_ON_WORKSPACE": "This event handler is already on the workspace.", "EVENT_HANDLER_ROBOT_EVENT_NOT_FOUND": "This block is an event handler for an event that no longer exists.", "EVENT_HANDLER_MECHANISM_EVENT_NOT_FOUND": "This block is an event handler for an event that no longer exists.", @@ -147,6 +148,9 @@ "GET": "get", "SET": "set", "TO": "to", + "STEPS": "steps", + "REPEAT_UNTIL": "repeat until", + "JUMP_TO": "jump to", "CUSTOM_EVENTS_LABEL": "Custom Events", "CUSTOM_METHODS_LABEL": "Custom Methods", "MORE_ROBOT_METHODS_LABEL": "More Robot Methods", @@ -162,6 +166,7 @@ "OPMODE_GROUP": "An optional group to group OpModes on Driver Station", "COMPONENTS": "These components are visible in this mechanism, the robot, and all opmodes.", "PRIVATE_COMPONENTS": "These components will not be visible in the robot or opmodes. They are only accessible within this mechanism.", + "JUMP_TO_STEP": "Jump to the step named {{stepName}}.", "CALL_BUILTIN_FUNCTION": "Calls the builtin function {{functionName}}.", "CALL_MODULE_FUNCTION": "Calls the module function {{moduleName}}.{{functionName}}.", "CALL_STATIC_METHOD": "Calls the static method {{className}}.{{functionName}}.", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index a7a51086..907bfa43 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -126,7 +126,8 @@ "PARAMETER": "parámetro", "PARAMETERS": "Parámetros", "PARAMETERS_CAN_ONLY_GO_IN_THEIR_METHODS_BLOCK": "Los parámetros solo pueden ir en el bloque de su método", - "EVENT_HANDLER_ALREADY_ON_WORKSPACE": "Este controlador de eventos ya está en el área de trabajo.", + "JUMP_CAN_ONLY_GO_IN_THEIR_STEPS_BLOCK": "El salto solo puede ir en el bloque de su paso", + "EVENT_HANDLER_ALREADY_ON_WORKSPACE": "Este controlador de eventos ya está en el espacio de trabajo.", "EVENT_HANDLER_ROBOT_EVENT_NOT_FOUND": "Este bloque es un controlador de eventos para un evento que ya no existe.", "EVENT_HANDLER_MECHANISM_EVENT_NOT_FOUND": "Este bloque es un controlador de eventos para un evento que ya no existe.", "EVENT_HANDLER_MECHANISM_NOT_FOUND": "Este bloque es un controlador de eventos para un evento en un mecanismo que ya no existe.", @@ -148,6 +149,9 @@ "GET": "obtener", "SET": "establecer", "TO": "a", + "STEPS": "pasos", + "REPEAT_UNTIL": "repetir hasta", + "JUMP_TO": "saltar a", "CUSTOM_EVENTS_LABEL": "Eventos Personalizados", "CUSTOM_METHODS_LABEL": "Métodos Personalizados", "MORE_ROBOT_METHODS_LABEL": "Más Métodos del Robot", @@ -163,6 +167,7 @@ "OPMODE_GROUP": "Un grupo opcional para agrupar OpModes en la Estación del Conductor", "COMPONENTS": "Estos componentes son visibles en este mecanismo, el robot y todos los opmodes.", "PRIVATE_COMPONENTS": "Estos componentes no serán visibles en el robot o en los opmodes. Solo son accesibles dentro de este mecanismo.", + "JUMP_TO_STEP": "Saltar al paso llamado {{stepName}}.", "CALL_BUILTIN_FUNCTION": "Llama a la función incorporada {{functionName}}.", "CALL_MODULE_FUNCTION": "Llama a la función del módulo {{moduleName}}.{{functionName}}.", "CALL_STATIC_METHOD": "Llama al método estático {{className}}.{{functionName}}.", diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index e1a21604..56f6e55d 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -124,8 +124,9 @@ "WHEN": "כאשר", "PARAMETER": "פרמטר", "PARAMETERS": "פרמטרים", - "PARAMETERS_CAN_ONLY_GO_IN_THEIR_METHODS_BLOCK": "פרמטרים יכולים להופיע רק בתוך הבלוק של המתודה שלהם.", - "EVENT_HANDLER_ALREADY_ON_WORKSPACE": "מנהל האירועים הזה כבר נמצא בסביבת העבודה.", + "PARAMETERS_CAN_ONLY_GO_IN_THEIR_METHODS_BLOCK": "פרמטרים יכולים ללכת רק בבלוק השיטה שלהם", + "JUMP_CAN_ONLY_GO_IN_THEIR_STEPS_BLOCK": "קפיצה יכולה ללכת רק בבלוק הצעד שלה", + "EVENT_HANDLER_ALREADY_ON_WORKSPACE": "מטפל אירועים זה כבר נמצא במרחב העבודה.", "EVENT_HANDLER_ROBOT_EVENT_NOT_FOUND": "הבלוק הזה הוא מנהל אירועים לאירוע שכבר לא קיים.", "EVENT_HANDLER_MECHANISM_EVENT_NOT_FOUND": "הבלוק הזה הוא מנהל אירועים לאירוע שכבר לא קיים.", "EVENT_HANDLER_MECHANISM_NOT_FOUND": "הבלוק הזה הוא מנהל אירועים לאירוע במנגנון שכבר לא קיים.", @@ -147,6 +148,9 @@ "GET": "קבל", "SET": "הגדר", "TO": "ל", + "STEPS": "צעדים", + "REPEAT_UNTIL": "לחזור על כך עד", + "JUMP_TO": "לקפוץ אל", "CUSTOM_EVENTS_LABEL": "אירועים מותאמים אישית", "CUSTOM_METHODS_LABEL": "מתודות מותאמות אישית", "MORE_ROBOT_METHODS_LABEL": "מתודות נוספות לרובוט", @@ -162,6 +166,7 @@ "OPMODE_GROUP": "קבוצה אופציונלית לארגון אופמודים באפליקציית ה־Driver Station", "COMPONENTS": "הרכיבים האלה גלויים במנגנון הזה, ברובוט ובכל האופמודים.", "PRIVATE_COMPONENTS": "הרכיבים האלה לא יהיו גלויים ברובוט או באופמודים. הם נגישים רק בתוך המנגנון הזה.", + "JUMP_TO_STEP": "קפוץ לשלב בשם {{stepName}}.", "CALL_BUILTIN_FUNCTION": "קורא לפונקציה מובנית בשם {{functionName}}.", "CALL_MODULE_FUNCTION": "קורא לפונקציה {{functionName}} במודול {{moduleName}}.", "CALL_STATIC_METHOD": "קורא למתודה סטטית {{className}}.{{functionName}}.", diff --git a/src/storage/upgrade_project.ts b/src/storage/upgrade_project.ts index 5b148256..53a057ee 100644 --- a/src/storage/upgrade_project.ts +++ b/src/storage/upgrade_project.ts @@ -32,7 +32,7 @@ import { ClassMethodDefBlock, BLOCK_NAME as MRC_CLASS_METHOD_DEF_BLOCK_NAME } fr import * as workspaces from '../blocks/utils/workspaces'; export const NO_VERSION = '0.0.0'; -export const CURRENT_VERSION = '0.0.3'; +export const CURRENT_VERSION = '0.0.4'; export async function upgradeProjectIfNecessary( storage: commonStorage.Storage, projectName: string): Promise { @@ -46,8 +46,15 @@ export async function upgradeProjectIfNecessary( // @ts-ignore case '0.0.1': upgradeFrom_001_to_002(storage, projectName, projectInfo); + // Intentional fallthrough + // @ts-ignore case '0.0.2': - upgradeFrom_002_to_003(storage, projectName, projectInfo); + upgradeFrom_002_to_003(storage, projectName, projectInfo); + case '0.0.3': + upgradeFrom_003_to_004(storage, projectName, projectInfo); + break; + default: + throw new Error('Unrecognized project version: ' + projectInfo.version); } await storageProject.saveProjectInfo(storage, projectName); @@ -141,3 +148,12 @@ async function upgradeFrom_002_to_003( } projectInfo.version = '0.0.3'; } + +async function upgradeFrom_003_to_004( + _storage: commonStorage.Storage, + _projectName: string, + projectInfo: storageProject.ProjectInfo): Promise { + // The only change in this version are some new blocks. This keeps you + // from loading a project with an older version of software. + projectInfo.version = '0.0.4'; +} diff --git a/src/themes/styles.ts b/src/themes/styles.ts index c8beecd4..28377a40 100644 --- a/src/themes/styles.ts +++ b/src/themes/styles.ts @@ -22,6 +22,7 @@ import * as Blockly from 'blockly/core' export const MRC_STYLE_FUNCTIONS = 'mrc_style_function'; +export const MRC_STYLE_STEPS = 'mrc_style_steps'; export const MRC_STYLE_ENUM = 'mrc_style_enum'; export const MRC_STYLE_VARIABLES = 'mrc_style_variables'; export const MRC_STYLE_COMMENTS = 'mrc_style_comments'; @@ -51,6 +52,9 @@ export const add_mrc_styles = function (theme: Blockly.Theme): Blockly.Theme { theme.setBlockStyle(MRC_STYLE_EVENTS, { ...procedureStyle }); + theme.setBlockStyle(MRC_STYLE_STEPS, { + ...procedureStyle, + }); theme.setBlockStyle(MRC_STYLE_ENUM, { ...variableStyle }); diff --git a/src/toolbox/methods_category.ts b/src/toolbox/methods_category.ts index 23695623..163d259a 100644 --- a/src/toolbox/methods_category.ts +++ b/src/toolbox/methods_category.ts @@ -27,6 +27,7 @@ import { MRC_CATEGORY_STYLE_METHODS } from '../themes/styles'; import { CLASS_NAME_ROBOT_BASE, CLASS_NAME_OPMODE, CLASS_NAME_MECHANISM } from '../blocks/utils/python'; import { addInstanceWithinBlocks } from '../blocks/mrc_call_python_function'; import { createCustomMethodBlock, getBaseClassBlocks, FIELD_METHOD_NAME, createCustomMethodBlockWithReturn } from '../blocks/mrc_class_method_def'; +import { createStepsBlock } from '../blocks/mrc_steps'; import { Editor } from '../editor/editor'; @@ -99,6 +100,10 @@ class MethodsCategory { methodNamesAlreadyOverridden, contents); break; case storageModule.ModuleType.OPMODE: + const hasSteps = editor.isStepsInWorkspace(); + if (!hasSteps) { + contents.push(createStepsBlock()); + } // Add the methods for an OpMode. this.addClassBlocksForCurrentModule( Blockly.Msg['MORE_OPMODE_METHODS_LABEL'], this.opmodeClassBlocks, [],