diff --git a/build.gradle b/build.gradle index 081223afa5..848bc748d2 100644 --- a/build.gradle +++ b/build.gradle @@ -416,6 +416,7 @@ project(":ui") { testCompile group: 'org.testfx', name: 'testfx-core', version: '4.0.+' testCompile group: 'org.testfx', name: 'testfx-junit', version: '4.0.+' testRuntime group: 'org.testfx', name: 'openjfx-monocle', version: '1.8.0_20' + compile group: 'org.fxmisc.richtext', name: 'richtextfx', version: '0.7-M2' testCompile group: 'org.opencv', name: 'opencv-java', version: '3.1.0' } diff --git a/core/src/main/java/edu/wpi/grip/core/OperationDescription.java b/core/src/main/java/edu/wpi/grip/core/OperationDescription.java index 6647c3b996..f63496d033 100644 --- a/core/src/main/java/edu/wpi/grip/core/OperationDescription.java +++ b/core/src/main/java/edu/wpi/grip/core/OperationDescription.java @@ -138,6 +138,7 @@ public enum Category { LOGICAL, OPENCV, MISCELLANEOUS, + CUSTOM, } /** diff --git a/core/src/main/java/edu/wpi/grip/core/Palette.java b/core/src/main/java/edu/wpi/grip/core/Palette.java index 1768fc5e6a..cb4d2e3d5c 100644 --- a/core/src/main/java/edu/wpi/grip/core/Palette.java +++ b/core/src/main/java/edu/wpi/grip/core/Palette.java @@ -1,8 +1,11 @@ package edu.wpi.grip.core; import edu.wpi.grip.core.events.OperationAddedEvent; +import edu.wpi.grip.core.events.OperationRemovedEvent; +import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; +import com.google.inject.Inject; import java.util.Collection; import java.util.LinkedHashMap; @@ -11,7 +14,6 @@ import javax.inject.Singleton; -import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; /** @@ -20,8 +22,14 @@ @Singleton public class Palette { + private final EventBus eventBus; private final Map operations = new LinkedHashMap<>(); + @Inject + Palette(EventBus eventBus) { + this.eventBus = eventBus; + } + @Subscribe public void onOperationAdded(OperationAddedEvent event) { final OperationMetaData operation = event.getOperation(); @@ -39,7 +47,20 @@ public void onOperationAdded(OperationAddedEvent event) { * @throws IllegalArgumentException if the key is already in the {@link #operations} map. */ private void map(String key, OperationMetaData operation) { - checkArgument(!operations.containsKey(key), "Operation name or alias already exists: " + key); + if (operations.containsKey(key)) { + OperationDescription existing = operations.get(key).getDescription(); + if (existing.category() == operation.getDescription().category() + && existing.category() == OperationDescription.Category.CUSTOM) { + // It's a custom operation that can be changed at runtime, allow it + // (But first remove the existing operation) + operations.remove(key); + eventBus.post(new OperationRemovedEvent(existing)); + } else { + // Not a custom operation, this should only happen if someone + // adds a new operation and uses an already-taken name + throw new IllegalArgumentException("Operation name or alias already exists: " + key); + } + } operations.put(key, operation); } diff --git a/core/src/main/java/edu/wpi/grip/core/events/OperationRemovedEvent.java b/core/src/main/java/edu/wpi/grip/core/events/OperationRemovedEvent.java new file mode 100644 index 0000000000..7cfa3e3c1b --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/events/OperationRemovedEvent.java @@ -0,0 +1,21 @@ +package edu.wpi.grip.core.events; + +import edu.wpi.grip.core.OperationDescription; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * An event fired when an operation is removed from the palette. + */ +public class OperationRemovedEvent { + + private final OperationDescription removedOperation; + + public OperationRemovedEvent(OperationDescription removedOperation) { + this.removedOperation = checkNotNull(removedOperation); + } + + public OperationDescription getRemovedOperation() { + return removedOperation; + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/operations/Operations.java b/core/src/main/java/edu/wpi/grip/core/operations/Operations.java index fee67bfc93..13960dffbf 100644 --- a/core/src/main/java/edu/wpi/grip/core/operations/Operations.java +++ b/core/src/main/java/edu/wpi/grip/core/operations/Operations.java @@ -40,6 +40,8 @@ import edu.wpi.grip.core.operations.opencv.MinMaxLoc; import edu.wpi.grip.core.operations.opencv.NewPointOperation; import edu.wpi.grip.core.operations.opencv.NewSizeOperation; +import edu.wpi.grip.core.operations.python.PythonOperationUtils; +import edu.wpi.grip.core.operations.python.PythonScriptOperation; import edu.wpi.grip.core.sockets.InputSocket; import edu.wpi.grip.core.sockets.OutputSocket; @@ -53,6 +55,10 @@ import org.bytedeco.javacpp.opencv_core.Point; import org.bytedeco.javacpp.opencv_core.Size; +import java.util.Arrays; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import static com.google.common.base.Preconditions.checkNotNull; @Singleton @@ -75,7 +81,8 @@ public class Operations { checkNotNull(httpPublishFactory, "httpPublisherFactory cannot be null"); checkNotNull(rosPublishFactory, "rosPublishFactory cannot be null"); checkNotNull(fileManager, "fileManager cannot be null"); - this.operations = ImmutableList.of( + PythonOperationUtils.checkDirExists(); + this.operations = new ImmutableList.Builder().addAll(Arrays.asList( // Composite operations new OperationMetaData(BlurOperation.DESCRIPTION, () -> new BlurOperation(isf, osf)), @@ -186,7 +193,16 @@ public class Operations { new OperationMetaData(HttpPublishOperation.descriptionFor(Boolean.class), () -> new HttpPublishOperation<>(isf, Boolean.class, BooleanPublishable.class, BooleanPublishable::new, httpPublishFactory)) - ); + )).addAll( + Stream.of(PythonOperationUtils.DIRECTORY.listFiles((dir, name) -> name.endsWith(".py"))) + .map(PythonOperationUtils::read) + .filter(code -> code != null) // read() returns null if the file couldn't be read + .map(PythonOperationUtils::tryCreate) + .filter(script -> script != null) // create() returns null if the code has errors + .map(psf -> new OperationMetaData(PythonScriptOperation.descriptionFor(psf), + () -> new PythonScriptOperation(isf, osf, psf))) + .collect(Collectors.toList()) + ).build(); } @VisibleForTesting diff --git a/core/src/main/java/edu/wpi/grip/core/operations/python/PythonOperationUtils.java b/core/src/main/java/edu/wpi/grip/core/operations/python/PythonOperationUtils.java new file mode 100644 index 0000000000..0c1f2a9a2f --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/operations/python/PythonOperationUtils.java @@ -0,0 +1,76 @@ +package edu.wpi.grip.core.operations.python; + +import edu.wpi.grip.core.GripFileManager; + +import org.python.core.PyException; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility class handling functionality for custom python operation files on disk. + */ +public final class PythonOperationUtils { + + private static final Logger log = Logger.getLogger(PythonOperationUtils.class.getName()); + + /** + * The directory where custom python operation files are stored. + */ + public static final File DIRECTORY = new File(GripFileManager.GRIP_DIRECTORY, "operations"); + + private PythonOperationUtils() { + // Utility class, avoid instantiation + } + + /** + * Reads the contents of the given file. Assumes it's encoded as UTF-8. + * + * @param file the file to read + * @return the String contents of the file, in UTF-8 encoding + */ + public static String read(File file) { + if (!file.getParentFile().equals(DIRECTORY) || !file.getName().endsWith(".py")) { + throw new IllegalArgumentException( + "Not a custom python operation: " + file.getAbsolutePath()); + } + try { + return new String(Files.readAllBytes(file.toPath()), Charset.forName("UTF-8")); + } catch (IOException e) { + log.log(Level.WARNING, "Could not read " + file.getAbsolutePath(), e); + return null; + } + } + + /** + * Ensures that {@link #DIRECTORY} exists. + */ + public static void checkDirExists() { + if (DIRECTORY.exists()) { + return; + } + DIRECTORY.mkdirs(); + } + + /** + * Tries to create a {@code PythonScriptFile} from the given python script. If the script has + * errors (syntax or runtime), the first one encountered will be logged along with the contents + * of the script. + * + * @param code the python script to create a {@code PythonScriptFile} from + * @return a {@code PythonScriptFile} for the given python script + */ + public static PythonScriptFile tryCreate(String code) { + try { + return PythonScriptFile.create(code); + } catch (PyException e) { + log.log(Level.WARNING, "Error in python script", e); + return null; + } + } + +} diff --git a/core/src/main/java/edu/wpi/grip/core/operations/PythonScriptFile.java b/core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptFile.java similarity index 64% rename from core/src/main/java/edu/wpi/grip/core/operations/PythonScriptFile.java rename to core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptFile.java index c29536bfa4..d7cba5e461 100644 --- a/core/src/main/java/edu/wpi/grip/core/operations/PythonScriptFile.java +++ b/core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptFile.java @@ -1,4 +1,4 @@ -package edu.wpi.grip.core.operations; +package edu.wpi.grip.core.operations.python; import edu.wpi.grip.core.OperationMetaData; @@ -8,6 +8,7 @@ import com.google.auto.value.AutoValue; +import org.python.core.PyException; import org.python.core.PyFunction; import org.python.core.PyObject; import org.python.core.PyString; @@ -26,6 +27,30 @@ @AutoValue public abstract class PythonScriptFile { + /** + * Template for custom python operations. Includes imports for sockets, as well as OpenCV + * core and image processing classes. + * + *

The sample operation is a simple arithmetic "add" that hopefully shows how the script + * should be written.

+ */ + public static final String TEMPLATE = + "import edu.wpi.grip.core.sockets.SocketHints.Inputs as inputs\n" + + "import edu.wpi.grip.core.sockets.SocketHints.Outputs as outputs\n" + + "import org.bytedeco.javacpp.opencv_core as opencv_core\n" + + "import org.bytedeco.javacpp.opencv_imgproc as opencv_imgproc\n\n" + + "name = \"Addition Sample\"\n" + + "summary = \"The sample python operation to add two numbers\"\n\n" + + "inputs = [\n" + + " inputs.createNumberSpinnerSocketHint(\"a\", 0.0),\n" + + " inputs.createNumberSpinnerSocketHint(\"b\", 0.0),\n" + + "]\n" + + "outputs = [\n" + + " outputs.createNumberSocketHint(\"sum\", 0.0),\n" + + "]\n\n\n" // two blank lines + + "def perform(a, b):\n" + + " return a + b\n"; + static { Properties pythonProperties = new Properties(); pythonProperties.setProperty("python.import.site", "false"); @@ -34,7 +59,7 @@ public abstract class PythonScriptFile { /** * @param url The URL to get the script file from. - * @return The constructed PythonScript file. + * @return The constructed PythonScriptFile. * @throws IOException If the URL fails to open. */ public static PythonScriptFile create(URL url) throws IOException { @@ -48,7 +73,8 @@ public static PythonScriptFile create(URL url) throws IOException { /** * @param code The code to create the file from. - * @return The constructed PythonScript file. + * @return The constructed PythonScriptFile. + * @throws PyException if the code has syntax or runtime errors */ public static PythonScriptFile create(String code) { final PythonInterpreter interpreter = new PythonInterpreter(); @@ -88,9 +114,11 @@ private static PythonScriptFile create(PythonInterpreter interpreter, String alt * @param osf Output Socket Factory * @return The meta data for a {@link PythonScriptOperation} */ - public final OperationMetaData toOperationMetaData(InputSocket.Factory isf, OutputSocket - .Factory osf) { - return new OperationMetaData(PythonScriptOperation.descriptionFor(this), () -> new - PythonScriptOperation(isf, osf, this)); + public final OperationMetaData toOperationMetaData(InputSocket.Factory isf, + OutputSocket.Factory osf) { + return new OperationMetaData( + PythonScriptOperation.descriptionFor(this), + () -> new PythonScriptOperation(isf, osf, this) + ); } } diff --git a/core/src/main/java/edu/wpi/grip/core/operations/PythonScriptOperation.java b/core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptOperation.java similarity index 73% rename from core/src/main/java/edu/wpi/grip/core/operations/PythonScriptOperation.java rename to core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptOperation.java index 378bbcee6e..e7cd996e40 100644 --- a/core/src/main/java/edu/wpi/grip/core/operations/PythonScriptOperation.java +++ b/core/src/main/java/edu/wpi/grip/core/operations/python/PythonScriptOperation.java @@ -1,4 +1,4 @@ -package edu.wpi.grip.core.operations; +package edu.wpi.grip.core.operations.python; import edu.wpi.grip.core.Operation; import edu.wpi.grip.core.OperationDescription; @@ -13,7 +13,6 @@ import org.python.core.PySequence; import java.util.List; -import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -81,7 +80,7 @@ public static OperationDescription descriptionFor(PythonScriptFile pythonScriptF .name(pythonScriptFile.name()) .summary(pythonScriptFile.summary()) .icon(Icon.iconStream("python")) - .category(OperationDescription.Category.MISCELLANEOUS) + .category(OperationDescription.Category.CUSTOM) .build(); } @@ -121,44 +120,31 @@ public void perform() { pyInputs[i] = Py.java2py(inputSockets.get(i).getValue().get()); } - try { - PyObject pyOutput = this.scriptFile.performFunction().__call__(pyInputs); - - if (pyOutput.isSequenceType()) { - /* - * If the Python function returned a sequence type, there must be multiple outputs for - * this step. - * Each element in the sequence is assigned to one output socket. - */ - PySequence pySequence = (PySequence) pyOutput; - Object[] javaOutputs = Py.tojava(pySequence, Object[].class); - - if (outputSockets.size() != javaOutputs.length) { - throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), - javaOutputs.length)); - } - - for (int i = 0; i < javaOutputs.length; i++) { - outputSockets.get(i).setValue(javaOutputs[i]); - } - } else { - /* If the Python script did not return a sequence, there should only be one - output socket. */ - if (outputSockets.size() != 1) { - throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), 1)); - } - - Object javaOutput = Py.tojava(pyOutput, outputSockets.get(0).getSocketHint().getType()); - outputSockets.get(0).setValue(javaOutput); + PyObject pyOutput = this.scriptFile.performFunction().__call__(pyInputs); + + if (pyOutput.isSequenceType()) { + // If the Python function returned a sequence type, + // there must be multiple outputs for this step. + // Each element in the sequence is assigned to one output socket. + PySequence pySequence = (PySequence) pyOutput; + Object[] javaOutputs = Py.tojava(pySequence, Object[].class); + + if (outputSockets.size() != javaOutputs.length) { + throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), + javaOutputs.length)); + } + + for (int i = 0; i < javaOutputs.length; i++) { + outputSockets.get(i).setValue(javaOutputs[i]); + } + } else { + // If the Python script did not return a sequence, there should only be one output socket. + if (outputSockets.size() != 1) { + throw new IllegalArgumentException(wrongNumberOfArgumentsMsg(outputSockets.size(), 1)); } - } catch (RuntimeException e) { - /* Exceptions can happen if there's a mistake in a Python script, so just print a - stack trace and leave the - * current state of the output sockets alone. - * - * TODO: communicate the error to the GUI. - */ - logger.log(Level.WARNING, e.getMessage(), e); + + Object javaOutput = Py.tojava(pyOutput, outputSockets.get(0).getSocketHint().getType()); + outputSockets.get(0).setValue(javaOutput); } } } diff --git a/core/src/test/java/edu/wpi/grip/core/PaletteTest.java b/core/src/test/java/edu/wpi/grip/core/PaletteTest.java index caf7ef5f66..a38ad13897 100644 --- a/core/src/test/java/edu/wpi/grip/core/PaletteTest.java +++ b/core/src/test/java/edu/wpi/grip/core/PaletteTest.java @@ -12,6 +12,8 @@ import java.util.Optional; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class PaletteTest { private Palette palette; @@ -21,13 +23,13 @@ public class PaletteTest { @Before public void setUp() { eventBus = new EventBus(); - palette = new Palette(); + palette = new Palette(eventBus); eventBus.register(palette); operation = new OperationMetaData(OperationDescription.builder() .name("Find Target") .summary("") .build(), - () -> null); + MockOperation::new); } @Test @@ -47,4 +49,42 @@ public void testGetNonexistantOperation() { eventBus.post(new OperationAddedEvent(operation)); assertEquals(Optional.empty(), palette.getOperationByName("Test")); } + + @Test + public void testReplacedOperation() { + // Only a custom operation may be replaced, and only by another custom operation + // with the same name + final String name = "Custom Operation"; + OperationMetaData first = new OperationMetaData(OperationDescription.builder() + .name(name) + .summary("A summary") + .category(OperationDescription.Category.CUSTOM) + .build(), + MockOperation::new); + OperationMetaData second = new OperationMetaData(OperationDescription.builder() + .name(name) + .summary("Another summary") + .category(OperationDescription.Category.CUSTOM) + .build(), + MockOperation::new); + eventBus.post(new OperationAddedEvent(first)); + assertTrue(palette.getOperations().contains(first)); + assertFalse(palette.getOperations().contains(second)); + eventBus.post(new OperationAddedEvent(second)); + assertFalse(palette.getOperations().contains(first)); + assertTrue(palette.getOperations().contains(second)); + } + + @Test(expected = IllegalArgumentException.class) + public void testAddOperationWithSameName() { + OperationMetaData custom = new OperationMetaData(OperationDescription.builder() + .name(operation.getDescription().name()) + .summary("Custom operation with the name of another operation in the palette") + .category(OperationDescription.Category.CUSTOM) + .build(), + MockOperation::new); + palette.onOperationAdded(new OperationAddedEvent(operation)); // EventBus messes with exceptions + palette.onOperationAdded(new OperationAddedEvent(custom)); + } + } diff --git a/core/src/test/java/edu/wpi/grip/core/PythonTest.java b/core/src/test/java/edu/wpi/grip/core/PythonTest.java index cbd4110511..4ef8a1371e 100644 --- a/core/src/test/java/edu/wpi/grip/core/PythonTest.java +++ b/core/src/test/java/edu/wpi/grip/core/PythonTest.java @@ -1,7 +1,7 @@ package edu.wpi.grip.core; -import edu.wpi.grip.core.operations.PythonScriptFile; import edu.wpi.grip.core.operations.network.MockGripNetworkModule; +import edu.wpi.grip.core.operations.python.PythonScriptFile; import edu.wpi.grip.core.sockets.InputSocket; import edu.wpi.grip.core.sockets.OutputSocket; import edu.wpi.grip.core.sockets.Socket; diff --git a/core/src/test/java/edu/wpi/grip/core/operations/python/PythonOperationUtilsTest.java b/core/src/test/java/edu/wpi/grip/core/operations/python/PythonOperationUtilsTest.java new file mode 100644 index 0000000000..cb33cd487e --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/operations/python/PythonOperationUtilsTest.java @@ -0,0 +1,47 @@ +package edu.wpi.grip.core.operations.python; + +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static edu.wpi.grip.core.operations.python.PythonOperationUtils.DIRECTORY; +import static edu.wpi.grip.core.operations.python.PythonOperationUtils.checkDirExists; +import static edu.wpi.grip.core.operations.python.PythonOperationUtils.read; +import static edu.wpi.grip.core.operations.python.PythonOperationUtils.tryCreate; +import static edu.wpi.grip.core.operations.python.PythonScriptFile.TEMPLATE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class PythonOperationUtilsTest { + + @Test(expected = IllegalArgumentException.class) + public void testNotCorrectFile() { + read(new File(System.getProperty("user.home"))); + } + + @Test(expected = IllegalArgumentException.class) + public void testNotPythonFile() { + read(new File(DIRECTORY, "not-a-python-file.txt")); + } + + @Test + public void testReadFile() throws IOException { + File file = new File(DIRECTORY, "a-python-file.py"); + file.deleteOnExit(); + checkDirExists(); + file.createNewFile(); + Files.write(file.toPath(), TEMPLATE.getBytes("UTF-8")); + String read = read(file); + assertEquals(TEMPLATE, read); + } + + @Test + public void testTryCreate() { + assertNotNull(tryCreate(TEMPLATE)); + assertNull(tryCreate("not executable python code")); + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/serialization/ProjectTest.java b/core/src/test/java/edu/wpi/grip/core/serialization/ProjectTest.java index b119685574..6c3090c81b 100644 --- a/core/src/test/java/edu/wpi/grip/core/serialization/ProjectTest.java +++ b/core/src/test/java/edu/wpi/grip/core/serialization/ProjectTest.java @@ -12,8 +12,8 @@ import edu.wpi.grip.core.events.OperationAddedEvent; import edu.wpi.grip.core.events.ProjectSettingsChangedEvent; import edu.wpi.grip.core.events.SourceAddedEvent; -import edu.wpi.grip.core.operations.PythonScriptFile; import edu.wpi.grip.core.operations.network.MockGripNetworkModule; +import edu.wpi.grip.core.operations.python.PythonScriptFile; import edu.wpi.grip.core.settings.ProjectSettings; import edu.wpi.grip.core.sockets.InputSocket; import edu.wpi.grip.core.sockets.OutputSocket; diff --git a/ui/src/main/java/edu/wpi/grip/ui/CustomOperationsListController.java b/ui/src/main/java/edu/wpi/grip/ui/CustomOperationsListController.java new file mode 100644 index 0000000000..51044b7a60 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/CustomOperationsListController.java @@ -0,0 +1,82 @@ +package edu.wpi.grip.ui; + +import edu.wpi.grip.core.OperationMetaData; +import edu.wpi.grip.core.Palette; +import edu.wpi.grip.core.Pipeline; +import edu.wpi.grip.core.events.OperationAddedEvent; +import edu.wpi.grip.core.operations.python.PythonScriptFile; +import edu.wpi.grip.core.operations.python.PythonScriptOperation; +import edu.wpi.grip.core.sockets.InputSocket; +import edu.wpi.grip.core.sockets.OutputSocket; +import edu.wpi.grip.ui.annotations.ParametrizedController; +import edu.wpi.grip.ui.python.PythonEditorController; + +import com.google.common.eventbus.EventBus; +import com.google.inject.Inject; + +import java.io.IOException; + +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.stage.Stage; + +/** + * Controller for the custom operations list. + */ +@ParametrizedController(url = "CustomOperationsList.fxml") +public class CustomOperationsListController extends OperationListController { + + @FXML private Button createNewButton; + @Inject private EventBus eventBus; + @Inject private InputSocket.Factory isf; + @Inject private OutputSocket.Factory osf; + @Inject private Palette palette; + @Inject private Main main; + @Inject private Pipeline pipeline; + + @FXML + @SuppressWarnings("PMD.UnusedPrivateMethod") + private void createNewPythonOperation() { + PythonEditorController editorController = loadEditor(); + editorController.injectMembers( + name -> palette.getOperationByName(name).isPresent() + || pipeline.getSteps() + .stream() + .map(step -> step.getOperationDescription().name()) + .anyMatch(name::equals), + main.getHostServices() + ); + Stage stage = new Stage(); + stage.setTitle("Python Script Editor"); + stage.setScene(new Scene(editorController.getRoot())); + createNewButton.setDisable(true); + try { + stage.showAndWait(); + String code = editorController.getScript(); + if (code != null) { + PythonScriptFile script = PythonScriptFile.create(code); + eventBus.post(new OperationAddedEvent(new OperationMetaData( + PythonScriptOperation.descriptionFor(script), + () -> new PythonScriptOperation(isf, osf, script) + ))); + } + } finally { + // make sure the button is re-enabled even if an exception gets thrown + createNewButton.setDisable(false); + } + } + + private PythonEditorController loadEditor() { + FXMLLoader loader = new FXMLLoader(); + loader.setLocation(getClass().getResource("/edu/wpi/grip/ui/python/PythonEditor.fxml")); + try { + loader.load(); + return loader.getController(); + } catch (IOException e) { + throw new RuntimeException("Couldn't load python editor", e); + } + } + +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/OperationListController.java b/ui/src/main/java/edu/wpi/grip/ui/OperationListController.java index cb0135087c..87db1f7c4b 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/OperationListController.java +++ b/ui/src/main/java/edu/wpi/grip/ui/OperationListController.java @@ -1,7 +1,9 @@ package edu.wpi.grip.ui; +import edu.wpi.grip.core.OperationDescription; import edu.wpi.grip.core.OperationMetaData; import edu.wpi.grip.core.events.OperationAddedEvent; +import edu.wpi.grip.core.events.OperationRemovedEvent; import edu.wpi.grip.ui.annotations.ParametrizedController; import edu.wpi.grip.ui.util.ControllerMap; import edu.wpi.grip.ui.util.SearchUtility; @@ -17,7 +19,7 @@ import javafx.collections.MapChangeListener; import javafx.fxml.FXML; import javafx.scene.Node; -import javafx.scene.control.Tab; +import javafx.scene.control.TitledPane; import javafx.scene.layout.VBox; /** @@ -31,7 +33,7 @@ public class OperationListController { protected static final String FILTER_TEXT = "filterText"; private final StringProperty filterText = new SimpleStringProperty(this, FILTER_TEXT, ""); - @FXML private Tab root; + @FXML private TitledPane root; @FXML private VBox operations; @Inject private OperationController.Factory operationControllerFactory; @SuppressWarnings("PMD.SingularField") private String baseText = null; @@ -43,7 +45,7 @@ protected void initialize() { baseText = root.getText(); InvalidationListener filterOperations = observable -> { - if (baseText == null) { + if (baseText == null || baseText.isEmpty()) { baseText = root.getText(); } @@ -60,10 +62,8 @@ protected void initialize() { if (!filter.isEmpty() && numMatches > 0) { // If we're filtering some operations and there's at least one match, set the title to - // bold and show the - // number of matches. This lets the user quickly see which tabs have matching operations - // when - // searching. + // bold and show the number of matches. + // This lets the user quickly see which tabs have matching operations when searching. root.setText(baseText + " (" + numMatches + ")"); root.styleProperty().setValue("-fx-font-weight: bold"); } else { @@ -93,6 +93,18 @@ public void onOperationAdded(OperationAddedEvent event) { } } + @Subscribe + public void onOperationRemoved(OperationRemovedEvent event) { + OperationDescription removedOperation = event.getRemovedOperation(); + if (root.getUserData() == null || removedOperation.category() == root.getUserData()) { + PlatformImpl.runAndWait(() -> operationsMapManager.remove(operationsMapManager.keySet() + .stream() + .filter(c -> c.getOperationDescription().equals(removedOperation)) + .findFirst() + .orElse(null))); + } + } + /** * Remove all operations. Used for tests. */ diff --git a/ui/src/main/java/edu/wpi/grip/ui/PaletteController.java b/ui/src/main/java/edu/wpi/grip/ui/PaletteController.java index 4fd3b9013c..c770836596 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/PaletteController.java +++ b/ui/src/main/java/edu/wpi/grip/ui/PaletteController.java @@ -10,8 +10,8 @@ import javafx.beans.property.ObjectProperty; import javafx.fxml.FXML; -import javafx.scene.control.Tab; import javafx.scene.control.TextField; +import javafx.scene.control.TitledPane; import javafx.scene.layout.VBox; import javax.inject.Singleton; @@ -24,13 +24,14 @@ public class PaletteController { @FXML private VBox root; @FXML private CustomTextField operationSearch; - @FXML private Tab allOperations; - @FXML private Tab imgprocOperations; - @FXML private Tab featureOperations; - @FXML private Tab networkOperations; - @FXML private Tab logicalOperations; - @FXML private Tab opencvOperations; - @FXML private Tab miscellaneousOperations; + @FXML private TitledPane allOperations; + @FXML private TitledPane imgprocOperations; + @FXML private TitledPane featureOperations; + @FXML private TitledPane networkOperations; + @FXML private TitledPane logicalOperations; + @FXML private TitledPane opencvOperations; + @FXML private TitledPane miscellaneousOperations; + @FXML private TitledPane customOperations; @FXML protected void initialize() { @@ -51,23 +52,26 @@ protected void initialize() { logicalOperations.setUserData(OperationDescription.Category.LOGICAL); opencvOperations.setUserData(OperationDescription.Category.OPENCV); miscellaneousOperations.setUserData(OperationDescription.Category.MISCELLANEOUS); + customOperations.setUserData(OperationDescription.Category.CUSTOM); // Bind the filterText of all of the individual tabs to the search field operationSearch.textProperty().addListener(observable -> { - allOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch - .getText()); - imgprocOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch - .getText()); - featureOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch - .getText()); - networkOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch - .getText()); - logicalOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch - .getText()); - opencvOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch - .getText()); + allOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); + imgprocOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); + featureOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); + networkOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); + logicalOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); + opencvOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); miscellaneousOperations.getProperties().put(OperationListController.FILTER_TEXT, operationSearch.getText()); + customOperations.getProperties().put(OperationListController.FILTER_TEXT, + operationSearch.getText()); }); // The palette should have a lower priority for resizing than other elements diff --git a/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java b/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java index 23a3c170dc..d1cd86fbca 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java +++ b/ui/src/main/java/edu/wpi/grip/ui/components/PreviousNextButtons.java @@ -6,6 +6,7 @@ import org.controlsfx.control.SegmentedButton; import java.util.function.Consumer; + import javafx.scene.Node; import javafx.scene.control.ToggleButton; import javafx.scene.control.Tooltip; diff --git a/ui/src/main/java/edu/wpi/grip/ui/preview/LinesSocketPreviewView.java b/ui/src/main/java/edu/wpi/grip/ui/preview/LinesSocketPreviewView.java index cf9d8ae4d9..0d046f2ad4 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/preview/LinesSocketPreviewView.java +++ b/ui/src/main/java/edu/wpi/grip/ui/preview/LinesSocketPreviewView.java @@ -11,6 +11,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.List; + import javafx.application.Platform; import javafx.geometry.Orientation; import javafx.scene.control.CheckBox; diff --git a/ui/src/main/java/edu/wpi/grip/ui/python/PythonEditorController.java b/ui/src/main/java/edu/wpi/grip/ui/python/PythonEditorController.java new file mode 100644 index 0000000000..cdd7ac8f43 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/python/PythonEditorController.java @@ -0,0 +1,362 @@ +package edu.wpi.grip.ui.python; + +import edu.wpi.grip.core.operations.python.PythonOperationUtils; +import edu.wpi.grip.core.operations.python.PythonScriptFile; +import edu.wpi.grip.ui.annotations.ParametrizedController; + +import org.fxmisc.richtext.CodeArea; +import org.fxmisc.richtext.LineNumberFactory; +import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyleSpansBuilder; +import org.python.core.PyException; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.util.Collection; +import java.util.Collections; +import java.util.Locale; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import javafx.application.HostServices; +import javafx.fxml.FXML; +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.stage.FileChooser; +import javafx.stage.Window; +import javafx.stage.WindowEvent; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Controller for the python editor. + */ +@ParametrizedController(url = "PythonEditor.fxml") +public class PythonEditorController { + + private static final Logger logger = Logger.getLogger(PythonEditorController.class.getName()); + + private Window window; + @FXML private BorderPane root; + private final CodeArea codeArea = new CodeArea(); + private File scriptFile = null; + private Predicate operationNameTaken; + private HostServices hostServices; + + /** + * Array of python keywords. + */ + private static final String[] KEYWORDS = new String[] { + "and", "as", "assert", "break", "class", "continue", + "def", "del", "elif", "else", "except", "exec", + "finally", "for", "from", "global", "if", "import", + "in", "is", "lambda", "not", "or", "pass", "print", + "raise", "return", "try", "while", "with", "yield" + }; + + private static final String KEYWORD_PATTERN = "\\b(" + String.join("|", KEYWORDS) + ")\\b"; + private static final String PAREN_PATTERN = "\\(|\\)"; + private static final String BRACE_PATTERN = "\\{|\\}"; + private static final String BRACKET_PATTERN = "\\[|\\]"; + private static final String STRING_PATTERN = "\"([^\"\\\\]|\\\\.)*\""; + private static final String COMMENT_PATTERN = "#[^\n]*|'''(.|\\R)*?'''"; + + /** + * Regular expression pattern matching text to be styled differently. + */ + private static final Pattern PATTERN = Pattern.compile( + "(?" + KEYWORD_PATTERN + ")" + + "|(?" + PAREN_PATTERN + ")" + + "|(?" + BRACE_PATTERN + ")" + + "|(?" + BRACKET_PATTERN + ")" + + "|(?" + STRING_PATTERN + ")" + + "|(?" + COMMENT_PATTERN + ")" + ); + + @FXML + private void initialize() { + codeArea.setParagraphGraphicFactory(LineNumberFactory.get(codeArea)); + codeArea.richChanges() + .filter(ch -> !ch.getInserted().equals(ch.getRemoved())) + .subscribe(change -> codeArea.setStyleSpans(0, computeHighlighting(codeArea.getText()))); + codeArea.replaceText(0, 0, PythonScriptFile.TEMPLATE); + codeArea.setStyle("-fx-font-family: 'Consolas'; -fx-font-size: 10pt;"); + codeArea.getStylesheets() + .add(getClass().getResource("python-keywords.css").toExternalForm()); + root.setCenter(codeArea); + } + + /** + * Computes highlighting for python code. + * + * @param text the text to highlight + */ + private static StyleSpans> computeHighlighting(String text) { + Matcher matcher = PATTERN.matcher(text); + int lastKwEnd = 0; + StyleSpansBuilder> spansBuilder = new StyleSpansBuilder<>(); + while (matcher.find()) { + String styleClass = + matcher.group("KEYWORD") != null ? "keyword" : + matcher.group("PAREN") != null ? "paren" : + matcher.group("BRACE") != null ? "brace" : + matcher.group("BRACKET") != null ? "bracket" : + matcher.group("STRING") != null ? "string" : + matcher.group("COMMENT") != null ? "comment" : + null; /* never happens */ + assert styleClass != null; + spansBuilder.add(Collections.emptyList(), matcher.start() - lastKwEnd); + spansBuilder.add(Collections.singleton(styleClass), matcher.end() - matcher.start()); + lastKwEnd = matcher.end(); + } + spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd); + return spansBuilder.create(); + } + + /** + * Injects members required for this controller. + * + * @param operationNameTaken predicate testing if an operation with a given name is in the palette + * or pipeline + */ + public void injectMembers(Predicate operationNameTaken, HostServices hostServices) { + this.operationNameTaken = checkNotNull(operationNameTaken, "operationNameTaken"); + this.hostServices = checkNotNull(hostServices, "hostServices"); + } + + /** + * Gets the window displaying the editor. + */ + private Window getWindow() { + if (window == null) { + window = root.getScene().getWindow(); + } + return window; + } + + /** + * Gets the root {@code BorderPane} for the editor. + */ + public @Nonnull BorderPane getRoot() { + return root; + } + + /** + * Gets the script text in the editor. Returns null if no name is present, the name is already + * used for another operation, or if there is an error in the script. + */ + public @Nullable String getScript() { + if (checkName()) { + return null; + } + String script = codeArea.getText(); + try { + PythonScriptFile.create(script); + return script; + } catch (PyException e) { + return null; + } + } + + /** + * Extracts the name of the operation from the script. Returns null if no name is present. + */ + private @Nullable String extractName() { + final Pattern namePattern = Pattern.compile("name *= *\"(.*)\" *"); + String name = null; + String code = codeArea.getText(); + String[] lines = code.split("\n"); + for (String line : lines) { + Matcher m = namePattern.matcher(line); + if (m.matches()) { + name = m.group(1).trim(); + break; + } + } + return name; + } + + /** + * Checks if the operation name is present and not used by any other operation in the palette + * or pipeline. This will show an alert dialog notifying the user if either does not hold. + */ + private boolean checkName() { + String name = extractName(); + if (name == null || name.isEmpty()) { + Alert noName = new Alert(Alert.AlertType.ERROR); + noName.setTitle("No Name"); + noName.setHeaderText("This operation needs a name!"); + noName.showAndWait(); + } + if (operationNameTaken.test(name)) { + Alert nameInUseAlert = new Alert(Alert.AlertType.ERROR); + nameInUseAlert.setTitle("Already in Use"); + nameInUseAlert.setHeaderText("An operation already exists with the name '" + name + "'"); + nameInUseAlert.setContentText("You won't be able to save this script until you change the " + + "name to something that doesn't belong to another operation."); + nameInUseAlert.showAndWait(); + return true; + } + return false; + } + + /** + * Creates the name of the file to save the script to based on the operation name. + * Returns null if the script has an error, or if the assigned name is already in use. + */ + @SuppressWarnings("PMD") + private @Nullable String scriptFileName() { + String name = extractName(); + if (name != null) { + name = name.trim().replaceAll("[ \\t]+", "_").toLowerCase(Locale.ENGLISH); + final String regex = name + "_?([0-9]*?)\\.py"; // e.g. $name.py, $name_2.py, etc. + boolean needsNumber = Stream.of(PythonOperationUtils.DIRECTORY.list()) + .filter(n -> n.matches(regex)) + .count() > 0; + if (needsNumber) { + int currentMax = Stream.of(PythonOperationUtils.DIRECTORY.list()) + .filter(n -> n.matches(regex)) + .map(n -> n.replaceAll(regex, "$1")) + .mapToInt(n -> n.isEmpty() ? 0 : Integer.valueOf(n)) + .max() + .orElse(1); + name = name + '_' + (currentMax + 1); + } + return name.concat(".py"); + } else { + return null; + } + } + + private void showScriptErrorAlert(String message) { + Alert malformed = new Alert(Alert.AlertType.ERROR); + malformed.setTitle("Error in script"); + malformed.setHeaderText("There is an error in the python script"); + malformed.getDialogPane().setContent(new Label(message)); + malformed.showAndWait(); + } + + /** + * Tries to save the script to disk. Does not save if the script has an error. + * + * @return true if the script was saved, false otherwise + */ + @FXML + private boolean save() { + if (checkName()) { + return false; + } + if (scriptFile == null) { + String fileName = scriptFileName(); + try { + PythonScriptFile.create(codeArea.getText()); + } catch (PyException e) { + showScriptErrorAlert(e.toString()); + return false; + } + scriptFile = new File(PythonOperationUtils.DIRECTORY, fileName); + } + try { + Files.write( + scriptFile.getAbsoluteFile().toPath(), + codeArea.getText().getBytes(Charset.defaultCharset()) + ); + return true; + } catch (IOException e) { + logger.log(Level.WARNING, "Could not save to " + scriptFile, e); + Alert couldNotSave = new Alert(Alert.AlertType.ERROR); + couldNotSave.setTitle("Could not save custom operation"); + couldNotSave.setContentText("Could not save to file: " + scriptFile); + couldNotSave.showAndWait(); + return false; + } + } + + /** + * Tries to 'save-as' the script to disk. Does nothing if the script has an error. + */ + @FXML + private void saveAs() { + if (checkName()) { + // No name + return; + } + try { + PythonScriptFile.create(codeArea.getText()); + } catch (PyException e) { + // Error in script + showScriptErrorAlert(e.toString()); + return; + } + FileChooser chooser = new FileChooser(); + chooser.setInitialDirectory(PythonOperationUtils.DIRECTORY); + chooser.setTitle("Choose save file"); + chooser.setSelectedExtensionFilter( + new FileChooser.ExtensionFilter("Custom GRIP operations", "*.py")); + String fileName = scriptFileName(); + if (fileName != null) { + chooser.setInitialFileName(fileName); + } + File file = chooser.showSaveDialog(getWindow()); + if (file == null) { + // Chooser was closed; no file selected + return; + } + scriptFile = file; + save(); + } + + @FXML + private void saveAndExit() { + if (save() && getScript() != null) { + // Don't exit if there's a problem with the script + exit(); + } + } + + @FXML + private void exit() { + getWindow().fireEvent(new WindowEvent(getWindow(), WindowEvent.WINDOW_CLOSE_REQUEST)); + } + + @FXML + private void openFile() { + FileChooser chooser = new FileChooser(); + chooser.setInitialDirectory(PythonOperationUtils.DIRECTORY); + chooser.setTitle("Choose an operation to edit"); + chooser.setSelectedExtensionFilter( + new FileChooser.ExtensionFilter("Custom GRIP operations", "*.py")); + File file = chooser.showOpenDialog(getWindow()); + if (file == null) { + // Chooser was closed; no file selected + return; + } + try { + byte[] bytes = Files.readAllBytes(file.getAbsoluteFile().toPath()); + String code = new String(bytes, Charset.defaultCharset()); + codeArea.replaceText(code); + } catch (IOException e) { + logger.log(Level.WARNING, "Could not read file " + file, e); + Alert couldNotRead = new Alert(Alert.AlertType.ERROR); + couldNotRead.setTitle("Could not read custom operation"); + couldNotRead.setContentText("Could not read from file: " + file); + couldNotRead.showAndWait(); + } + } + + @FXML + private void openWiki() { + hostServices.showDocument("https://github.com/WPIRoboticsProjects/GRIP/wiki"); + } + +} diff --git a/ui/src/main/resources/edu/wpi/grip/ui/CustomOperationsList.fxml b/ui/src/main/resources/edu/wpi/grip/ui/CustomOperationsList.fxml new file mode 100644 index 0000000000..c6354c9209 --- /dev/null +++ b/ui/src/main/resources/edu/wpi/grip/ui/CustomOperationsList.fxml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/resources/edu/wpi/grip/ui/OperationList.fxml b/ui/src/main/resources/edu/wpi/grip/ui/OperationList.fxml index 73d0806700..b84f4232f7 100644 --- a/ui/src/main/resources/edu/wpi/grip/ui/OperationList.fxml +++ b/ui/src/main/resources/edu/wpi/grip/ui/OperationList.fxml @@ -3,12 +3,10 @@ - + - - - + + + - + diff --git a/ui/src/main/resources/edu/wpi/grip/ui/Palette.fxml b/ui/src/main/resources/edu/wpi/grip/ui/Palette.fxml index bf9b86fb1d..3bc6135443 100644 --- a/ui/src/main/resources/edu/wpi/grip/ui/Palette.fxml +++ b/ui/src/main/resources/edu/wpi/grip/ui/Palette.fxml @@ -1,21 +1,25 @@ + - - - diff --git a/ui/src/main/resources/edu/wpi/grip/ui/python/PythonEditor.fxml b/ui/src/main/resources/edu/wpi/grip/ui/python/PythonEditor.fxml new file mode 100644 index 0000000000..874b84b67d --- /dev/null +++ b/ui/src/main/resources/edu/wpi/grip/ui/python/PythonEditor.fxml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/resources/edu/wpi/grip/ui/python/python-keywords.css b/ui/src/main/resources/edu/wpi/grip/ui/python/python-keywords.css new file mode 100644 index 0000000000..a87ad9b92a --- /dev/null +++ b/ui/src/main/resources/edu/wpi/grip/ui/python/python-keywords.css @@ -0,0 +1,31 @@ +.keyword { + -fx-fill: purple; + -fx-font-weight: bold; +} + +.paren { + -fx-fill: firebrick; + -fx-font-weight: bold; +} + +.bracket { + -fx-fill: darkgreen; + -fx-font-weight: bold; +} + +.brace { + -fx-fill: teal; + -fx-font-weight: bold; +} + +.string { + -fx-fill: #669900; +} + +.comment { + -fx-fill: darkgray; +} + +.paragraph-box:has-caret { + -fx-background-color: #f2f9fc; +} diff --git a/ui/src/test/java/edu/wpi/grip/ui/PaletteTest.java b/ui/src/test/java/edu/wpi/grip/ui/PaletteTest.java index 0d16758a77..0537437cf9 100644 --- a/ui/src/test/java/edu/wpi/grip/ui/PaletteTest.java +++ b/ui/src/test/java/edu/wpi/grip/ui/PaletteTest.java @@ -26,6 +26,7 @@ import java.io.IOException; import java.util.Collections; import java.util.List; + import javafx.fxml.FXMLLoader; import javafx.scene.Scene; import javafx.stage.Stage; @@ -65,10 +66,10 @@ public void testPalette() { operation))); // Record when a a StepAddedEvent happens - Step[] step = new Step[]{null}; + Step[] step = new Step[] {null}; eventBus.register(new Object() { @SuppressFBWarnings(value = "UMAC_UNCALLABLE_METHOD_OF_ANONYMOUS_CLASS", - justification = "This method is called by Guava's EventBus") + justification = "This method is called by Guava's EventBus") @Subscribe public void onStepAdded(StepAddedEvent event) { step[0] = event.getStep();