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. ![Scratch for Java Studio with the code editor and the visual stage designer side by side](docs/screenshot.png) 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);