diff --git a/README.md b/README.md
index 28d44a9..8f53821 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Scratch for Java Studio is a desktop IDE for students moving from Scratch to
Java. It builds projects with [Scratch for Java](https://github.com/openpatch/scratch-for-java)
and keeps them as ordinary Java source files that also work in BlueJ and VS Code.
-**Status: 0.1.0-alpha.11.** The IDE and its
+**Status: 0.1.0-alpha.12.** The IDE and its
project format may change while we test it with students and teachers.

diff --git a/app/pom.xml b/app/pom.xml
index 11cc3f6..6f95060 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -7,7 +7,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
app
diff --git a/core/pom.xml b/core/pom.xml
index 9d9cd8e..f017866 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -7,7 +7,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
core
diff --git a/export/pom.xml b/export/pom.xml
index 8cbe789..aea860a 100644
--- a/export/pom.xml
+++ b/export/pom.xml
@@ -7,7 +7,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
export
diff --git a/pom.xml b/pom.xml
index cf09b41..9faf8d0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
pom
Scratch for Java Studio
diff --git a/runner/pom.xml b/runner/pom.xml
index 81189d3..3405d4b 100644
--- a/runner/pom.xml
+++ b/runner/pom.xml
@@ -7,7 +7,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
runner
diff --git a/sound/pom.xml b/sound/pom.xml
index 861d009..426a623 100644
--- a/sound/pom.xml
+++ b/sound/pom.xml
@@ -7,7 +7,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
sound
diff --git a/ui/pom.xml b/ui/pom.xml
index 12fb056..6bab222 100644
--- a/ui/pom.xml
+++ b/ui/pom.xml
@@ -7,7 +7,7 @@
org.openpatch.scratch4j
scratch4j-ide
- 0.1.0-alpha.11
+ 0.1.0-alpha.12
ui
diff --git a/ui/src/main/java/org/openpatch/scratch4j/ui/CodeEditor.java b/ui/src/main/java/org/openpatch/scratch4j/ui/CodeEditor.java
index 702709d..f397044 100644
--- a/ui/src/main/java/org/openpatch/scratch4j/ui/CodeEditor.java
+++ b/ui/src/main/java/org/openpatch/scratch4j/ui/CodeEditor.java
@@ -94,6 +94,9 @@ org.openpatch.scratch4j.core.compile.Completions.Result complete(Path file, Stri
});
private static final String INDENT = " ";
+ /** A method snippet from the block palette (an event block): its name. */
+ private static final java.util.regex.Pattern EVENT_METHOD = java.util.regex.Pattern.compile(
+ "^(?:public |protected |private )?void (\\w+)\\([^)]*\\) \\{\n");
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*$");
private static final Pattern WORD = Pattern.compile("\\b[A-Za-z_][A-Za-z0-9_]{2,}\\b");
private static final Pattern IMAGE_STRING = Pattern.compile(
@@ -536,6 +539,27 @@ void insertBlock(String snippet) {
}
String body = snippet.substring(0, snippet.length() - 1);
boolean multiLine = body.contains("\n");
+ var method = EVENT_METHOD.matcher(body);
+ if (method.find()) {
+ // an event block is a method: it goes into the class body, not into a method
+ int current = area.getCurrentParagraph();
+ int lineEnd = area.getAbsolutePosition(current, area.getParagraph(current).length());
+ var place = MemberPlacement.place(area.getText(), lineEnd, method.group(1));
+ if (place != null && place.existing()) {
+ // the class already reacts to this event: go there instead of a second copy
+ area.moveTo(place.offset());
+ int open = area.getCurrentParagraph();
+ if (open + 1 < area.getParagraphs().size()
+ && !area.getParagraph(open + 1).getText().strip().startsWith("}")) {
+ open++; // the method's first line
+ }
+ area.moveTo(open, area.getParagraph(open).length());
+ area.requestFollowCaret();
+ area.requestFocus();
+ return;
+ }
+ if (place != null) area.moveTo(place.offset());
+ }
int paragraph = area.getCurrentParagraph();
String line = area.getParagraph(paragraph).getText();
boolean blank = line.isBlank();
@@ -545,8 +569,10 @@ void insertBlock(String snippet) {
boolean prevIsBlank = blank
? paragraph == 0 || area.getParagraph(paragraph - 1).getText().isBlank()
: false;
+ // a closing brace below needs no blank line before it either
boolean nextIsBlank = paragraph + 1 >= area.getParagraphs().size()
- || area.getParagraph(paragraph + 1).getText().isBlank();
+ || area.getParagraph(paragraph + 1).getText().isBlank()
+ || area.getParagraph(paragraph + 1).getText().strip().startsWith("}");
int lineStart = area.getAbsolutePosition(paragraph, 0);
// multi-line snippets (method overrides) keep a blank line on both sides
String before = blank ? (multiLine && !prevIsBlank ? "\n" : "")
diff --git a/ui/src/main/java/org/openpatch/scratch4j/ui/ImageEditorView.java b/ui/src/main/java/org/openpatch/scratch4j/ui/ImageEditorView.java
index 1cbc719..5bd9c00 100644
--- a/ui/src/main/java/org/openpatch/scratch4j/ui/ImageEditorView.java
+++ b/ui/src/main/java/org/openpatch/scratch4j/ui/ImageEditorView.java
@@ -197,6 +197,18 @@ private record Snapshot(List layers, List visible, int a
HBox bottom = new HBox(8, status, spacer, credits);
bottom.getStyleClass().add("editor-status");
setBottom(bottom);
+ // Ctrl+Z / Ctrl+Y (Cmd on macOS) anywhere in the editor; text fields keep their own
+ addEventHandler(javafx.scene.input.KeyEvent.KEY_PRESSED, e -> {
+ if (!e.isShortcutDown()
+ || e.getTarget() instanceof javafx.scene.control.TextInputControl) return;
+ if (e.getCode() == javafx.scene.input.KeyCode.Z) {
+ if (e.isShiftDown()) redo(); else undo();
+ e.consume();
+ } else if (e.getCode() == javafx.scene.input.KeyCode.Y) {
+ redo();
+ e.consume();
+ }
+ });
updateApplyButton();
updateStatus();
redrawOverlay();
diff --git a/ui/src/main/java/org/openpatch/scratch4j/ui/MemberPlacement.java b/ui/src/main/java/org/openpatch/scratch4j/ui/MemberPlacement.java
new file mode 100644
index 0000000..d657100
--- /dev/null
+++ b/ui/src/main/java/org/openpatch/scratch4j/ui/MemberPlacement.java
@@ -0,0 +1,147 @@
+package org.openpatch.scratch4j.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Where a method from the block palette (an event block, {@code when ...})
+ * belongs: in a class body, never inside another method. Braces are counted
+ * past comments, strings and char literals; a brace opens a class when the
+ * code before it names one ({@code class}, {@code interface}, {@code enum},
+ * {@code record}); anonymous classes and lambdas count as code.
+ */
+final class MemberPlacement {
+
+ private MemberPlacement() { }
+
+ /**
+ * Where to put the method: after the line of {@code offset}, or, when
+ * {@code existing}, the caret inside the method of that name the class
+ * already has.
+ */
+ record Place(int offset, boolean existing) { }
+
+ private static final Pattern TYPE = Pattern.compile(
+ "(^|[^\\w@])(class|interface|enum|record)\\s+\\w+");
+
+ private static final class Brace {
+ final int open;
+ final boolean type;
+ final String header;
+ final Brace parent;
+ int close = -1;
+
+ Brace(int open, boolean type, String header, Brace parent) {
+ this.open = open;
+ this.type = type;
+ this.header = header;
+ this.parent = parent;
+ }
+ }
+
+ /**
+ * The place for method {@code name} when the block would be inserted after
+ * the line ending at {@code lineEnd}; null when that line already is in a
+ * class body (or the file has no class to put it in).
+ */
+ static Place place(String text, int lineEnd, String name) {
+ List braces = new ArrayList<>();
+ List atLine = null;
+ List stack = new ArrayList<>();
+ StringBuilder header = new StringBuilder();
+ int i = 0;
+ while (i <= text.length()) {
+ if (i >= lineEnd && atLine == null) {
+ atLine = List.copyOf(stack);
+ }
+ if (i == text.length()) break;
+ char c = text.charAt(i);
+ char next = i + 1 < text.length() ? text.charAt(i + 1) : 0;
+ if (c == '/' && next == '/') {
+ int end = text.indexOf('\n', i);
+ i = end < 0 ? text.length() : end;
+ continue;
+ }
+ if (c == '/' && next == '*') {
+ int end = text.indexOf("*/", i + 2);
+ i = end < 0 ? text.length() : end + 2;
+ header.append(' ');
+ continue;
+ }
+ if (c == '"' || c == '\'') {
+ i = skipLiteral(text, i);
+ header.append(" \"\" ");
+ continue;
+ }
+ if (c == '{') {
+ Brace parent = stack.isEmpty() ? null : stack.get(stack.size() - 1);
+ String head = header.toString();
+ Brace brace = new Brace(i, TYPE.matcher(head).find(), head, parent);
+ braces.add(brace);
+ stack.add(brace);
+ header.setLength(0);
+ } else if (c == '}') {
+ if (!stack.isEmpty()) stack.remove(stack.size() - 1).close = i;
+ header.setLength(0);
+ } else if (c == ';') {
+ header.setLength(0);
+ } else {
+ header.append(c);
+ }
+ i++;
+ }
+ if (atLine == null) atLine = List.copyOf(stack);
+
+ // the innermost class around the line, else the file's first class
+ int typeIndex = -1;
+ for (int k = atLine.size() - 1; k >= 0; k--) {
+ if (atLine.get(k).type) {
+ typeIndex = k;
+ break;
+ }
+ }
+ Brace type = typeIndex >= 0 ? atLine.get(typeIndex)
+ : braces.stream().filter(b -> b.type && b.parent == null).findFirst().orElse(null);
+ if (type == null) return null;
+
+ Pattern same = Pattern.compile("\\bvoid\\s+" + Pattern.quote(name) + "\\s*\\(");
+ for (Brace member : braces) {
+ if (member.parent == type && !member.type && same.matcher(member.header).find()) {
+ return new Place(member.open + 1, true);
+ }
+ }
+ if (typeIndex >= 0 && typeIndex == atLine.size() - 1) {
+ return null; // already in the class body
+ }
+ if (typeIndex >= 0) {
+ // inside a method (or a block of the class): after the member's closing brace
+ Brace member = atLine.get(typeIndex + 1);
+ return member.close < 0 ? null : new Place(member.close, false);
+ }
+ // outside the class: before its closing brace
+ if (type.close < 0) return null;
+ int lineBefore = text.lastIndexOf('\n', type.close - 1);
+ return lineBefore <= type.open ? null : new Place(lineBefore, false);
+ }
+
+ /** The index after the string, text block or char literal starting at {@code start}. */
+ private static int skipLiteral(String text, int start) {
+ char quote = text.charAt(start);
+ if (quote == '"' && text.startsWith("\"\"\"", start)) {
+ int end = text.indexOf("\"\"\"", start + 3);
+ return end < 0 ? text.length() : end + 3;
+ }
+ int i = start + 1;
+ while (i < text.length()) {
+ char c = text.charAt(i);
+ if (c == '\\') {
+ i += 2;
+ continue;
+ }
+ if (c == quote || c == '\n') return i + 1;
+ i++;
+ }
+ return text.length();
+ }
+}
diff --git a/ui/src/main/java/org/openpatch/scratch4j/ui/SoundEditorView.java b/ui/src/main/java/org/openpatch/scratch4j/ui/SoundEditorView.java
index f3bc60e..ef836b6 100644
--- a/ui/src/main/java/org/openpatch/scratch4j/ui/SoundEditorView.java
+++ b/ui/src/main/java/org/openpatch/scratch4j/ui/SoundEditorView.java
@@ -64,7 +64,22 @@ final class SoundEditorView extends BorderPane {
this.clip = SoundIO.decode(file);
getStyleClass().add("sound-editor");
+ // Ctrl+Z / Ctrl+Y (Cmd on macOS) anywhere in the editor; text fields keep their own
+ addEventHandler(javafx.scene.input.KeyEvent.KEY_PRESSED, e -> {
+ if (!e.isShortcutDown()
+ || e.getTarget() instanceof javafx.scene.control.TextInputControl) return;
+ if (e.getCode() == javafx.scene.input.KeyCode.Z) {
+ if (e.isShiftDown()) redo(); else undo();
+ e.consume();
+ } else if (e.getCode() == javafx.scene.input.KeyCode.Y) {
+ redo();
+ e.consume();
+ }
+ });
+
+ wave.setFocusTraversable(true);
wave.setOnMousePressed(e -> {
+ wave.requestFocus(); // the shortcuts need the focus in the editor
dragStartX = e.getX();
selection = null;
redraw();
diff --git a/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_de.properties b/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_de.properties
index d85915d..b38e5a2 100644
--- a/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_de.properties
+++ b/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_de.properties
@@ -87,8 +87,8 @@ imageeditor.nineslice.help=Die vier blauen Linien verschieben, um die dehnbare M
imageeditor.nineslice.saved=Neun-Scheiben-R\u00e4nder in {0} geschrieben
imageeditor.nineslice.no.uisprite=Vor dem Speichern eine UISprite-Klasse erstellen.
imageeditor.save=Speichern
-imageeditor.undo=R\u00fcckg\u00e4ngig
-imageeditor.redo=Wiederholen
+imageeditor.undo=R\u00fcckg\u00e4ngig (Strg+Z)
+imageeditor.redo=Wiederholen (Strg+Y)
imageeditor.zoom=Zoom
imageeditor.grid=Raster
imageeditor.grid.tooltip=Pixelgrenzen ab 600 % Vergr\u00f6\u00dferung anzeigen
@@ -166,8 +166,8 @@ soundeditor.normalize=Normalisieren
soundeditor.reverse=Umkehren
soundeditor.faster=Schneller
soundeditor.slower=Langsamer
-soundeditor.undo=R\u00fcckg\u00e4ngig
-soundeditor.redo=Wiederholen
+soundeditor.undo=R\u00fcckg\u00e4ngig (Strg+Z)
+soundeditor.redo=Wiederholen (Strg+Y)
soundeditor.effect.all=Keine Auswahl - Effekt wirkt auf den ganzen Klang
soundeditor.mp3.saved=Als WAV gespeichert (die Bibliothek kann kein MP3)
menu.export=Exportieren
diff --git a/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_en.properties b/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_en.properties
index 7c0ab21..7e6898e 100644
--- a/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_en.properties
+++ b/ui/src/main/resources/org/openpatch/scratch4j/ui/messages_en.properties
@@ -87,8 +87,8 @@ imageeditor.nineslice.help=Drag the four blue guides to set the stretchable cent
imageeditor.nineslice.saved=Nine-slice margins written to {0}
imageeditor.nineslice.no.uisprite=Create a UISprite class before saving nine-slice guides.
imageeditor.save=Save
-imageeditor.undo=Undo
-imageeditor.redo=Redo
+imageeditor.undo=Undo (Ctrl+Z)
+imageeditor.redo=Redo (Ctrl+Y)
imageeditor.zoom=Zoom
imageeditor.grid=Grid
imageeditor.grid.tooltip=Show pixel boundaries at 600% zoom or higher
@@ -166,8 +166,8 @@ soundeditor.normalize=Normalize
soundeditor.reverse=Reverse
soundeditor.faster=Faster
soundeditor.slower=Slower
-soundeditor.undo=Undo
-soundeditor.redo=Redo
+soundeditor.undo=Undo (Ctrl+Z)
+soundeditor.redo=Redo (Ctrl+Y)
soundeditor.effect.all=No selection - effect applies to the whole sound
soundeditor.mp3.saved=Saved as WAV (the library cannot play MP3)
menu.export=Export
diff --git a/ui/src/test/java/org/openpatch/scratch4j/ui/EditorLinesIT.java b/ui/src/test/java/org/openpatch/scratch4j/ui/EditorLinesIT.java
index bf946af..ad7b86b 100644
--- a/ui/src/test/java/org/openpatch/scratch4j/ui/EditorLinesIT.java
+++ b/ui/src/test/java/org/openpatch/scratch4j/ui/EditorLinesIT.java
@@ -68,6 +68,21 @@ void lineOperationsAndRefactoringMenu() throws Exception {
.forEach(i -> i.fire());
assertThat(renamed).containsExactly(4);
assertThat(usages).containsExactly(4);
+
+ // an event block dropped into a method lands after it, in the class body
+ area.replaceText("public class Cat extends Sprite {\n public void run() {\n"
+ + " this.move(1);\n }\n}\n");
+ area.moveTo(2, 4);
+ code.insertBlock("public void whenClicked() {\n \n}\n");
+ assertThat(area.getText()).isEqualTo("public class Cat extends Sprite {\n"
+ + " public void run() {\n this.move(1);\n }\n\n"
+ + " public void whenClicked() {\n \n }\n}\n");
+ // a second one takes the caret into the method that is already there
+ area.moveTo(2, 4);
+ String before = area.getText();
+ code.insertBlock("public void whenClicked() {\n \n}\n");
+ assertThat(area.getText()).isEqualTo(before);
+ assertThat(area.getCurrentParagraph()).isEqualTo(6);
});
}
}
diff --git a/ui/src/test/java/org/openpatch/scratch4j/ui/MemberPlacementTest.java b/ui/src/test/java/org/openpatch/scratch4j/ui/MemberPlacementTest.java
new file mode 100644
index 0000000..9ba7fcf
--- /dev/null
+++ b/ui/src/test/java/org/openpatch/scratch4j/ui/MemberPlacementTest.java
@@ -0,0 +1,85 @@
+package org.openpatch.scratch4j.ui;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class MemberPlacementTest {
+
+ private static final String STAGE = """
+ public class MyStage extends Stage {
+ public MyStage() {
+ this.add(new Cat());
+ }
+
+ public void run() {
+ // a { in a comment and "}" in a string are no braces
+ String s = "}";
+ if (s.isEmpty()) {
+ this.wait(1);
+ }
+ }
+ }
+ """;
+
+ private static int lineEnd(String text, String line) {
+ int start = text.indexOf(line);
+ return text.indexOf('\n', start);
+ }
+
+ @Test
+ void aMethodBodyMovesTheBlockAfterTheMethod() {
+ int inRun = lineEnd(STAGE, "this.wait(1);");
+ var place = MemberPlacement.place(STAGE, inRun, "whenKeyPressed");
+ assertThat(place).isNotNull();
+ assertThat(place.existing()).isFalse();
+ // the closing brace of run(), not of the if or the class
+ assertThat(place.offset()).isEqualTo(STAGE.lastIndexOf(" }\n}") + 2);
+ }
+
+ @Test
+ void theLineOpeningAMethodCountsAsInsideIt() {
+ var place = MemberPlacement.place(STAGE, lineEnd(STAGE, "public MyStage() {"),
+ "whenKeyPressed");
+ assertThat(place.offset()).isEqualTo(STAGE.indexOf(" }\n\n") + 2);
+ }
+
+ @Test
+ void theClassBodyStaysAsItIs() {
+ assertThat(MemberPlacement.place(STAGE, lineEnd(STAGE, " }\n\n") + 1, "whenKeyPressed"))
+ .isNull();
+ }
+
+ @Test
+ void outsideTheClassTheBlockGoesToItsEnd() {
+ String text = "import org.openpatch.scratch.*;\n\n" + STAGE;
+ var place = MemberPlacement.place(text, 0, "whenKeyPressed");
+ // the end of the line before the class's closing brace
+ assertThat(place.offset()).isEqualTo(text.lastIndexOf("\n}"));
+ }
+
+ @Test
+ void anExistingEventMethodIsFoundInsteadOfASecondOne() {
+ String text = STAGE.replace(" public void run() {",
+ " public void whenKeyPressed(KeyCode keyCode) {\n }\n\n public void run() {");
+ var place = MemberPlacement.place(text, lineEnd(text, "this.wait(1);"), "whenKeyPressed");
+ assertThat(place.existing()).isTrue();
+ assertThat(place.offset()).isEqualTo(text.indexOf("keyCode) {") + "keyCode) {".length());
+ }
+
+ @Test
+ void anAnonymousClassIsCodeOfTheMethod() {
+ String text = """
+ public class Cat extends Sprite {
+ public void run() {
+ Runnable r = new Runnable() {
+ public void run() { }
+ };
+ }
+ }
+ """;
+ var place = MemberPlacement.place(text, lineEnd(text, "public void run() { }"),
+ "whenClicked");
+ assertThat(place.offset()).isEqualTo(text.indexOf(" }\n}") + 2);
+ }
+}
diff --git a/ui/src/test/java/org/openpatch/scratch4j/ui/UiSmokeIT.java b/ui/src/test/java/org/openpatch/scratch4j/ui/UiSmokeIT.java
index 459b1cc..aa233e5 100644
--- a/ui/src/test/java/org/openpatch/scratch4j/ui/UiSmokeIT.java
+++ b/ui/src/test/java/org/openpatch/scratch4j/ui/UiSmokeIT.java
@@ -227,6 +227,16 @@ public Button() {
assertThat(pixels.getArgb(6, 6)).as("scaled to 2x2").isEqualTo(0xffff0000);
paint.cropToSelection();
assertThat(paint.image().getWidth()).isEqualTo(2);
+ // Ctrl+Z / Ctrl+Y (Cmd on macOS) undo and redo the crop
+ boolean mac = System.getProperty("os.name").toLowerCase().contains("mac");
+ javafx.event.Event.fireEvent(paint, new javafx.scene.input.KeyEvent(
+ javafx.scene.input.KeyEvent.KEY_PRESSED, "", "", javafx.scene.input.KeyCode.Z,
+ false, !mac, false, mac));
+ assertThat(paint.image().getWidth()).isEqualTo(8);
+ javafx.event.Event.fireEvent(paint, new javafx.scene.input.KeyEvent(
+ javafx.scene.input.KeyEvent.KEY_PRESSED, "", "", javafx.scene.input.KeyCode.Y,
+ false, !mac, false, mac));
+ assertThat(paint.image().getWidth()).isEqualTo(2);
// layers: draw on a new layer, the flattened picture has both, merging keeps it
paint.addLayer();
assertThat(paint.layerCount()).isEqualTo(2);