From 91640c3b7bac26084416b10272d987cfa62235f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:41:36 +0000 Subject: [PATCH 01/59] Don't reset the editor scroll position on every edit Editing reset the first visible row to the top, while the scrollbar stayed in place. Only reset it when a new script is loaded, and clamp the scroll position when the text shrinks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 5359a1b0f..9f9deb125 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -155,6 +155,7 @@ public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { } public void setValue(String value) { + this.firstRow = 0; this.setValuePassive(value); textFieldHelper.setCursorToStart(); @@ -165,7 +166,6 @@ public void setValue(String value) { } public void setValuePassive(String value) { - this.firstRow = 0; this.value = value; this.clearDisplayCache(); @@ -552,26 +552,40 @@ private DisplayCache rebuildDisplayCache() { int cursorPos = this.textFieldHelper.getCursorPos(); int selectionPos = this.textFieldHelper.getSelectionPos(); IntList linePositionsOld = new IntArrayList(); - List linesAll = Lists.newArrayList(); - MutableInt mutableint = new MutableInt(); + List> linesRaw = Lists.newArrayList(); + IntList lineNumbers = new IntArrayList(); + List linesHasCursor = Lists.newArrayList(); MutableBoolean mutableboolean = new MutableBoolean(); StringSplitter stringsplitter = this.font.getSplitter(); MutableInt lineNumber = new MutableInt(); stringsplitter.splitLines(s, this.getWidth() - getLinesXOffset() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { - int k3 = mutableint.getAndIncrement(); String stringPart = s.substring(startPos, endPos); boolean hasNewLine = stringPart.endsWith("\n"); mutableboolean.setValue(hasNewLine); - String s3 = StringUtils.stripEnd(stringPart, " \n"); - int l3 = (k3 - this.firstRow) * 9; // Offset firstRow! - Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, l3)); linePositionsOld.add(startPos); - linesAll.add(new LineInfo(this.markupLine(style, s3), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumber.getValue(), cursorPos >= startPos && cursorPos < (hasNewLine ? endPos : s.indexOf("\n", endPos)))); + linesRaw.add(Pair.of(StringUtils.stripEnd(stringPart, " \n"), style)); + lineNumbers.add(lineNumber.intValue()); + linesHasCursor.add(cursorPos >= startPos && cursorPos < (hasNewLine ? endPos : s.indexOf("\n", endPos))); if (hasNewLine) { lineNumber.increment(); } }); + // Keep scroll position within bounds, as the text may have shrunk + if (this.scrollBar != null) { + int maxFirstRow = Math.max(0, linesRaw.size() - this.scrollBar.getVisibleRows()); + if (this.firstRow > maxFirstRow) { + this.firstRow = maxFirstRow; + this.scrollBar.setFirstRow(this.firstRow, false); + } + } + + List linesAll = Lists.newArrayList(); + for (int k3 = 0; k3 < linesRaw.size(); k3++) { + Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, (k3 - this.firstRow) * 9)); // Offset firstRow! + linesAll.add(new LineInfo(this.markupLine(linesRaw.get(k3).getRight(), linesRaw.get(k3).getLeft()), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumbers.getInt(k3), linesHasCursor.get(k3))); + } + // --- Changed --- // Slice lines based on scroll position int linesTotal = linesAll.size(); From 776430ed79c23b5644bf1bf19f56b92902204738 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:42:07 +0000 Subject: [PATCH 02/59] Keep the cursor visible in the editor Only up and down arrow keys scrolled the editor, so typing, pressing enter, home/end and pasting could move the cursor out of view. Scroll to the cursor after every handled key or typed character, and allow the cursor to be placed on the empty line after a trailing newline. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 9f9deb125..fdeaac5fd 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -312,6 +312,7 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { if (flag) { this.clearDisplayCache(); this.setFocused(true); + this.ensureCursorVisible(); return true; } else { this.setFocused(false); @@ -328,6 +329,7 @@ public boolean charTyped(char typedChar, int keyCode) { this.textFieldHelper.insertText(Character.toString(typedChar)); this.clearDisplayCache(); this.setFocused(true); + this.ensureCursorVisible(); return true; } else { this.setFocused(false); @@ -404,16 +406,19 @@ private void changeLine(int p_98098_) { DisplayCache displayCache = this.getDisplayCache(); int j = displayCache.changeLine(cursorPos, p_98098_); this.textFieldHelper.setCursorPos(j, Screen.hasShiftDown()); + } - // Modify scroll position when cursor goes out of screen + /** + * Scroll the viewport so that the cursor is visible. + */ + public void ensureCursorVisible() { if (this.scrollBar != null) { - int cursorLine = findLineFromPos(Arrays.stream(displayCache.lineStarts).toArray(), cursorPos); - if (cursorLine < this.firstRow + 2) { - // Cursor is above viewport - this.setFirstRow(cursorLine - 2, true); - } else if (cursorLine > this.firstRow + this.scrollBar.getVisibleRows() - 3) { - // Cursor is below viewport - this.setFirstRow(cursorLine - (this.scrollBar.getVisibleRows() - 3), true); + int cursorLine = findLineFromPos(this.getDisplayCache().lineStarts, this.textFieldHelper.getCursorPos()); + int visibleRows = this.scrollBar.getVisibleRows(); + if (cursorLine < this.firstRow) { + this.setFirstRow(cursorLine, true); + } else if (cursorLine >= this.firstRow + visibleRows) { + this.setFirstRow(cursorLine - visibleRows + 1, true); } } } @@ -570,6 +575,13 @@ private DisplayCache rebuildDisplayCache() { lineNumber.increment(); } }); + // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there + if (mutableboolean.isTrue()) { + linePositionsOld.add(s.length()); + linesRaw.add(Pair.of("", Style.EMPTY)); + lineNumbers.add(lineNumber.intValue()); + linesHasCursor.add(cursorPos == s.length()); + } // Keep scroll position within bounds, as the text may have shrunk if (this.scrollBar != null) { @@ -597,14 +609,9 @@ private DisplayCache rebuildDisplayCache() { int[] lineStartsArr = lineStarts.toIntArray(); boolean flag = cursorPos == s.length(); - Pos2i cursor; - if (flag && mutableboolean.isTrue()) { - cursor = new Pos2i(0, lines.size() * 9); - } else { - int cursorLine = findLineFromPos(lineStartsArr, cursorPos); - int cursorX = this.font.width(s.substring(lineStartsArr[cursorLine], cursorPos)); - cursor = new Pos2i(cursorX, (cursorLine - this.firstRow) * 9); // We have to offset the firstRow here! - } + int cursorLine = findLineFromPos(lineStartsArr, cursorPos); + int cursorX = this.font.width(s.substring(lineStartsArr[cursorLine], cursorPos)); + Pos2i cursor = new Pos2i(cursorX, (cursorLine - this.firstRow) * 9); // We have to offset the firstRow here! // Hide cursor if out of view if (cursor.y < 0 || cursor.y > getHeight()) { cursor = null; From 36b7e7a1b6c740d948fffdbaf8f1fea27cac1d5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:42:18 +0000 Subject: [PATCH 03/59] Don't lose editor focus on unhandled keys Pressing a key the editor doesn't handle, such as tab or a function key, unfocused it, so a following 'e' closed the terminal. Consume all keys except escape while focused. Typing only worked before because of this unfocusing: once the widget stays focused, EditBox.charTyped consumes every character into its own unused value. Handle characters without delegating to EditBox. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .gitignore | 4 ++++ .../client/gui/component/input/WidgetTextArea.java | 13 +++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index ed5313ce9..e6ff7e5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ changelog.txt # Ignore Minecraft runtime logs /logs + +# ClientDevBridge session state (golden images are intentionally kept) +.clientdevbridge/* +!.clientdevbridge/golden/ diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index fdeaac5fd..2865d8f49 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,6 +39,7 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; import java.util.Arrays; @@ -314,25 +315,25 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { this.setFocused(true); this.ensureCursorVisible(); return true; - } else { + } else if (typedChar == GLFW.GLFW_KEY_ESCAPE) { this.setFocused(false); return false; + } else { + // Consume all other keys, so they don't trigger screen actions (such as closing the screen) while typing + return true; } } @Override public boolean charTyped(char typedChar, int keyCode) { - if (super.charTyped(typedChar, keyCode)) { - this.setFocused(true); - return true; - } else if (StringUtil.isAllowedChatCharacter(typedChar)) { + // Don't delegate to EditBox, as that would consume the character into its own (unused) value + if (StringUtil.isAllowedChatCharacter(typedChar)) { this.textFieldHelper.insertText(Character.toString(typedChar)); this.clearDisplayCache(); this.setFocused(true); this.ensureCursorVisible(); return true; } else { - this.setFocused(false); return false; } } From 89d189ea34c4f9ef440726bfcff6be54b24709cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:42:45 +0000 Subject: [PATCH 04/59] Keep trailing newlines when syncing scripts String.split dropped trailing empty strings, so blank lines at the end of a script were lost when sending it between client and server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- ...TerminalScriptingModifiedScriptPacket.java | 2 +- ...inalScriptingModifiedScriptPacketTest.java | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java index 631133abf..ae34d68f2 100644 --- a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java +++ b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java @@ -42,7 +42,7 @@ public TerminalScriptingModifiedScriptPacket(int disk, Path path, String script) super(ID); this.disk = disk; this.path = path.toString(); - this.script = Arrays.stream(script.split("\n")).toList(); + this.script = Arrays.stream(script.split("\n", -1)).toList(); } public String getScript() { diff --git a/src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java b/src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java new file mode 100644 index 000000000..0f78a812e --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java @@ -0,0 +1,41 @@ +package org.cyclops.integratedscripting.network.packet; + +import org.junit.Test; + +import java.nio.file.Path; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +public class TerminalScriptingModifiedScriptPacketTest { + + private static String roundTrip(String script) { + return new TerminalScriptingModifiedScriptPacket(0, Path.of("script0.js"), script).getScript(); + } + + @Test + public void testSingleLine() { + assertThat(roundTrip("const a = 1;"), equalTo("const a = 1;")); + } + + @Test + public void testEmpty() { + assertThat(roundTrip(""), equalTo("")); + } + + @Test + public void testTrailingNewlines() { + assertThat(roundTrip("const a = 1;\n\n"), equalTo("const a = 1;\n\n")); + } + + @Test + public void testOnlyNewlines() { + assertThat(roundTrip("\n\n\n"), equalTo("\n\n\n")); + } + + @Test + public void testLeadingAndInnerNewlines() { + assertThat(roundTrip("\na\n\nb"), equalTo("\na\n\nb")); + } + +} From bb7247de6072d214319f9cf18e52dcad474dbbab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:42:51 +0000 Subject: [PATCH 05/59] Start editor line numbers at 1 Script error messages count lines from 1, so a 0-based gutter made them off by one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/component/input/WidgetTextArea.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 2865d8f49..b5cd2399a 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -462,7 +462,7 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float RenderHelpers.drawScaledString( guiGraphics, font, - String.valueOf(line.lineNumber), + String.valueOf(line.lineNumber + 1), // Line numbers start at 1, like in error messages this.getX(), line.y - offsetY + 2, 0.5f, From 136e9f160a5ed181a18184a215763eddf118fe23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:43:00 +0000 Subject: [PATCH 06/59] Don't wrap editor text below the scrollbar The wrap width didn't account for the scrollbar, so long lines were drawn underneath it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/component/input/WidgetTextArea.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index b5cd2399a..905326095 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -66,6 +66,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { public static final int ROW_HEIGHT = 9; + public static final int SCROLLBAR_WIDTH = 14; private final TextFieldHelperExtended textFieldHelper; private final Font font; @@ -97,7 +98,7 @@ public WidgetTextArea(Font font, int x, int y, int width, int height, Component this.showLineNumbers = showLineNumbers; this.textFieldHelper = new TextFieldHelperExtended(this::getValue, this::setValuePassive, this::getClipboard, this::setClipboard, s -> true, this::setSelected, this::onCursorPosChanged); if (scrollBar) { - this.scrollBar = new WidgetScrollBar(x + width - 14, y, height, + this.scrollBar = new WidgetScrollBar(x + width - SCROLLBAR_WIDTH, y, height, Component.translatable("gui.cyclopscore.scrollbar"), firstRow -> setFirstRow(firstRow, false), height / ROW_HEIGHT) { @Override public int getTotalRows() { @@ -526,6 +527,11 @@ private void renderHighlight(Rect2i[] p_98139_) { RenderSystem.setShaderColor(1, 1, 1, 1); } + private int getTextWidth() { + // Don't render text below the scrollbar + return this.getWidth() - getLinesXOffset() - (this.scrollBar != null ? SCROLLBAR_WIDTH : 0); + } + private int getLinesXOffset() { return this.showLineNumbers ? 14 : 0; } @@ -564,7 +570,7 @@ private DisplayCache rebuildDisplayCache() { MutableBoolean mutableboolean = new MutableBoolean(); StringSplitter stringsplitter = this.font.getSplitter(); MutableInt lineNumber = new MutableInt(); - stringsplitter.splitLines(s, this.getWidth() - getLinesXOffset() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { + stringsplitter.splitLines(s, getTextWidth() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { String stringPart = s.substring(startPos, endPos); boolean hasNewLine = stringPart.endsWith("\n"); mutableboolean.setValue(hasNewLine); From 6e9c2e05f64c398cfa0ccc492a16a05e24e80c6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:43:12 +0000 Subject: [PATCH 07/59] Make page up and page down move through the editor Both keys were consumed without doing anything. Move the cursor and the viewport by one page instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 905326095..2f590de1f 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -378,10 +378,10 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { this.keyUp(); return true; case 266: -// this.backButton.onPress(); + this.keyPage(-1); return true; case 267: -// this.forwardButton.onPress(); + this.keyPage(1); return true; case 268: this.keyHome(); @@ -410,6 +410,12 @@ private void changeLine(int p_98098_) { this.textFieldHelper.setCursorPos(j, Screen.hasShiftDown()); } + private void keyPage(int direction) { + int rows = this.scrollBar != null ? this.scrollBar.getVisibleRows() : this.getHeight() / ROW_HEIGHT; + this.changeLine(direction * rows); + this.setFirstRow(this.firstRow + direction * rows, true); + } + /** * Scroll the viewport so that the cursor is visible. */ @@ -730,17 +736,10 @@ public int getIndexAtPosition(Font p_98214_, Pos2i p_98215_) { public int changeLine(int p_98211_, int p_98212_) { int i = WidgetTextArea.findLineFromPos(this.lineStarts, p_98211_); - int j = i + p_98212_; - int k; - if (0 <= j && j < this.lineStarts.length) { - int l = p_98211_ - this.lineStarts[i]; - int i1 = this.linesAll[j].contents.length(); - k = this.lineStarts[j] + Math.min(l, i1); - } else { - k = p_98211_; - } - - return k; + int j = Math.max(0, Math.min(this.lineStarts.length - 1, i + p_98212_)); + int l = p_98211_ - this.lineStarts[i]; + int i1 = this.linesAll[j].contents.length(); + return this.lineStarts[j] + Math.min(l, i1); } public int findLineStart(int p_98209_) { From 0fb748891da31d60a4848684f8b51d07bda4595f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:46:07 +0000 Subject: [PATCH 08/59] Keep the open script when the scripting terminal is resized Resizing the window, or changing the GUI scale, recreated the editor without the open script. The next key press then replaced the whole script with what was typed into the empty editor. Restore the open script and cursor position when the screen is initialized again, and close the removal dialog if it was open, as its widgets are removed as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../container/ContainerScreenTerminalScripting.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index c13eeca3d..21f0c76d8 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -96,6 +96,12 @@ public ContainerScreenTerminalScripting(ContainerTerminalScripting container, In @Override public void init() { + // This is called again when the screen is resized, which recreates all widgets + if (textArea != null) { + this.saveCursorPos(); + } + pendingScriptRemovalDialog = null; + super.init(); fieldDisk = new WidgetArrowedListField<>(Minecraft.getInstance().font, leftPos + 30, @@ -132,6 +138,13 @@ public int getTotalRows() { buttonCreateFile = new ButtonText(this.leftPos + 19, this.topPos + 222, 56, 10, Component.translatable("gui.integratedscripting.create_file"), Component.literal("+"), (button) -> getMenu().createNewFile(), true); addRenderableWidget(buttonCreateFile); + + // Restore the open script in the new editor + if (getMenu().getActiveScriptPath() != null) { + this.onActiveScriptSelected(); + this.restoreCursorPos(); + textArea.ensureCursorVisible(); + } } @Override From 1030ef3f26977f26b8337ebc4d876ce64369a8f4 Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Sun, 18 Oct 2026 14:43:05 +0200 Subject: [PATCH 09/59] Show what variable card would bind to (#83) This also avoids binding variable cards to keywords. --- .../api/language/ILanguageHandler.java | 9 +++ .../ContainerScreenTerminalScripting.java | 38 ++++++++++- .../language/LanguageHandlerJavaScript.java | 14 ++++ .../gametest/GameTestsTerminalScripting.java | 64 +++++++++++++++++++ .../container/ContainerTerminalScripting.java | 38 ++++++++++- .../integratedscripting/lang/en_us.json | 3 + .../LanguageHandlerJavaScriptTest.java | 60 +++++++++++++++++ 7 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/gametest/GameTestsTerminalScripting.java create mode 100644 src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index 55962bb24..70f75810e 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -8,7 +8,9 @@ import org.cyclops.integrateddynamics.api.evaluate.EvaluationException; import org.cyclops.integratedscripting.api.network.IScriptFactory; +import java.util.Collections; import java.util.List; +import java.util.Set; /** * Logic for handling a specific programming language. @@ -27,6 +29,13 @@ public interface ILanguageHandler { public IScriptFactory getScriptFactory() throws EvaluationException; + /** + * @return Words of the language that can not be the name of a script member, such as keywords. + */ + public default Set getReservedWords() { + return Collections.emptySet(); + } + public void onServerStarted(); } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index c13eeca3d..ad6074317 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -88,8 +88,6 @@ public ContainerScreenTerminalScripting(ContainerTerminalScripting container, In this.imageHeight = 240; this.titleLabelX = 88; this.titleLabelY = 6; - this.inventoryLabelX = 88; - this.inventoryLabelY = this.imageHeight - 94; this.firstRow = 0; this.lastClientSyncTick = 0; } @@ -306,14 +304,48 @@ protected Path getHoveredScriptPath(double mouseX, double mouseY) { @Override protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { - super.renderLabels(guiGraphics, mouseX, mouseY); + // The inventory label is left out, so its row can show the card binding + guiGraphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 4210752, false); // Draw disk label guiGraphics.drawString(font, L10NHelpers.localize("gui.integratedscripting.disk") + ":", 8, 6, 16777215); + // Draw the member the card is bound to, or will be bound to, right-aligned next to the card slot + Component bindingLabel = getBindingLabel(); + if (bindingLabel != null) { + int right = getErrorX() - 2; + guiGraphics.drawString(font, bindingLabel, right - font.width(bindingLabel), getErrorY() + 2, 4210752, false); + } + displayErrors.drawForeground(guiGraphics.pose(), getMenu().getReadErrors(), getErrorX(), getErrorY(), mouseX, mouseY, this, this.leftPos, this.topPos); } + @Nullable + protected Component getBindingLabel() { + if (getMenu().getActiveScriptPath() == null) { + return null; + } + String cardMember = getMenu().getCardMember(); + if (cardMember != null) { + return Component.translatable("gui.integratedscripting.binding.card", formatBindingMember(cardMember, "gui.integratedscripting.binding.card")); + } + if (getMenu().isMemberSelected()) { + // Not bound yet, so shown less prominently + return Component.translatable("gui.integratedscripting.binding.next", formatBindingMember(getMenu().getSelectedMember(), "gui.integratedscripting.binding.next")) + .withStyle(style -> style.withItalic(true).withColor(0x707070)); + } + return null; + } + + protected Component formatBindingMember(String member, String translationKey) { + // Truncate the member so the label fits between the editor's left border and the status icon + int maxWidth = getErrorX() - 2 - (SCRIPT_X_INNER - 6) - font.width(L10NHelpers.localize(translationKey, "")); + if (font.width(member) > maxWidth) { + member = font.plainSubstrByWidth(member, maxWidth - font.width("\u2026")) + "\u2026"; + } + return Component.literal(member).withStyle(style -> style.withColor(0x1D4F91)); + } + @Override public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { // Make active dialog consume all input diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index a5ce5568a..6c3e6d5c0 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; /** * @author rubensworks @@ -35,6 +36,14 @@ public class LanguageHandlerJavaScript implements ILanguageHandler { public static final Style CONSTANT = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(0, 134, 179))); // Blue public static final Style KEYWORD = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(167, 29, 93))); // Red-purple + // Keywords and literals, which can never be the name of a member in a (non-strict) script + public static final Set RESERVED_WORDS = Set.of( + "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", + "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", + "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", + "while", "with" + ); + public final Map tokenStyles; private final IScriptFactory scriptFactory; @@ -110,6 +119,11 @@ public List> markupLine(String line) { return segments; } + @Override + public Set getReservedWords() { + return RESERVED_WORDS; + } + @Override public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; diff --git a/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsTerminalScripting.java new file mode 100644 index 000000000..591525faf --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsTerminalScripting.java @@ -0,0 +1,64 @@ +package org.cyclops.integratedscripting.gametest; + +import it.unimi.dsi.fastutil.ints.IntList; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.GameType; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.cyclops.integrateddynamics.RegistryEntries; +import org.cyclops.integrateddynamics.api.part.PartTarget; +import org.cyclops.integrateddynamics.core.helper.PartHelpers; +import org.cyclops.integratedscripting.Reference; +import org.cyclops.integratedscripting.api.network.IScriptingData; +import org.cyclops.integratedscripting.core.network.ScriptingNetworkHelpers; +import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScripting; +import org.cyclops.integratedscripting.part.PartTypes; + +import java.nio.file.Path; + +import static org.cyclops.integratedscripting.gametest.GameTestHelpersIntegratedScripting.createBasicNetwork; + +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestsTerminalScripting { + + public static final String TEMPLATE_EMPTY = "empty10"; + + @GameTest(template = TEMPLATE_EMPTY) + public void testTerminalScriptingKeywordDoesNotRebindCard(GameTestHelper helper) { + GameTestHelpersIntegratedScripting.NetworkPositions positions = createBasicNetwork(helper, GameTestsScripts.POS); + Path path = Path.of("script0.js"); + ScriptingNetworkHelpers.getScriptingData().setScript(positions.diskId(), path, "function abc() {}\nconst def = 1;", IScriptingData.ChangeLocation.MEMORY); + + ContainerTerminalScripting container = new ContainerTerminalScripting(0, helper.makeMockPlayer(GameType.CREATIVE).getInventory(), + PartTarget.fromCenter(positions.terminal()), PartHelpers.getPartContainer(positions.terminal().getPos(), positions.terminal().getSide()), + PartTypes.TERMINAL_SCRIPTING, new ContainerTerminalScripting.InitData(IntList.of(positions.diskId()))) { + // Handle values as if they were sent by the client, as the mock player has no connection + @Override + public void setValue(int valueId, CompoundTag value) { + onUpdate(valueId, value); + } + }; + container.setActiveScriptPath(path); + + // Inserting a card binds it to the selected member + container.setSelection("abc"); + container.getContainerInventory().setItem(0, new ItemStack(RegistryEntries.ITEM_VARIABLE)); + helper.assertValueEqual(container.getCardMember(), "abc", "card member"); + + // Selecting a keyword keeps the card as is + container.setSelection("const"); + helper.assertFalse(container.isMemberSelected(), "Keyword must not be a selected member"); + helper.assertValueEqual(container.getCardMember(), "abc", "card member"); + + // Selecting another member rebinds the card + container.setSelection("def"); + helper.assertValueEqual(container.getCardMember(), "def", "card member"); + + helper.succeed(); + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index 6a12766f7..268e0c1ba 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -23,6 +23,8 @@ import org.cyclops.cyclopscore.inventory.container.InventoryContainer; import org.cyclops.cyclopscore.persist.IDirtyMarkListener; import org.cyclops.integrateddynamics.IntegratedDynamics; +import org.cyclops.integrateddynamics.api.evaluate.variable.ValueDeseralizationContext; +import org.cyclops.integrateddynamics.api.item.IVariableFacade; import org.cyclops.integrateddynamics.api.item.IVariableFacadeHandlerRegistry; import org.cyclops.integrateddynamics.api.network.INetwork; import org.cyclops.integrateddynamics.api.part.IPartContainer; @@ -33,10 +35,12 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.RegistryEntries; import org.cyclops.integratedscripting.api.item.IScriptVariableFacade; +import org.cyclops.integratedscripting.api.language.ILanguageHandler; import org.cyclops.integratedscripting.api.network.IScriptingData; import org.cyclops.integratedscripting.api.network.IScriptingNetwork; import org.cyclops.integratedscripting.core.evaluate.ScriptVariableFacadeHandler; import org.cyclops.integratedscripting.core.item.ScriptVariableFacade; +import org.cyclops.integratedscripting.core.language.LanguageHandlers; import org.cyclops.integratedscripting.core.network.ScriptingNetworkHelpers; import org.cyclops.integratedscripting.network.packet.TerminalScriptingCreateNewScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; @@ -291,7 +295,31 @@ public void setSelection(String selection) { } public boolean isMemberSelected() { - return VALID_MEMBER_NAME.matcher(getSelectedMember()).matches(); + String member = getSelectedMember(); + return VALID_MEMBER_NAME.matcher(member).matches() && !isReservedWord(member); + } + + /** + * @return If the given word is reserved in the language of the active script, such as a keyword. + */ + public boolean isReservedWord(String word) { + Path path = getActiveScriptPath(); + ILanguageHandler languageHandler = path == null ? null : LanguageHandlers.REGISTRY.getProvider(path); + return languageHandler != null && languageHandler.getReservedWords().contains(word); + } + + /** + * @return The member the variable card in the slot is bound to, or null if there is no bound card. + */ + @Nullable + public String getCardMember() { + ItemStack itemStack = getContainerInventory().getItem(0); + if (itemStack.isEmpty()) { + return null; + } + IVariableFacadeHandlerRegistry registry = IntegratedDynamics._instance.getRegistryManager().getRegistry(IVariableFacadeHandlerRegistry.class); + IVariableFacade variableFacade = registry.handle(ValueDeseralizationContext.of(getLevel()), itemStack); + return variableFacade instanceof IScriptVariableFacade scriptVariableFacade ? scriptVariableFacade.getMember() : null; } @Nullable @@ -332,8 +360,12 @@ public List getReadErrors() { List list = Lists.newArrayList(); if (canWriteScriptToVariable()) { if (!isMemberSelected()) { - list.add(Component.translatable("gui.integratedscripting.error.invalid_member")); - list.add(Component.translatable("gui.integratedscripting.error.invalid_member.current", getSelectedMember())); + if (isReservedWord(getSelectedMember())) { + list.add(Component.translatable("gui.integratedscripting.error.reserved_member", getSelectedMember())); + } else { + list.add(Component.translatable("gui.integratedscripting.error.invalid_member")); + list.add(Component.translatable("gui.integratedscripting.error.invalid_member.current", getSelectedMember())); + } } } return list; diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 23728d28a..e7b73c0a8 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -32,6 +32,9 @@ "gui.integratedscripting.removal_dialog.keep": "Keep", "gui.integratedscripting.error.invalid_member": "Select a valid function name or variable name inside the script editor to bind with.", "gui.integratedscripting.error.invalid_member.current": "Current selection: \"%s\"", + "gui.integratedscripting.error.reserved_member": "\"%s\" is a keyword, so it can not be bound. Select a function name or variable name instead.", + "gui.integratedscripting.binding.next": "Binds to: %s", + "gui.integratedscripting.binding.card": "Bound to: %s", "_comment": "Value Type Translation", "valuetype.integratedscripting.error.translation.unknown_to_graal": "Could not find a translator from %s values to Graal.", diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptTest.java new file mode 100644 index 000000000..0dceb4344 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptTest.java @@ -0,0 +1,60 @@ +package org.cyclops.integratedscripting.core.language; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * @author rubensworks + */ +public class LanguageHandlerJavaScriptTest { + + private static Context CTX; + + @BeforeClass + public static void beforeClass() { + CTX = Context.newBuilder("js").build(); + } + + @AfterClass + public static void afterClass() { + CTX.close(); + } + + private static boolean isValidVariableName(String name) { + try { + CTX.parse(Source.create("js", "var " + name + " = 1;")); + return true; + } catch (PolyglotException e) { + if (e.isSyntaxError()) { + return false; + } + throw e; + } + } + + @Test + public void testReservedWordsAreNoValidNames() { + for (String word : LanguageHandlerJavaScript.RESERVED_WORDS) { + if (isValidVariableName(word)) { + fail("\"" + word + "\" is a valid name in a script, so it must not be reserved"); + } + } + } + + @Test + public void testValidNamesAreNotReserved() { + for (String name : new String[]{"double", "offset", "of", "async", "get", "set", "let", "static", "yield", "await"}) { + assertTrue(name, isValidVariableName(name)); + assertFalse(name, LanguageHandlerJavaScript.RESERVED_WORDS.contains(name)); + } + } + +} From fe0388e4c7679d6f04c117b2cf2f775a0c54481c Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Sun, 18 Oct 2026 14:47:39 +0200 Subject: [PATCH 10/59] Avoid long disk labels to overlap with disk selector (#84) This could happen in non-English languages. --- .../container/ContainerScreenTerminalScripting.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index ad6074317..28e766e7c 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -60,6 +60,7 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended(Minecraft.getInstance().font, leftPos + 30, + fieldDisk = new WidgetArrowedListField<>(Minecraft.getInstance().font, leftPos + DISK_X, topPos + 4, 42, 15, true, Component.translatable("gui.integratedscripting.disk"), true, getMenu().getAvailableDisks()); @@ -307,8 +308,11 @@ protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { // The inventory label is left out, so its row can show the card binding guiGraphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 4210752, false); - // Draw disk label - guiGraphics.drawString(font, L10NHelpers.localize("gui.integratedscripting.disk") + ":", 8, 6, 16777215); + // Draw disk label, scaled to the space before the disk field, which fits the English label at normal size + String diskLabel = L10NHelpers.localize("gui.integratedscripting.disk") + ":"; + float diskLabelScale = (float) (DISK_X - 8 - 1) / font.width(diskLabel); + int diskLabelY = Math.round(6 + font.lineHeight * (1 - diskLabelScale) / 2); + RenderHelpers.drawScaledString(guiGraphics, font, diskLabel, 8, diskLabelY, diskLabelScale, 16777215, true, Font.DisplayMode.NORMAL); // Draw the member the card is bound to, or will be bound to, right-aligned next to the card slot Component bindingLabel = getBindingLabel(); From 968d44c4eae3523f04827c6da46cd65afb9248e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:06:59 +0000 Subject: [PATCH 11/59] Use the macOS keys for moving by word and line in the script editor On macOS, Cmd+Left/Right jumped by word, while native text fields use Option+Left/Right for that, and Cmd+Left/Right to go to the start or end of the line. The editor now follows these conventions on macOS, including Option+Backspace to delete a word and Cmd+Up/Down to go to the start or end of the script. Other platforms keep using Ctrl. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/EditorKeys.java | 28 +++++++++++++ .../gui/component/input/WidgetTextArea.java | 39 +++++++++++++----- .../gui/component/input/EditorKeysTest.java | 40 +++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeys.java create mode 100644 src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeys.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeys.java new file mode 100644 index 000000000..c45b40a6b --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeys.java @@ -0,0 +1,28 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.lwjgl.glfw.GLFW; + +/** + * Platform conventions for moving the cursor in text editors. + * @author rubensworks + */ +public final class EditorKeys { + + private EditorKeys() { + } + + /** + * @return If the cursor should move or delete by word: Option on macOS, Ctrl elsewhere. + */ + public static boolean isWordModifier(int modifiers, boolean osx) { + return (modifiers & (osx ? GLFW.GLFW_MOD_ALT : GLFW.GLFW_MOD_CONTROL)) != 0; + } + + /** + * @return If arrow keys should jump to the line or document start or end, which is Cmd on macOS. + */ + public static boolean isLineModifier(int modifiers, boolean osx) { + return osx && (modifiers & GLFW.GLFW_MOD_SUPER) != 0; + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 2f590de1f..03130fb15 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -353,7 +353,8 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { this.textFieldHelper.cut(); return true; } else { - TextFieldHelper.CursorStep cursorStep = Screen.hasControlDown() ? TextFieldHelper.CursorStep.WORD : TextFieldHelper.CursorStep.CHARACTER; + TextFieldHelper.CursorStep cursorStep = EditorKeys.isWordModifier(p_98155_, Minecraft.ON_OSX) ? TextFieldHelper.CursorStep.WORD : TextFieldHelper.CursorStep.CHARACTER; + boolean lineModifier = EditorKeys.isLineModifier(p_98155_, Minecraft.ON_OSX); switch (p_98153_) { case 257: case 335: @@ -366,16 +367,32 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { this.textFieldHelper.removeFromCursor(1, cursorStep); return true; case 262: - this.textFieldHelper.moveBy(1, Screen.hasShiftDown(), cursorStep); + if (lineModifier) { + this.keyEnd(false); + } else { + this.textFieldHelper.moveBy(1, Screen.hasShiftDown(), cursorStep); + } return true; case 263: - this.textFieldHelper.moveBy(-1, Screen.hasShiftDown(), cursorStep); + if (lineModifier) { + this.keyHome(false); + } else { + this.textFieldHelper.moveBy(-1, Screen.hasShiftDown(), cursorStep); + } return true; case 264: - this.keyDown(); + if (lineModifier) { + this.keyEnd(true); + } else { + this.keyDown(); + } return true; case 265: - this.keyUp(); + if (lineModifier) { + this.keyHome(true); + } else { + this.keyUp(); + } return true; case 266: this.keyPage(-1); @@ -384,10 +401,10 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { this.keyPage(1); return true; case 268: - this.keyHome(); + this.keyHome(Screen.hasControlDown()); return true; case 269: - this.keyEnd(); + this.keyEnd(Screen.hasControlDown()); return true; default: return false; @@ -431,8 +448,8 @@ public void ensureCursorVisible() { } } - private void keyHome() { - if (Screen.hasControlDown()) { + private void keyHome(boolean document) { + if (document) { this.textFieldHelper.setCursorToStart(Screen.hasShiftDown()); } else { int i = this.textFieldHelper.getCursorPos(); @@ -442,8 +459,8 @@ private void keyHome() { } - private void keyEnd() { - if (Screen.hasControlDown()) { + private void keyEnd(boolean document) { + if (document) { this.textFieldHelper.setCursorToEnd(Screen.hasShiftDown()); } else { DisplayCache displayCache = this.getDisplayCache(); diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java new file mode 100644 index 000000000..8b10b7590 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java @@ -0,0 +1,40 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.junit.Test; +import org.lwjgl.glfw.GLFW; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author rubensworks + */ +public class EditorKeysTest { + + @Test + public void testWordModifierMac() { + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_ALT, true)); + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_ALT | GLFW.GLFW_MOD_SHIFT, true)); + assertFalse(EditorKeys.isWordModifier(GLFW.GLFW_MOD_SUPER, true)); + assertFalse(EditorKeys.isWordModifier(GLFW.GLFW_MOD_CONTROL, true)); + assertFalse(EditorKeys.isWordModifier(0, true)); + } + + @Test + public void testWordModifierOthers() { + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_CONTROL, false)); + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_CONTROL | GLFW.GLFW_MOD_SHIFT, false)); + assertFalse(EditorKeys.isWordModifier(GLFW.GLFW_MOD_ALT, false)); + assertFalse(EditorKeys.isWordModifier(0, false)); + } + + @Test + public void testLineModifier() { + assertTrue(EditorKeys.isLineModifier(GLFW.GLFW_MOD_SUPER, true)); + assertTrue(EditorKeys.isLineModifier(GLFW.GLFW_MOD_SUPER | GLFW.GLFW_MOD_SHIFT, true)); + assertFalse(EditorKeys.isLineModifier(GLFW.GLFW_MOD_ALT, true)); + assertFalse(EditorKeys.isLineModifier(GLFW.GLFW_MOD_SUPER, false)); + assertFalse(EditorKeys.isLineModifier(GLFW.GLFW_MOD_CONTROL, false)); + } + +} From 076603e4efb896cd74c8149936158a790ec224d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:08:48 +0000 Subject: [PATCH 12/59] Don't draw the selection over the bottom border of the script editor When a selection continued below the last visible line, the highlight of the hidden next line was drawn over the editor's bottom border. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/component/input/WidgetTextArea.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 03130fb15..c9f589445 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -671,11 +671,11 @@ private DisplayCache rebuildDisplayCache() { selection.add(this.createPartialLineSelection(s, stringsplitter, lineStartsArr[lineEnd], i1, lineEnd * 9, lineStartsArr[lineEnd])); } } - // Hide out-of-view selections and offset them + // Hide out-of-view selections and offset them, including the partially visible row below the last line List selectionNew = Lists.newArrayList(); for (Rect2i rect2i : selection) { Rect2i rect2iNew = new Rect2i(rect2i.getX(), rect2i.getY() - this.firstRow * ROW_HEIGHT, rect2i.getWidth(), rect2i.getHeight()); - if (rect2iNew.getY() - this.getY() >= 0 && rect2iNew.getY() - this.getY() < getHeight()) { + if (rect2iNew.getY() - this.getY() >= 0 && rect2iNew.getY() + rect2iNew.getHeight() - this.getY() <= getHeight()) { selectionNew.add(rect2iNew); } } From c913defce64f407c3cbd85d1e1834429b25d1c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:10:38 +0000 Subject: [PATCH 13/59] Don't block dragging items over slots in the scripting terminal The script editor claimed every mouse drag, even outside of it, so spreading a held stack over inventory slots by dragging put the whole stack in one slot. The editor now only claims drags it handles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/component/input/WidgetTextArea.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index c9f589445..f6deda584 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -304,8 +304,10 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl this.textFieldHelper.setCursorPos(i, true); this.clearDisplayCache(); this.setFocused(true); + return true; } - return true; + // Let other drags through, such as spreading items over inventory slots + return false; } @Override From 41456421b21cc8b8c12c58704954c36f5e9f6a54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:21:02 +0000 Subject: [PATCH 14/59] Don't put the cursor on the hidden row below the script editor's last line The editor has room for part of a row below its last visible line. Clicking or dragging there put the cursor on that hidden line, and the cursor was drawn over the editor's bottom border. Such positions now go to the last visible line instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index f6deda584..6cf30e0d7 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -246,7 +246,7 @@ public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { this.setFocused(true); long i = Util.getMillis(); DisplayCache displayCache = this.getDisplayCache(); - int j = displayCache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, (int)mouseY))); + int j = displayCache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, this.clampToVisibleRows(mouseY)))); if (j >= 0) { if (j == this.lastIndex && i - this.lastClickTime < 250L) { if (!this.textFieldHelper.isSelecting()) { @@ -300,7 +300,7 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); - int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, (int)mouseY))); + int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, this.clampToVisibleRows(mouseY)))); this.textFieldHelper.setCursorPos(i, true); this.clearDisplayCache(); this.setFocused(true); @@ -429,6 +429,14 @@ private void changeLine(int p_98098_) { this.textFieldHelper.setCursorPos(j, Screen.hasShiftDown()); } + /** + * @return The y position, moved up to the last fully visible row if it is below it. + */ + private int clampToVisibleRows(double mouseY) { + int rows = this.scrollBar != null ? this.scrollBar.getVisibleRows() : this.getHeight() / ROW_HEIGHT; + return (int) Math.min(mouseY, this.getY() + rows * ROW_HEIGHT - 1); + } + private void keyPage(int direction) { int rows = this.scrollBar != null ? this.scrollBar.getVisibleRows() : this.getHeight() / ROW_HEIGHT; this.changeLine(direction * rows); @@ -644,8 +652,8 @@ private DisplayCache rebuildDisplayCache() { int cursorLine = findLineFromPos(lineStartsArr, cursorPos); int cursorX = this.font.width(s.substring(lineStartsArr[cursorLine], cursorPos)); Pos2i cursor = new Pos2i(cursorX, (cursorLine - this.firstRow) * 9); // We have to offset the firstRow here! - // Hide cursor if out of view - if (cursor.y < 0 || cursor.y > getHeight()) { + // Hide cursor if out of view, including the partially visible row below the last line + if (cursor.y < 0 || cursor.y + ROW_HEIGHT > getHeight()) { cursor = null; } From 7dea53dadf94db1dab641422bd28f139bbea0629 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:21:56 +0000 Subject: [PATCH 15/59] Add undo and redo to the script editor Scripts are saved on every edit and right-click selected the whole script, so a right-click followed by a single key press replaced and saved the entire script, without any way to get it back. Add a per-file undo history (ctrl+z, and ctrl+y or ctrl+shift+z to redo) that groups consecutive typing, and stop selecting everything on right-click. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../input/TextFieldHelperExtended.java | 5 +- .../gui/component/input/TextHistory.java | 75 +++++++++++++++ .../gui/component/input/WidgetTextArea.java | 56 ++++++++++- .../ContainerScreenTerminalScripting.java | 4 + .../gui/component/input/TextHistoryTest.java | 93 +++++++++++++++++++ 5 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java create mode 100644 src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistoryTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java index 2fca066bd..2cc00c2cc 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java @@ -27,8 +27,9 @@ public TextFieldHelperExtended(Supplier getMessageFn, Consumer s public String getSelected() { String value = this.getMessageFn.get(); - int i = Math.min(this.getCursorPos(), this.getSelectionPos()); - int j = Math.max(this.getCursorPos(), this.getSelectionPos()); + // Positions may still refer to a previous, longer value + int i = Math.min(Math.min(this.getCursorPos(), this.getSelectionPos()), value.length()); + int j = Math.min(Math.max(this.getCursorPos(), this.getSelectionPos()), value.length()); return value.substring(i, j); } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java new file mode 100644 index 000000000..bf5cefa30 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java @@ -0,0 +1,75 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import javax.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Undo and redo history of a text area. + * @author rubensworks + */ +public class TextHistory { + + public static final int MAX_SIZE = 500; + public static final long TYPING_GROUP_MILLIS = 1000; + + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + private boolean lastWasTyping; + private long lastRecordTime; + private int lastTypingEnd = -1; + + /** + * Record the state before an edit. + * Consecutive typing at the same position within a short time is grouped into a single undo step. + * @param before The state before the edit. + * @param typing If the edit was caused by typing a single character. + * @param time The current time in milliseconds. + */ + public void record(State before, boolean typing, long time) { + boolean group = typing && this.lastWasTyping && !this.undoStack.isEmpty() + && time - this.lastRecordTime < TYPING_GROUP_MILLIS + && Math.min(before.cursorPos(), before.selectionPos()) == this.lastTypingEnd; + if (!group) { + this.undoStack.push(before); + if (this.undoStack.size() > MAX_SIZE) { + this.undoStack.removeLast(); + } + } + this.redoStack.clear(); + this.lastWasTyping = typing; + this.lastRecordTime = time; + this.lastTypingEnd = typing ? Math.min(before.cursorPos(), before.selectionPos()) + 1 : -1; + } + + /** + * @param current The current state, which will become redoable. + * @return The state to restore, or null if there is nothing to undo. + */ + @Nullable + public State undo(State current) { + if (this.undoStack.isEmpty()) { + return null; + } + this.redoStack.push(current); + this.lastWasTyping = false; + return this.undoStack.pop(); + } + + /** + * @param current The current state, which will become undoable. + * @return The state to restore, or null if there is nothing to redo. + */ + @Nullable + public State redo(State current) { + if (this.redoStack.isEmpty()) { + return null; + } + this.undoStack.push(current); + this.lastWasTyping = false; + return this.redoStack.pop(); + } + + public static record State(String value, int cursorPos, int selectionPos) {} + +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 6cf30e0d7..728caa959 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -91,6 +91,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { @Nullable private WidgetScrollBar scrollBar; private int firstRow; + private TextHistory history = new TextHistory(); public WidgetTextArea(Font font, int x, int y, int width, int height, Component narrationMessage, boolean scrollBar, boolean showLineNumbers) { super(font, x, y, width, height, narrationMessage); @@ -152,6 +153,10 @@ public void setListenerCursor(@Nullable IInputListener listenerCursor) { this.listenerCursor = listenerCursor; } + public void setHistory(TextHistory history) { + this.history = history; + } + public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { this.markupProvider = markupProvider; } @@ -236,9 +241,8 @@ public void tick() { public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { if (mouseButton == 1 && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { - // Select everything + // Don't select everything on right-click, as typing would then replace the whole script this.setFocused(true); - textFieldHelper.selectAll(); return true; } else { if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width @@ -312,8 +316,13 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { - boolean flag = this.textFieldKeyPressed(typedChar, keyCode, modifiers); + TextHistory.State before = this.getState(); + boolean history = this.historyKeyPressed(typedChar, modifiers); + boolean flag = history || this.textFieldKeyPressed(typedChar, keyCode, modifiers); if (flag) { + if (!history) { + this.recordHistory(before, false); + } this.clearDisplayCache(); this.setFocused(true); this.ensureCursorVisible(); @@ -331,7 +340,9 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { public boolean charTyped(char typedChar, int keyCode) { // Don't delegate to EditBox, as that would consume the character into its own (unused) value if (StringUtil.isAllowedChatCharacter(typedChar)) { + TextHistory.State before = this.getState(); this.textFieldHelper.insertText(Character.toString(typedChar)); + this.recordHistory(before, true); this.clearDisplayCache(); this.setFocused(true); this.ensureCursorVisible(); @@ -341,6 +352,45 @@ public boolean charTyped(char typedChar, int keyCode) { } } + private TextHistory.State getState() { + return new TextHistory.State(this.getValue(), this.textFieldHelper.getCursorPos(), this.textFieldHelper.getSelectionPos()); + } + + private void setState(TextHistory.State state) { + this.setValuePassive(state.value()); + this.textFieldHelper.setSelectionRange(state.cursorPos(), state.selectionPos()); + } + + private void recordHistory(TextHistory.State before, boolean typing) { + if (!before.value().equals(this.getValue())) { + this.history.record(before, typing, Util.getMillis()); + } + } + + // Modifiers are read from the key event instead of the global keyboard state + private static boolean hasControlDown(int modifiers) { + return (modifiers & (Minecraft.ON_OSX ? GLFW.GLFW_MOD_SUPER : GLFW.GLFW_MOD_CONTROL)) != 0; + } + + private boolean historyKeyPressed(int key, int modifiers) { + if (!hasControlDown(modifiers)) { + return false; + } + boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + TextHistory.State state; + if (key == GLFW.GLFW_KEY_Z && !shift) { + state = this.history.undo(this.getState()); + } else if (key == GLFW.GLFW_KEY_Y || (key == GLFW.GLFW_KEY_Z && shift)) { + state = this.history.redo(this.getState()); + } else { + return false; + } + if (state != null) { + this.setState(state); + } + return true; + } + private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { if (Screen.isSelectAll(p_98153_)) { this.textFieldHelper.selectAll(); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 2f64ceb31..fc51a8bff 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -30,6 +30,7 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.client.gui.component.input.TextHistory; import org.cyclops.integratedscripting.client.gui.component.input.WidgetDialog; import org.cyclops.integratedscripting.client.gui.component.input.WidgetTextArea; import org.cyclops.integratedscripting.client.gui.image.ScriptImages; @@ -81,6 +82,7 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended lastScriptCursorPos = Maps.newHashMap(); private final Map lastScriptSelectionPos = Maps.newHashMap(); private final Int2ObjectMap lastDiskScript = new Int2ObjectAVLTreeMap<>(); + private final Map, TextHistory> scriptHistories = Maps.newHashMap(); public ContainerScreenTerminalScripting(ContainerTerminalScripting container, Inventory inventory, Component title) { super(container, inventory, title); @@ -499,6 +501,8 @@ public void setFirstRow(int firstRow) { private void onActiveScriptSelected() { String script = getMenu().getActiveScript(); this.textArea.setValue(script == null ? "" : script); + Path path = getMenu().getActiveScriptPath(); + this.textArea.setHistory(path == null ? new TextHistory() : scriptHistories.computeIfAbsent(Pair.of(getMenu().getActiveDisk(), path), k -> new TextHistory())); } private void onActiveScriptModified() { diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistoryTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistoryTest.java new file mode 100644 index 000000000..eeb523565 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistoryTest.java @@ -0,0 +1,93 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +public class TextHistoryTest { + + private static TextHistory.State s(String value, int cursor) { + return new TextHistory.State(value, cursor, cursor); + } + + @Test + public void testEmpty() { + TextHistory history = new TextHistory(); + assertThat(history.undo(s("a", 1)), nullValue()); + assertThat(history.redo(s("a", 1)), nullValue()); + } + + @Test + public void testUndoRedo() { + TextHistory history = new TextHistory(); + history.record(s("", 0), false, 0); + assertThat(history.undo(s("pasted", 6)), equalTo(s("", 0))); + assertThat(history.undo(s("", 0)), nullValue()); + assertThat(history.redo(s("", 0)), equalTo(s("pasted", 6))); + assertThat(history.redo(s("pasted", 6)), nullValue()); + } + + @Test + public void testGroupsConsecutiveTyping() { + TextHistory history = new TextHistory(); + history.record(s("", 0), true, 0); + history.record(s("a", 1), true, 100); + history.record(s("ab", 2), true, 200); + assertThat(history.undo(s("abc", 3)), equalTo(s("", 0))); + assertThat(history.undo(s("", 0)), nullValue()); + } + + @Test + public void testDoesNotGroupTypingAfterPause() { + TextHistory history = new TextHistory(); + history.record(s("", 0), true, 0); + history.record(s("a", 1), true, 5000); + assertThat(history.undo(s("ab", 2)), equalTo(s("a", 1))); + assertThat(history.undo(s("a", 1)), equalTo(s("", 0))); + } + + @Test + public void testDoesNotGroupTypingAtOtherPosition() { + TextHistory history = new TextHistory(); + history.record(s("xy", 0), true, 0); + history.record(s("axy", 3), true, 100); + assertThat(history.undo(s("axyb", 4)), equalTo(s("axy", 3))); + } + + @Test + public void testDoesNotGroupTypingWithOtherEdits() { + TextHistory history = new TextHistory(); + history.record(s("", 0), true, 0); + history.record(s("a", 1), false, 100); + history.record(s("a\n", 2), true, 200); + assertThat(history.undo(s("a\nb", 3)), equalTo(s("a\n", 2))); + assertThat(history.undo(s("a\n", 2)), equalTo(s("a", 1))); + assertThat(history.undo(s("a", 1)), equalTo(s("", 0))); + } + + @Test + public void testNewEditClearsRedo() { + TextHistory history = new TextHistory(); + history.record(s("", 0), false, 0); + history.undo(s("a", 1)); + history.record(s("", 0), false, 100); + assertThat(history.redo(s("b", 1)), nullValue()); + } + + @Test + public void testMaxSize() { + TextHistory history = new TextHistory(); + for (int i = 0; i < TextHistory.MAX_SIZE + 10; i++) { + history.record(s(String.valueOf(i), 0), false, i * 10000L); + } + TextHistory.State last = null; + TextHistory.State state; + while ((state = history.undo(s("current", 0))) != null) { + last = state; + } + assertThat(last, equalTo(s("10", 0))); + } + +} From 1ffe4be9efecaab65e09833aa91ebed3fc6a92bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:22:09 +0000 Subject: [PATCH 16/59] Don't echo synced scripts back to the client When the client synced a script, the server sent the same version back. If the player typed in the meantime, this echo overwrote the newer client version, which was then synced instead, losing the last keystrokes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../inventory/container/ContainerTerminalScripting.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index 268e0c1ba..8eddf5294 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -250,6 +250,9 @@ public Int2ObjectMap> getLastScripts() { } public void setServerScript(int disk, Path path, @Nullable String script) { + // The client already has this version, so don't send it back in broadcastChanges. + // Otherwise, this echo could overwrite newer client changes that were not synced yet. + setLastScript(disk, path, script); ScriptingNetworkHelpers.getScriptingData().setScript(disk, path, script, IScriptingData.ChangeLocation.MEMORY); } From 89a13424dd544642e65683287614085f25eae98f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:59:59 +0000 Subject: [PATCH 17/59] Write scripts to disk atomically Scripts were flushed by truncating and rewriting the file, so the file watcher could reload a partially written or empty script into memory and send it to clients. Write to a temporary file outside the watched directories and move it into place instead, keeping the permissions of existing files and the default permissions for new ones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../core/network/ScriptingData.java | 28 +++++++++- .../core/network/ScriptingDataTest.java | 55 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java index 5df42670a..9ee70381b 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java +++ b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java @@ -18,14 +18,18 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.ClosedWatchServiceException; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -390,6 +394,28 @@ public Pair getOutputStreams(int disk, Path scriptPa ); } + /** + * Write a file without exposing partially written contents to the file watcher. + * The temporary file is created outside the watched disk directories. + */ + protected void writeAtomically(Path path, String contents) throws IOException { + Path tempFile = Files.createTempFile(rootPath, "flush", ".tmp"); + try { + // Temporary files are only accessible by their owner, so use the permissions of a regular file instead + if (Files.getFileStore(tempFile).supportsFileAttributeView(PosixFileAttributeView.class)) { + Files.setPosixFilePermissions(tempFile, Files.exists(path) ? Files.getPosixFilePermissions(path) : PosixFilePermissions.fromString("rw-r--r--")); + } + Files.writeString(tempFile, contents, StandardCharsets.UTF_8); + try { + Files.move(tempFile, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tempFile, path, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(tempFile); + } + } + protected void flushScript(int disk, Path scriptPathRelative) { Map scripts = getScripts(disk); String script = scripts.get(scriptPathRelative); @@ -399,7 +425,7 @@ protected void flushScript(int disk, Path scriptPathRelative) { FileUtils.delete(scriptPathAbsolute.toFile()); } else { scriptPathAbsolute.getParent().toFile().mkdirs(); - FileUtils.write(scriptPathAbsolute.toFile(), script, StandardCharsets.UTF_8); + writeAtomically(scriptPathAbsolute, script); this.registerPathWatcher(disk, null); this.registerPathWatcher(disk, scriptPathRelative.getParent()); } diff --git a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java index eb09f4ae9..0935f5786 100644 --- a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java +++ b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java @@ -171,6 +171,61 @@ public void testExternalUpdatesOnRuntimeFirstUsedDiskSynced() throws IOException } } + @Test + public void testFlushDoesNotExposePartialContents() throws IOException, InterruptedException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + scriptingData.initialize(); + Path scriptPath = Path.of("main.js"); + String body = "x".repeat(1_000_000); + + // The file watcher reloads the disk after each flush, which must never see a partially written file + for (int i = 0; i < 20; i++) { + scriptingData.setScript(789, scriptPath, body + i + "//end", IScriptingData.ChangeLocation.MEMORY); + scriptingData.tick(); + long deadline = System.currentTimeMillis() + 100; + while (System.currentTimeMillis() < deadline) { + String script = scriptingData.getScripts(789).get(scriptPath); + assertTrue("Observed partial script contents", script != null && script.endsWith("//end")); + } + } + + try (var files = Files.list(rootPath)) { + assertTrue("Temporary files were left behind", files.noneMatch(f -> f.toString().endsWith(".tmp"))); + } + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + + @Test + public void testFlushKeepsFilePermissions() throws IOException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + org.junit.Assume.assumeTrue(Files.getFileStore(rootPath).supportsFileAttributeView(java.nio.file.attribute.PosixFileAttributeView.class)); + Path scriptPath = Path.of("main.js"); + Path scriptPathAbsolute = rootPath.resolve("scripting-disks").resolve("321").resolve(scriptPath); + + // New files are readable by others + scriptingData.setScript(321, scriptPath, scriptValue(1), IScriptingData.ChangeLocation.MEMORY); + scriptingData.tick(); + assertThat(java.nio.file.attribute.PosixFilePermissions.toString(Files.getPosixFilePermissions(scriptPathAbsolute)), equalTo("rw-r--r--")); + + // Existing permissions are kept + Files.setPosixFilePermissions(scriptPathAbsolute, java.nio.file.attribute.PosixFilePermissions.fromString("rw-rw-r--")); + scriptingData.setScript(321, scriptPath, scriptValue(2), IScriptingData.ChangeLocation.MEMORY); + scriptingData.tick(); + assertThat(java.nio.file.attribute.PosixFilePermissions.toString(Files.getPosixFilePermissions(scriptPathAbsolute)), equalTo("rw-rw-r--")); + assertThat(Files.readString(scriptPathAbsolute), equalTo(scriptValue(2))); + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + private static void awaitCondition(Condition condition) throws InterruptedException { assertTrue("Timed out waiting for condition", awaitCondition(condition, AWAIT_TIMEOUT_MS)); } From 9622668b2e40c7df1b7d36723ed2643db9ae8f03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:23:06 +0000 Subject: [PATCH 18/59] Show external script changes in the open editor When a script changed outside the editor, for example by editing the file on disk, the open editor kept showing the old text, and the next key press synced that old text back, discarding the external change. Reload the active script when the server sends a newer version, as an undoable edit that keeps the cursor position. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 11 +++++++++++ .../ContainerScreenTerminalScripting.java | 11 +++++++++++ .../container/ContainerTerminalScripting.java | 18 ++++++++++++++++++ .../TerminalScriptingModifiedScriptPacket.java | 1 + 4 files changed, 41 insertions(+) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 728caa959..86da7a15c 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -367,6 +367,17 @@ private void recordHistory(TextHistory.State before, boolean typing) { } } + /** + * Replace the value as an undoable edit, keeping the cursor position where possible. + * @param value The new value. + */ + public void replaceValue(String value) { + TextHistory.State before = this.getState(); + this.setState(new TextHistory.State(value, before.cursorPos(), before.selectionPos())); + this.recordHistory(before, false); + this.clearDisplayCache(); + } + // Modifiers are read from the key event instead of the global keyboard state private static boolean hasControlDown(int modifiers) { return (modifiers & (Minecraft.ON_OSX ? GLFW.GLFW_MOD_SUPER : GLFW.GLFW_MOD_CONTROL)) != 0; diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index fc51a8bff..84eef1b32 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -127,6 +127,7 @@ public int getTotalRows() { textArea = new WidgetTextArea(Minecraft.getInstance().font, this.leftPos + SCRIPT_X + 1, this.topPos + SCRIPT_Y + 1, SCRIPT_WIDTH, SCRIPT_HEIGHT, Component.translatable("gui.integratedscripting.script"), true, true); textArea.setListener(this::onActiveScriptModified); + getMenu().setRemoteScriptChangeListener(this::onRemoteScriptChanged); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); textArea.setMarkupProvider((style, line) -> { @@ -505,6 +506,16 @@ private void onActiveScriptSelected() { this.textArea.setHistory(path == null ? new TextHistory() : scriptHistories.computeIfAbsent(Pair.of(getMenu().getActiveDisk(), path), k -> new TextHistory())); } + private void onRemoteScriptChanged(int disk, Path path) { + // Show changes from outside this editor, such as external file edits, instead of overwriting them on the next edit + if (disk == getMenu().getActiveDisk() && path.equals(getMenu().getActiveScriptPath())) { + String script = getMenu().getActiveScript(); + if (script != null && !script.equals(this.textArea.getValue())) { + this.textArea.replaceValue(script); + } + } + } + private void onActiveScriptModified() { getMenu().setActiveScript(this.textArea.getValue()); } diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index 8eddf5294..e41ac3169 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -50,6 +50,7 @@ import javax.annotation.Nullable; import java.nio.file.Path; import java.util.*; +import java.util.function.BiConsumer; import java.util.regex.Pattern; /** @@ -74,6 +75,8 @@ public class ContainerTerminalScripting extends InventoryContainer implements ID private final int activeDiskId; private final int activeScriptPathId; private final int selectionId; + @Nullable + private BiConsumer remoteScriptChangeListener; public ContainerTerminalScripting(int id, Inventory playerInventory, RegistryFriendlyByteBuf packetBuffer) { this(id, playerInventory, PartHelpers.readPartTarget(packetBuffer), Optional.empty(), @@ -245,6 +248,21 @@ public void setLastScript(int disk, Path path, @Nullable String script) { } } + public void setRemoteScriptChangeListener(@Nullable BiConsumer remoteScriptChangeListener) { + this.remoteScriptChangeListener = remoteScriptChangeListener; + } + + /** + * Called on the client when a script was changed by the server. + * @param disk The disk id. + * @param path The script path. + */ + public void onRemoteScriptChange(int disk, Path path) { + if (this.remoteScriptChangeListener != null) { + this.remoteScriptChangeListener.accept(disk, path); + } + } + public Int2ObjectMap> getLastScripts() { return lastScripts; } diff --git a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java index ae34d68f2..e35b6ff48 100644 --- a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java +++ b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java @@ -59,6 +59,7 @@ public boolean isAsync() { public void actionClient(Level world, Player player) { if(player.containerMenu instanceof ContainerTerminalScripting container) { container.setLastScript(disk, Path.of(path), getScript()); + container.onRemoteScriptChange(disk, Path.of(path)); } } From 63e7aa2f69336e531533a05a32e66b7e2da0a5ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:24:19 +0000 Subject: [PATCH 19/59] Only apply external changes when the file watcher reloads scripts When the file watcher reloaded a disk directory, it replaced the scripts in memory with whatever was on disk at that moment. As it also triggers on the server's own writes, it could run while a script was not written yet, for example right after creating the directory of a new disk, which made that script disappear. Concurrent reloads could also overwrite each other. Remember the contents last written or read for each file, only apply files that changed outside of the mod, and don't let reloads, flushes and script changes run at the same time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../core/network/ScriptingData.java | 40 ++++++++++++++--- .../core/network/ScriptingDataTest.java | 44 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java index 9ee70381b..ece96edc9 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java +++ b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java @@ -38,6 +38,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** @@ -48,6 +49,8 @@ public class ScriptingData implements IScriptingData { public static final LevelResource LEVEL_RESOURCE = new LevelResource("integratedscripting"); private final Int2ObjectMap> diskScripts = new Int2ObjectAVLTreeMap<>(); + // The file contents as last written or read by this class, to detect changes made outside of it + private final Map> knownDiskContents = new ConcurrentHashMap<>(); private final Path rootPath; private final Set> dirtyPaths = Sets.newHashSet(); private final Int2ObjectMap> scriptChangeListeners = new Int2ObjectAVLTreeMap<>(); @@ -140,8 +143,9 @@ public void initialize() { watchThread.start(); } - protected void initializeDiskScripts(int diskId) { - Map scripts = Maps.newHashMap(); + // Synchronized, as this is also called from the file watcher thread + protected synchronized void initializeDiskScripts(int diskId) { + Map scriptsDisk = Maps.newHashMap(); Path diskDir = getDiskPath(diskId); Iterator filesIt = FileUtils.iterateFilesAndDirs(diskDir.toFile(), FileFilterUtils.trueFileFilter(), FileFilterUtils.trueFileFilter()); while (filesIt.hasNext()) { @@ -149,12 +153,33 @@ protected void initializeDiskScripts(int diskId) { if (file.isFile()) { Path filePathRelative = diskDir.relativize(file.toPath()); try { - scripts.put(filePathRelative, FileUtils.readFileToString(file, StandardCharsets.UTF_8)); + scriptsDisk.put(filePathRelative, FileUtils.readFileToString(file, StandardCharsets.UTF_8)); } catch (IOException e) { e.printStackTrace(); } } } + + // Only apply changes made outside of this class. + // The file watcher may also trigger while scripts are being written here, + // in which case the disk contents are outdated compared to the scripts in memory. + Map known = knownDiskContents.computeIfAbsent(diskId, k -> new ConcurrentHashMap<>()); + Map scriptsMemory = diskScripts.get(diskId); + Map scripts = scriptsMemory == null ? Maps.newHashMap() : Maps.newHashMap(scriptsMemory); + Set paths = Sets.newHashSet(scriptsDisk.keySet()); + paths.addAll(known.keySet()); + for (Path path : paths) { + String scriptDisk = scriptsDisk.get(path); + if (!Objects.equals(scriptDisk, known.get(path))) { + if (scriptDisk == null) { + scripts.remove(path); + known.remove(path); + } else { + scripts.put(path, scriptDisk); + known.put(path, scriptDisk); + } + } + } this.setScripts(diskId, scripts, ChangeLocation.DISK); } @@ -207,7 +232,7 @@ public Map getScripts(int disk) { } @Override - public void setScripts(int disk, Map scripts, ChangeLocation changeLocation) { + public synchronized void setScripts(int disk, Map scripts, ChangeLocation changeLocation) { // Update script data Map oldScripts = diskScripts.put(disk, scripts); @@ -291,7 +316,7 @@ boolean isScriptPathSafe(int disk, Path supplied) { } @Override - public void setScript(int disk, Path scriptPathRelative, @Nullable String script, ChangeLocation changeLocation) { + public synchronized void setScript(int disk, Path scriptPathRelative, @Nullable String script, ChangeLocation changeLocation) { // Validate that the path is safe and contained within the disk root if (!isScriptPathSafe(disk, scriptPathRelative)) { return; @@ -416,16 +441,19 @@ protected void writeAtomically(Path path, String contents) throws IOException { } } - protected void flushScript(int disk, Path scriptPathRelative) { + protected synchronized void flushScript(int disk, Path scriptPathRelative) { Map scripts = getScripts(disk); String script = scripts.get(scriptPathRelative); Path scriptPathAbsolute = getDiskPath(disk).resolve(scriptPathRelative); try { + Map known = knownDiskContents.computeIfAbsent(disk, k -> new ConcurrentHashMap<>()); if (script == null) { FileUtils.delete(scriptPathAbsolute.toFile()); + known.remove(scriptPathRelative); } else { scriptPathAbsolute.getParent().toFile().mkdirs(); writeAtomically(scriptPathAbsolute, script); + known.put(scriptPathRelative, script); this.registerPathWatcher(disk, null); this.registerPathWatcher(disk, scriptPathRelative.getParent()); } diff --git a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java index 0935f5786..3d40ac31c 100644 --- a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java +++ b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java @@ -226,6 +226,50 @@ public void testFlushKeepsFilePermissions() throws IOException { } } + @Test + public void testDiskReloadDuringFirstFlushKeepsScript() throws IOException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + scriptingData.initialize(); + scriptingData.setScript(777, Path.of("main.js"), "value", IScriptingData.ChangeLocation.MEMORY); + + // The file watcher reloads the disk once its directory is created, possibly before the script is written + Files.createDirectories(rootPath.resolve("scripting-disks").resolve("777")); + scriptingData.initializeDiskScripts(777); + + assertThat(scriptingData.getScripts(777).get(Path.of("main.js")), equalTo("value")); + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + + @Test + public void testDiskReloadAppliesExternalChanges() throws IOException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + Path diskPath = rootPath.resolve("scripting-disks").resolve("778"); + Files.createDirectories(diskPath); + Files.writeString(diskPath.resolve("a.js"), "a1"); + Files.writeString(diskPath.resolve("b.js"), "b1"); + scriptingData.initialize(); + assertThat(scriptingData.getScripts(778).get(Path.of("a.js")), equalTo("a1")); + + // Externally modify one file and delete another + Files.writeString(diskPath.resolve("a.js"), "a2"); + Files.delete(diskPath.resolve("b.js")); + scriptingData.initializeDiskScripts(778); + + assertThat(scriptingData.getScripts(778).get(Path.of("a.js")), equalTo("a2")); + assertThat(scriptingData.getScripts(778).get(Path.of("b.js")), nullValue()); + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + private static void awaitCondition(Condition condition) throws InterruptedException { assertTrue("Timed out waiting for condition", awaitCondition(condition, AWAIT_TIMEOUT_MS)); } From 926fd6503bf8d3bb9279e52dacc1ccf193297078 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:59:11 +0000 Subject: [PATCH 20/59] Notice all externally created script files Files created outside of the game did not always show up until another file changed. Every reload cancelled and re-registered the directory watchers, losing events that happened in between, and only directories that already contained scripts were watched, so files in new, empty or intermediate directories were missed. Watch all directories of a disk before reading its files, keep existing watchers registered, and clean up watchers of deleted directories. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../core/network/ScriptingData.java | 63 ++++++++++--------- .../core/network/ScriptingDataTest.java | 60 ++++++++++++++++++ 2 files changed, 95 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java index ece96edc9..eea57b951 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java +++ b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java @@ -17,6 +17,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.ClosedWatchServiceException; @@ -40,6 +41,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * @author rubensworks @@ -54,8 +56,9 @@ public class ScriptingData implements IScriptingData { private final Path rootPath; private final Set> dirtyPaths = Sets.newHashSet(); private final Int2ObjectMap> scriptChangeListeners = new Int2ObjectAVLTreeMap<>(); - private final Map pathWatchers = Maps.newHashMap(); - private final Map pathWatchersReverse = Maps.newHashMap(); + // Accessed by both the server thread and the file watcher thread + private final Map pathWatchers = new ConcurrentHashMap<>(); + private final Map pathWatchersReverse = new ConcurrentHashMap<>(); private boolean initialized = false; private WatchService watchService; @@ -145,6 +148,9 @@ public void initialize() { // Synchronized, as this is also called from the file watcher thread protected synchronized void initializeDiskScripts(int diskId) { + // Watch all directories before reading files, so files created during reading still trigger a reload + this.registerDiskWatchers(diskId); + Map scriptsDisk = Maps.newHashMap(); Path diskDir = getDiskPath(diskId); Iterator filesIt = FileUtils.iterateFilesAndDirs(diskDir.toFile(), FileFilterUtils.trueFileFilter(), FileFilterUtils.trueFileFilter()); @@ -210,6 +216,13 @@ protected void watchFiles() { } } poll = key.reset(); + if (!poll) { + // The directory was deleted + Path path = pathWatchersReverse.remove(key); + if (path != null) { + pathWatchers.remove(path); + } + } Thread.yield(); } } catch (InterruptedException e) { @@ -273,18 +286,8 @@ public synchronized void setScripts(int disk, Map scripts, ChangeL } } - // Unregister old watchers - if (oldScripts != null) { - for (Path path : oldScripts.keySet()) { - this.unregisterPathWatcher(disk, path.getParent()); - } - } - - // Register watchers for all directories - this.registerPathWatcher(disk, null); - for (Path path : scripts.keySet()) { - this.registerPathWatcher(disk, path.getParent()); - } + // Watchers are not re-registered, as events that happen in between would be lost + this.registerDiskWatchers(disk); } // Invoke listeners @@ -340,7 +343,7 @@ public synchronized void setScript(int disk, Path scriptPathRelative, @Nullable } if (changeLocation == ChangeLocation.DISK) { // Register watcher - this.registerPathWatcher(disk, scriptPathRelative.getParent()); + this.registerDiskWatchers(disk); } // Invoke listeners @@ -352,6 +355,21 @@ public synchronized void setScript(int disk, Path scriptPathRelative, @Nullable } } + /** + * Watch all directories of a disk, including empty ones, so that new files in any of them are noticed. + */ + protected void registerDiskWatchers(int diskId) { + Path diskPath = getDiskPath(diskId); + if (!Files.isDirectory(diskPath)) { + return; + } + try (Stream paths = Files.walk(diskPath)) { + paths.filter(Files::isDirectory).forEach(this::registerAbsolutePathWatcher); + } catch (IOException | UncheckedIOException e) { + // A directory may have been deleted while walking, which will trigger another reload + } + } + protected void registerPathWatcher(int diskId, @Nullable Path pathRelative) { Path diskPath = getDiskPath(diskId); Path pathAbsolute = pathRelative == null ? diskPath : diskPath.resolve(pathRelative); @@ -373,17 +391,6 @@ protected void registerAbsolutePathWatcher(Path pathAbsolute) { } } - protected void unregisterPathWatcher(int diskId, @Nullable Path pathRelative) { - Path diskPath = getDiskPath(diskId); - Path pathAbsolute = pathRelative == null ? diskPath : diskPath.resolve(pathRelative); - WatchKey watchKey = pathWatchers.get(pathAbsolute); - if (watchKey != null) { - pathWatchers.remove(pathAbsolute); - pathWatchersReverse.remove(watchKey); - watchKey.cancel(); - } - } - @Override public void markDirty(int disk, Path scriptPathRelative) { dirtyPaths.add(Pair.of(disk, scriptPathRelative)); @@ -454,8 +461,8 @@ protected synchronized void flushScript(int disk, Path scriptPathRelative) { scriptPathAbsolute.getParent().toFile().mkdirs(); writeAtomically(scriptPathAbsolute, script); known.put(scriptPathRelative, script); - this.registerPathWatcher(disk, null); - this.registerPathWatcher(disk, scriptPathRelative.getParent()); + // Also watch intermediate directories that were created + this.registerDiskWatchers(disk); } } catch (IOException e) { e.printStackTrace(); diff --git a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java index 3d40ac31c..5ab3cf714 100644 --- a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java +++ b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java @@ -1,5 +1,6 @@ package org.cyclops.integratedscripting.core.network; +import com.google.common.collect.Sets; import org.apache.commons.io.FileUtils; import org.cyclops.integratedscripting.api.network.IScriptingData; import org.junit.Test; @@ -270,6 +271,65 @@ public void testDiskReloadAppliesExternalChanges() throws IOException { } } + @Test + public void testExternalFilesCreatedAfterDeletionsSynced() throws IOException, InterruptedException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + Path diskPath = rootPath.resolve("scripting-disks").resolve("901"); + Files.createDirectories(diskPath); + for (int i = 0; i < 4; i++) { + Files.writeString(diskPath.resolve("old" + i + ".js"), "old"); + } + scriptingData.initialize(); + + // Delete and create files in quick succession, as a file manager or script would + for (int i = 0; i < 4; i++) { + Files.delete(diskPath.resolve("old" + i + ".js")); + } + for (int i = 0; i < 4; i++) { + Files.writeString(diskPath.resolve("new" + i + ".js"), "new"); + } + + awaitCondition(() -> scriptingData.getScripts(901).keySet().equals(Sets.newHashSet( + Path.of("new0.js"), Path.of("new1.js"), Path.of("new2.js"), Path.of("new3.js")))); + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + + @Test + public void testExternalFilesInNewSubdirectoriesSynced() throws IOException, InterruptedException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + Path diskPath = rootPath.resolve("scripting-disks").resolve("902"); + Files.createDirectories(diskPath); + Files.writeString(diskPath.resolve("main.js"), "main"); + scriptingData.initialize(); + + // A file in a new nested directory + Files.createDirectories(diskPath.resolve("lib").resolve("helpers")); + Files.writeString(diskPath.resolve("lib").resolve("helpers").resolve("a.js"), "a"); + awaitCondition(() -> "a".equals(scriptingData.getScripts(902).get(Path.of("lib/helpers/a.js")))); + + // A file in the intermediate directory, which contains no scripts yet + Files.writeString(diskPath.resolve("lib").resolve("b.js"), "b"); + awaitCondition(() -> "b".equals(scriptingData.getScripts(902).get(Path.of("lib/b.js")))); + + // A file in an empty directory that already existed + Files.createDirectories(diskPath.resolve("empty")); + Files.writeString(diskPath.resolve("main.js"), "main2"); + awaitCondition(() -> "main2".equals(scriptingData.getScripts(902).get(Path.of("main.js")))); + Files.writeString(diskPath.resolve("empty").resolve("c.js"), "c"); + awaitCondition(() -> "c".equals(scriptingData.getScripts(902).get(Path.of("empty/c.js")))); + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + private static void awaitCondition(Condition condition) throws InterruptedException { assertTrue("Timed out waiting for condition", awaitCondition(condition, AWAIT_TIMEOUT_MS)); } From fb034c8927a3d1b84fc4c922563147c5960dc6b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:16:30 +0000 Subject: [PATCH 21/59] Indent with tab in the script editor Tab did nothing in the editor, so indenting code meant typing spaces one by one. Make tab insert an indent, or indent all selected lines, and shift+tab remove one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 104 ++++++++++++++++++ .../gui/component/input/WidgetTextArea.java | 3 + .../gui/component/input/TextEditingTest.java | 63 +++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java create mode 100644 src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java new file mode 100644 index 000000000..19aaead1d --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -0,0 +1,104 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +/** + * Code editing operations on the state of a text area. + * @author rubensworks + */ +public class TextEditing { + + public static final String INDENT = " "; + + private static int start(TextHistory.State state) { + return Math.min(state.cursorPos(), state.selectionPos()); + } + + private static int end(TextHistory.State state) { + return Math.max(state.cursorPos(), state.selectionPos()); + } + + public static int lineStart(String value, int pos) { + return value.lastIndexOf('\n', pos - 1) + 1; + } + + public static int lineEnd(String value, int pos) { + int i = value.indexOf('\n', pos); + return i < 0 ? value.length() : i; + } + + private static boolean isMultiLineSelection(TextHistory.State state) { + return state.value().substring(start(state), end(state)).indexOf('\n') >= 0; + } + + /** + * Indent the selected lines, or insert an indent at the cursor if no lines are selected. + */ + public static TextHistory.State indent(TextHistory.State state) { + String value = state.value(); + if (!isMultiLineSelection(state)) { + int start = start(state); + String newValue = value.substring(0, start) + INDENT + value.substring(end(state)); + return new TextHistory.State(newValue, start + INDENT.length(), start + INDENT.length()); + } + return mapLines(state, line -> INDENT + line); + } + + /** + * Remove one indent from the start of the lines touched by the cursor or selection. + */ + public static TextHistory.State unindent(TextHistory.State state) { + return mapLines(state, line -> { + int remove = 0; + while (remove < INDENT.length() && remove < line.length() && line.charAt(remove) == ' ') { + remove++; + } + if (remove == 0 && !line.isEmpty() && line.charAt(0) == '\t') { + remove = 1; + } + return line.substring(remove); + }); + } + + /** + * Apply a function to each line touched by the cursor or selection, + * keeping the cursor and selection on the same characters. + */ + private static TextHistory.State mapLines(TextHistory.State state, java.util.function.UnaryOperator mapper) { + String value = state.value(); + int from = lineStart(value, start(state)); + // A selection ending at the start of a line doesn't include that line + int endPos = end(state) > start(state) && value.charAt(end(state) - 1) == '\n' ? end(state) - 1 : end(state); + int to = lineEnd(value, endPos); + String[] lines = value.substring(from, to).split("\n", -1); + String[] newLines = new String[lines.length]; + for (int i = 0; i < lines.length; i++) { + newLines[i] = mapper.apply(lines[i]); + } + String newValue = value.substring(0, from) + String.join("\n", newLines) + value.substring(to); + return new TextHistory.State(newValue, + mapPosition(state.cursorPos(), from, lines, newLines), + mapPosition(state.selectionPos(), from, lines, newLines)); + } + + /** + * Map a position from before to after changing lines, where each line only changed at its start. + */ + private static int mapPosition(int pos, int from, String[] lines, String[] newLines) { + if (pos < from) { + return pos; + } + int lineStart = from; + int delta = 0; + for (int i = 0; i < lines.length; i++) { + int lineDelta = newLines[i].length() - lines[i].length(); + int lineEnd = lineStart + lines[i].length(); + if (pos <= lineEnd || i == lines.length - 1) { + // Positions inside a removed prefix move to the start of the line + return Math.max(lineStart + delta, pos + delta + lineDelta); + } + delta += lineDelta; + lineStart = lineEnd + 1; + } + return pos + delta; + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 86da7a15c..1e4723a4a 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -423,6 +423,9 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { case 335: this.textFieldHelper.insertText("\n"); return true; + case GLFW.GLFW_KEY_TAB: + this.setState((p_98155_ & GLFW.GLFW_MOD_SHIFT) != 0 ? TextEditing.unindent(this.getState()) : TextEditing.indent(this.getState())); + return true; case 259: this.textFieldHelper.removeFromCursor(-1, cursorStep); return true; diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java new file mode 100644 index 000000000..f798813f6 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -0,0 +1,63 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +public class TextEditingTest { + + private static TextHistory.State s(String value, int cursor) { + return new TextHistory.State(value, cursor, cursor); + } + + private static TextHistory.State s(String value, int cursor, int selection) { + return new TextHistory.State(value, cursor, selection); + } + + @Test + public void testIndentAtCursor() { + assertThat(TextEditing.indent(s("ab", 1)), equalTo(s("a b", 5))); + } + + @Test + public void testIndentReplacesSingleLineSelection() { + assertThat(TextEditing.indent(s("abc", 1, 2)), equalTo(s("a c", 5))); + } + + @Test + public void testIndentMultipleLines() { + assertThat(TextEditing.indent(s("a\nb\nc", 0, 3)), equalTo(s(" a\n b\nc", 4, 11))); + } + + @Test + public void testIndentMultipleLinesSelectionEndingAtLineStart() { + assertThat(TextEditing.indent(s("a\nb\nc", 0, 4)), equalTo(s(" a\n b\nc", 4, 12))); + } + + @Test + public void testUnindentCurrentLine() { + assertThat(TextEditing.unindent(s("a\n b", 9)), equalTo(s("a\n b", 5))); + } + + @Test + public void testUnindentCursorInsideIndent() { + assertThat(TextEditing.unindent(s(" b", 2)), equalTo(s("b", 0))); + } + + @Test + public void testUnindentPartialIndent() { + assertThat(TextEditing.unindent(s(" b", 3)), equalTo(s("b", 1))); + } + + @Test + public void testUnindentNoIndent() { + assertThat(TextEditing.unindent(s("b", 1)), equalTo(s("b", 1))); + } + + @Test + public void testUnindentMultipleLines() { + assertThat(TextEditing.unindent(s(" a\n b\nc", 0, 11)), equalTo(s("a\nb\nc", 0, 5))); + } + +} From f99fae63b890b9b8190bda627a8fbea0f1e5896f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:22:58 +0000 Subject: [PATCH 22/59] Keep indentation on enter in the script editor A new line always started at the first column, so the indentation had to be retyped on every line. Keep the indentation of the current line, and indent one more level after an opening bracket. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 41 +++++++++++++++++++ .../gui/component/input/WidgetTextArea.java | 2 +- .../gui/component/input/TextEditingTest.java | 30 ++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java index 19aaead1d..af68e2de6 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -25,6 +25,14 @@ public static int lineEnd(String value, int pos) { return i < 0 ? value.length() : i; } + private static String leadingWhitespace(String value, int lineStart) { + int i = lineStart; + while (i < value.length() && (value.charAt(i) == ' ' || value.charAt(i) == '\t')) { + i++; + } + return value.substring(lineStart, i); + } + private static boolean isMultiLineSelection(TextHistory.State state) { return state.value().substring(start(state), end(state)).indexOf('\n') >= 0; } @@ -101,4 +109,37 @@ private static int mapPosition(int pos, int from, String[] lines, String[] newLi return pos + delta; } + /** + * Insert a newline that keeps the indentation of the current line, + * and indents one more level after an opening bracket. + */ + public static TextHistory.State newline(TextHistory.State state) { + String value = state.value(); + int start = start(state); + int end = end(state); + int lineStart = lineStart(value, start); + String indent = leadingWhitespace(value, lineStart); + indent = indent.substring(0, Math.min(indent.length(), start - lineStart)); + String before = value.substring(lineStart, start).stripTrailing(); + char open = before.isEmpty() ? 0 : before.charAt(before.length() - 1); + boolean opens = open == '{' || open == '(' || open == '['; + String insert = "\n" + indent + (opens ? INDENT : ""); + int cursor = start + insert.length(); + // Put a directly following closing bracket on its own line + if (opens && end < value.length() && value.charAt(end) == closing(open)) { + insert += "\n" + indent; + } + String newValue = value.substring(0, start) + insert + value.substring(end); + return new TextHistory.State(newValue, cursor, cursor); + } + + private static char closing(char open) { + return switch (open) { + case '{' -> '}'; + case '(' -> ')'; + case '[' -> ']'; + default -> 0; + }; + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 1e4723a4a..069647711 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -421,7 +421,7 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { switch (p_98153_) { case 257: case 335: - this.textFieldHelper.insertText("\n"); + this.setState(TextEditing.newline(this.getState())); return true; case GLFW.GLFW_KEY_TAB: this.setState((p_98155_ & GLFW.GLFW_MOD_SHIFT) != 0 ? TextEditing.unindent(this.getState()) : TextEditing.indent(this.getState())); diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java index f798813f6..932f3e6d8 100644 --- a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -60,4 +60,34 @@ public void testUnindentMultipleLines() { assertThat(TextEditing.unindent(s(" a\n b\nc", 0, 11)), equalTo(s("a\nb\nc", 0, 5))); } + @Test + public void testNewlineKeepsIndent() { + assertThat(TextEditing.newline(s(" a;", 6)), equalTo(s(" a;\n ", 11))); + } + + @Test + public void testNewlineIndentsAfterOpeningBracket() { + assertThat(TextEditing.newline(s(" if (a) {", 10)), equalTo(s(" if (a) {\n ", 17))); + } + + @Test + public void testNewlineIndentsAfterOpeningBracketWithTrailingSpace() { + assertThat(TextEditing.newline(s("f(", 2)), equalTo(s("f(\n ", 7))); + } + + @Test + public void testNewlineSplitsBracketPair() { + assertThat(TextEditing.newline(s(" {}", 3)), equalTo(s(" {\n \n }", 10))); + } + + @Test + public void testNewlineReplacesSelection() { + assertThat(TextEditing.newline(s("abc", 1, 2)), equalTo(s("a\nc", 2))); + } + + @Test + public void testNewlineInMiddleOfIndent() { + assertThat(TextEditing.newline(s(" a", 2)), equalTo(s(" \n a", 5))); + } + } From ddb91eb3bed271a53248eeaa831460c737f11f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:22:58 +0000 Subject: [PATCH 23/59] Highlight matching brackets in the script editor In the small editor it was hard to see which bracket closes which block. Highlight the bracket next to the cursor and its matching bracket. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 43 +++++++++++++++++++ .../gui/component/input/WidgetTextArea.java | 23 ++++++++++ .../gui/component/input/TextEditingTest.java | 26 +++++++++++ 3 files changed, 92 insertions(+) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java index af68e2de6..ebf251bd8 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -142,4 +142,47 @@ private static char closing(char open) { }; } + private static char opening(char close) { + return switch (close) { + case '}' -> '{'; + case ')' -> '('; + case ']' -> '['; + default -> 0; + }; + } + + /** + * Find the bracket matching the one right after or right before the cursor. + * @return A pair of bracket positions, or null if there is no matching bracket. + */ + public static int[] findMatchingBrackets(String value, int cursorPos) { + for (int pos : new int[]{cursorPos, cursorPos - 1}) { + if (pos < 0 || pos >= value.length()) { + continue; + } + char c = value.charAt(pos); + if (closing(c) != 0) { + int match = scan(value, pos, c, closing(c), 1); + return match < 0 ? null : new int[]{pos, match}; + } else if (opening(c) != 0) { + int match = scan(value, pos, c, opening(c), -1); + return match < 0 ? null : new int[]{match, pos}; + } + } + return null; + } + + private static int scan(String value, int from, char self, char other, int step) { + int depth = 0; + for (int i = from; i >= 0 && i < value.length(); i += step) { + char c = value.charAt(i); + if (c == self) { + depth++; + } else if (c == other && --depth == 0) { + return i; + } + } + return -1; + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 069647711..0ba22104b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -551,6 +551,16 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float List lines = Arrays.asList(displayCache.lines); int offsetY = 0; + // Highlight the bracket pair around the cursor + if (this.isFocused()) { + int[] brackets = TextEditing.findMatchingBrackets(this.getValue(), this.textFieldHelper.getCursorPos()); + if (brackets != null) { + for (int bracket : brackets) { + this.renderCharBackground(guiGraphics, displayCache, bracket, Helpers.RGBAToInt(160, 200, 255, 255)); + } + } + } + // Draw lines int lastLineNumber = -1; for(LineInfo line : lines) { @@ -584,6 +594,19 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float } } + private void renderCharBackground(GuiGraphics guiGraphics, DisplayCache displayCache, int pos, int color) { + int line = findLineFromPos(displayCache.lineStarts, pos); + int row = line - this.firstRow; + if (row < 0 || row >= this.getHeight() / ROW_HEIGHT) { + return; + } + String value = this.getValue(); + int x = this.font.width(value.substring(displayCache.lineStarts[line], pos)); + int width = this.font.width(value.substring(pos, pos + 1)); + Pos2i screenPos = this.convertLocalToScreen(new Pos2i(x, row * ROW_HEIGHT)); + guiGraphics.fill(screenPos.x, screenPos.y - 1, screenPos.x + width, screenPos.y + ROW_HEIGHT - 1, color); + } + private void renderCursor(GuiGraphics guiGraphics, Pos2i pos, boolean cursorAtEnd) { if (this.isFocused() && this.frameTick / 6 % 2 == 0) { pos = this.convertLocalToScreen(pos); diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java index 932f3e6d8..19b651dd9 100644 --- a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -3,6 +3,7 @@ import org.junit.Test; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; import static org.junit.Assert.assertThat; public class TextEditingTest { @@ -90,4 +91,29 @@ public void testNewlineInMiddleOfIndent() { assertThat(TextEditing.newline(s(" a", 2)), equalTo(s(" \n a", 5))); } + @Test + public void testMatchingBracketAfterCursor() { + assertThat(TextEditing.findMatchingBrackets("f(a(b))", 1), equalTo(new int[]{1, 6})); + } + + @Test + public void testMatchingBracketBeforeCursor() { + assertThat(TextEditing.findMatchingBrackets("f(a(b))", 7), equalTo(new int[]{1, 6})); + } + + @Test + public void testMatchingBracketNested() { + assertThat(TextEditing.findMatchingBrackets("{[()]}", 1), equalTo(new int[]{1, 4})); + } + + @Test + public void testMatchingBracketUnbalanced() { + assertThat(TextEditing.findMatchingBrackets("((a)", 0), nullValue()); + } + + @Test + public void testMatchingBracketNone() { + assertThat(TextEditing.findMatchingBrackets("abc", 1), nullValue()); + } + } From 6e95b6a8f68ecda549eff12ae1c8ebe81ae79482 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:33:11 +0000 Subject: [PATCH 24/59] Add find and go to line to the script editor Finding something in a script meant scrolling through it in the small editor. Ctrl+F opens a search bar below the editor to find text, and Ctrl+G opens it to go to a line number, for example one from an error message. The bar shows the current match and the number of matches, has previous and next buttons (also Shift+Enter and Enter), highlights all matches, and turns red when nothing matches. Escape closes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 66 +++++++ .../gui/component/input/WidgetSearchBar.java | 168 ++++++++++++++++++ .../gui/component/input/WidgetTextArea.java | 119 +++++++++++-- .../ContainerScreenTerminalScripting.java | 130 ++++++++++++++ .../integratedscripting/lang/en_us.json | 7 + .../gui/component/input/TextEditingTest.java | 45 +++++ 6 files changed, 522 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetSearchBar.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java index ebf251bd8..30c4a318f 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -185,4 +185,70 @@ private static int scan(String value, int from, char self, char other, int step) return -1; } + /** + * Find the next case-insensitive occurrence of a query, wrapping around at the end. + * @return The start position of the occurrence, or -1 if there is none. + */ + public static int findNext(String value, String query, int from) { + if (query.isEmpty()) { + return -1; + } + String haystack = value.toLowerCase(java.util.Locale.ROOT); + String needle = query.toLowerCase(java.util.Locale.ROOT); + int i = haystack.indexOf(needle, Math.max(0, Math.min(from, haystack.length()))); + return i >= 0 ? i : haystack.indexOf(needle); + } + + /** + * Find all non-overlapping case-insensitive occurrences of a query. + * @return The start positions of all occurrences. + */ + public static int[] findAll(String value, String query) { + if (query.isEmpty()) { + return new int[0]; + } + String haystack = value.toLowerCase(java.util.Locale.ROOT); + String needle = query.toLowerCase(java.util.Locale.ROOT); + it.unimi.dsi.fastutil.ints.IntList positions = new it.unimi.dsi.fastutil.ints.IntArrayList(); + int i = haystack.indexOf(needle); + while (i >= 0) { + positions.add(i); + i = haystack.indexOf(needle, i + needle.length()); + } + return positions.toIntArray(); + } + + /** + * Find the last case-insensitive occurrence of a query that starts before a position, wrapping around at the start. + * @return The start position of the occurrence, or -1 if there is none. + */ + public static int findPrevious(String value, String query, int before) { + int[] positions = findAll(value, query); + if (positions.length == 0) { + return -1; + } + for (int i = positions.length - 1; i >= 0; i--) { + if (positions[i] < before) { + return positions[i]; + } + } + return positions[positions.length - 1]; + } + + /** + * @param line A 1-based line number, which is clamped to the existing lines. + * @return The position of the start of the line. + */ + public static int lineOffset(String value, int line) { + int pos = 0; + for (int i = 1; i < line; i++) { + int next = value.indexOf('\n', pos); + if (next < 0) { + break; + } + pos = next + 1; + } + return pos; + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetSearchBar.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetSearchBar.java new file mode 100644 index 000000000..ca5388e7e --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetSearchBar.java @@ -0,0 +1,168 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; +import org.cyclops.cyclopscore.client.gui.component.button.ButtonText; +import org.cyclops.cyclopscore.helper.Helpers; +import org.lwjgl.glfw.GLFW; + +import java.util.function.Consumer; +import java.util.function.IntConsumer; + +/** + * A bar below a text area to find text or go to a line. + * @author rubensworks + */ +public class WidgetSearchBar extends AbstractWidget { + + public static final int HEIGHT = 12; + private static final int BUTTON_WIDTH = 10; + private static final int COLOR_TEXT = Helpers.RGBToInt(224, 224, 224); + private static final int COLOR_ERROR = Helpers.RGBToInt(255, 85, 85); + + private final Font font; + private final boolean lineMode; + private final Component label; + private final EditBox input; + private final ButtonText buttonPrevious; + private final ButtonText buttonNext; + private final ButtonText buttonClose; + private final IntConsumer navigateCallback; + private final Runnable closeCallback; + private String status = ""; + private boolean error; + + /** + * @param lineMode If a line number is entered instead of a text to find. + * @param changeCallback Called when the input changes. + * @param navigateCallback Called with 1 to go to the next match (or the line), and -1 to go to the previous match. + * @param closeCallback Called when the bar should be closed. + */ + public WidgetSearchBar(Font font, int x, int y, int width, boolean lineMode, String initialValue, + Consumer changeCallback, IntConsumer navigateCallback, Runnable closeCallback) { + super(x, y, width, HEIGHT, Component.translatable(lineMode ? "gui.integratedscripting.goto_line" : "gui.integratedscripting.find")); + this.font = font; + this.lineMode = lineMode; + this.label = Component.translatable(lineMode ? "gui.integratedscripting.goto_line.label" : "gui.integratedscripting.find.label"); + this.navigateCallback = navigateCallback; + this.closeCallback = closeCallback; + + // Layout from right to left: close, next, previous, status + int right = x + width - 1; + this.buttonClose = createButton(right - BUTTON_WIDTH, "x", "gui.integratedscripting.find.close", closeCallback::run); + right -= BUTTON_WIDTH + 1; + this.buttonNext = createButton(right - BUTTON_WIDTH, ">", "gui.integratedscripting.find.next", () -> navigateCallback.accept(1)); + right -= BUTTON_WIDTH + 1; + this.buttonPrevious = createButton(right - BUTTON_WIDTH, "<", "gui.integratedscripting.find.previous", () -> navigateCallback.accept(-1)); + if (!lineMode) { + right -= BUTTON_WIDTH + 1 + font.width("000/000") + 2; + } else { + right = x + width - 1 - BUTTON_WIDTH - 2; + } + + int inputX = x + 3 + font.width(this.label) + 3; + this.input = new EditBox(font, inputX, y + 1, Math.max(10, right - inputX), HEIGHT - 2, this.getMessage()); + this.input.setMaxLength(256); + this.input.setValue(initialValue); + this.input.setResponder(changeCallback); + this.input.setFocused(true); + } + + private ButtonText createButton(int x, String text, String tooltip, Runnable callback) { + ButtonText button = new ButtonText(x, this.getY() + 1, BUTTON_WIDTH, HEIGHT - 2, + Component.translatable(tooltip), Component.literal(text), (b) -> callback.run(), true); + button.setTooltip(Tooltip.create(Component.translatable(tooltip))); + return button; + } + + public boolean isLineMode() { + return lineMode; + } + + public String getValue() { + return this.input.getValue(); + } + + public void focusAndSelect(String value) { + if (!value.isEmpty()) { + this.input.setValue(value); + } + this.input.moveCursorToEnd(false); + this.input.setHighlightPos(0); + this.setFocused(true); + } + + /** + * @param status A short status text, such as the current match and number of matches. + * @param error If the input is shown as erroneous, for example when nothing is found. + */ + public void setStatus(String status, boolean error) { + this.status = status; + this.error = error; + this.input.setTextColor(error ? COLOR_ERROR : COLOR_TEXT); + } + + @Override + public void setFocused(boolean focused) { + super.setFocused(focused); + this.input.setFocused(focused); + } + + @Override + protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + guiGraphics.fill(this.getX(), this.getY(), this.getX() + this.width, this.getY() + this.height, Helpers.RGBAToInt(60, 60, 60, 255)); + guiGraphics.drawString(this.font, this.label, this.getX() + 3, this.getY() + 2, COLOR_TEXT, false); + this.input.render(guiGraphics, mouseX, mouseY, partialTicks); + if (!this.lineMode) { + int statusRight = this.buttonPrevious.getX() - 2; + guiGraphics.drawString(this.font, this.status, statusRight - this.font.width(this.status), this.getY() + 2, + this.error ? COLOR_ERROR : COLOR_TEXT, false); + this.buttonPrevious.render(guiGraphics, mouseX, mouseY, partialTicks); + this.buttonNext.render(guiGraphics, mouseX, mouseY, partialTicks); + } + this.buttonClose.render(guiGraphics, mouseX, mouseY, partialTicks); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { + if (!this.isMouseOver(mouseX, mouseY)) { + return false; + } + if (this.buttonClose.mouseClicked(mouseX, mouseY, mouseButton) + || (!this.lineMode && (this.buttonPrevious.mouseClicked(mouseX, mouseY, mouseButton) + || this.buttonNext.mouseClicked(mouseX, mouseY, mouseButton)))) { + return true; + } + this.input.mouseClicked(mouseX, mouseY, mouseButton); + return true; + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + if (key == GLFW.GLFW_KEY_ESCAPE) { + this.closeCallback.run(); + } else if (key == GLFW.GLFW_KEY_ENTER || key == GLFW.GLFW_KEY_KP_ENTER) { + this.navigateCallback.accept((modifiers & GLFW.GLFW_MOD_SHIFT) != 0 ? -1 : 1); + } else { + this.input.keyPressed(key, scanCode, modifiers); + } + // Don't let typing trigger screen actions, such as closing the screen + return true; + } + + @Override + public boolean charTyped(char c, int modifiers) { + return this.input.charTyped(c, modifiers); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { + narrationElementOutput.add(NarratedElementType.TITLE, this.getMessage()); + } +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 0ba22104b..f481e6358 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -45,6 +45,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.IntPredicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -92,6 +93,15 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private WidgetScrollBar scrollBar; private int firstRow; private TextHistory history = new TextHistory(); + @Nullable + private String searchQuery; + @Nullable + private String searchMatchesValue; + @Nullable + private String searchMatchesQuery; + private int[] searchMatches = new int[0]; + @Nullable + private IntPredicate shortcutListener; public WidgetTextArea(Font font, int x, int y, int width, int height, Component narrationMessage, boolean scrollBar, boolean showLineNumbers) { super(font, x, y, width, height, narrationMessage); @@ -99,22 +109,44 @@ public WidgetTextArea(Font font, int x, int y, int width, int height, Component this.showLineNumbers = showLineNumbers; this.textFieldHelper = new TextFieldHelperExtended(this::getValue, this::setValuePassive, this::getClipboard, this::setClipboard, s -> true, this::setSelected, this::onCursorPosChanged); if (scrollBar) { - this.scrollBar = new WidgetScrollBar(x + width - SCROLLBAR_WIDTH, y, height, - Component.translatable("gui.cyclopscore.scrollbar"), firstRow -> setFirstRow(firstRow, false), height / ROW_HEIGHT) { - @Override - public int getTotalRows() { - return getDisplayCache().linesTotal; - } + this.scrollBar = this.createScrollBar(); + } + } - @Override - public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { - // Only show scrollbar if needed - if (this.needsScrollBars()) { - super.renderWidget(guiGraphics, mouseX, mouseY, partialTicks); - } + private WidgetScrollBar createScrollBar() { + int x = this.getX(); + int y = this.getY(); + int width = this.getWidth(); + int height = this.getHeight(); + return new WidgetScrollBar(x + width - SCROLLBAR_WIDTH, y, height, + Component.translatable("gui.cyclopscore.scrollbar"), firstRow -> setFirstRow(firstRow, false), height / ROW_HEIGHT) { + @Override + public int getTotalRows() { + return getDisplayCache().linesTotal; + } + + @Override + public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + // Only show scrollbar if needed + if (this.needsScrollBars()) { + super.renderWidget(guiGraphics, mouseX, mouseY, partialTicks); } - }; + } + }; + } + + /** + * Change the height, for example to make room for other widgets, while keeping the scroll and cursor position. + */ + @Override + public void setHeight(int height) { + super.setHeight(height); + if (this.scrollBar != null) { + this.scrollBar = this.createScrollBar(); + this.setFirstRow(this.firstRow, true); } + this.clearDisplayCache(); + this.ensureCursorVisible(); } private DisplayCache createEmptyDisplayCache() { @@ -153,6 +185,47 @@ public void setListenerCursor(@Nullable IInputListener listenerCursor) { this.listenerCursor = listenerCursor; } + /** + * @param shortcutListener Called with the key of ctrl-shortcuts, returns true if it handled the shortcut. + */ + public void setShortcutListener(@Nullable IntPredicate shortcutListener) { + this.shortcutListener = shortcutListener; + } + + /** + * Select a range and scroll to it. + * @param start The start position, where the selection is anchored. + * @param end The end position, where the cursor is placed. + */ + public void selectRange(int start, int end) { + this.textFieldHelper.setSelectionRange(end, start); + this.clearDisplayCache(); + this.ensureCursorVisible(); + } + + /** + * @param searchQuery A query of which all occurrences are highlighted, or null. + */ + public void setSearchQuery(@Nullable String searchQuery) { + this.searchQuery = searchQuery == null || searchQuery.isEmpty() ? null : searchQuery; + } + + /** + * @return The start positions of all occurrences of the search query. + */ + public int[] getSearchMatches() { + if (this.searchQuery == null) { + return new int[0]; + } + String value = this.getValue(); + if (!value.equals(this.searchMatchesValue) || !this.searchQuery.equals(this.searchMatchesQuery)) { + this.searchMatches = TextEditing.findAll(value, this.searchQuery); + this.searchMatchesValue = value; + this.searchMatchesQuery = this.searchQuery; + } + return this.searchMatches; + } + public void setHistory(TextHistory history) { this.history = history; } @@ -316,6 +389,9 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { + if (hasControlDown(modifiers) && this.shortcutListener != null && this.shortcutListener.test(typedChar)) { + return true; + } TextHistory.State before = this.getState(); boolean history = this.historyKeyPressed(typedChar, modifiers); boolean flag = history || this.textFieldKeyPressed(typedChar, keyCode, modifiers); @@ -551,6 +627,23 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float List lines = Arrays.asList(displayCache.lines); int offsetY = 0; + // Highlight all visible search matches + if (this.searchQuery != null) { + String value = this.getValue(); + int visibleStart = displayCache.lineStarts[Math.min(this.firstRow, displayCache.lineStarts.length - 1)]; + int visibleEnd = this.firstRow + this.getHeight() / ROW_HEIGHT < displayCache.lineStarts.length ? displayCache.lineStarts[this.firstRow + this.getHeight() / ROW_HEIGHT] : value.length(); + for (int match : this.getSearchMatches()) { + if (match > visibleEnd) { + break; + } + for (int pos = Math.max(match, visibleStart); pos < Math.min(match + this.searchQuery.length(), visibleEnd); pos++) { + if (value.charAt(pos) != '\n') { + this.renderCharBackground(guiGraphics, displayCache, pos, Helpers.RGBAToInt(255, 225, 120, 255)); + } + } + } + } + // Highlight the bracket pair around the cursor if (this.isFocused()) { int[] brackets = TextEditing.findMatchingBrackets(this.getValue(), this.textFieldHelper.getCursorPos()); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 84eef1b32..b9ad8bd94 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -30,8 +30,10 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.client.gui.component.input.TextEditing; import org.cyclops.integratedscripting.client.gui.component.input.TextHistory; import org.cyclops.integratedscripting.client.gui.component.input.WidgetDialog; +import org.cyclops.integratedscripting.client.gui.component.input.WidgetSearchBar; import org.cyclops.integratedscripting.client.gui.component.input.WidgetTextArea; import org.cyclops.integratedscripting.client.gui.image.ScriptImages; import org.cyclops.integratedscripting.core.language.LanguageHandlers; @@ -76,6 +78,8 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended { + if (key == GLFW.GLFW_KEY_F || key == GLFW.GLFW_KEY_G) { + openNavigate(key == GLFW.GLFW_KEY_G); + return true; + } + return false; + }); getMenu().setRemoteScriptChangeListener(this::onRemoteScriptChanged); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); @@ -153,6 +165,8 @@ public int getTotalRows() { protected void containerTick() { super.containerTick(); textArea.tick(); + // The matches change when the script is edited while searching + this.updateSearchStatus(); if (this.lastClientSyncTick == 0) { // Send modified scripts from client to server @@ -577,6 +591,15 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return false; } + if (this.searchBar != null && this.searchBar.isFocused()) { + return this.searchBar.keyPressed(typedChar, keyCode, modifiers); + } + // Close the search bar before closing the screen + if (this.searchBar != null && typedChar == GLFW.GLFW_KEY_ESCAPE) { + closeNavigate(); + return true; + } + if (textArea.isFocused()) { boolean ret = textArea.keyPressed(typedChar, keyCode, modifiers); if (typedChar != GLFW.GLFW_KEY_ESCAPE) { @@ -586,12 +609,119 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return super.keyPressed(typedChar, keyCode, modifiers); } + /** + * Show a bar below the editor to find text, or to go to a line number. + */ + private void openNavigate(boolean toLine) { + String selected = this.textArea.getSelected(); + String initialValue = !toLine && !selected.isEmpty() && selected.indexOf('\n') < 0 ? selected : ""; + if (this.searchBar != null && this.searchBar.isLineMode() == toLine) { + this.searchBar.focusAndSelect(initialValue); + } else { + closeNavigate(); + // The editor gives up the space for the bar + this.textArea.setHeight(this.textArea.getHeight() - WidgetSearchBar.HEIGHT); + this.searchBar = new WidgetSearchBar(font, this.textArea.getX(), this.textArea.getY() + this.textArea.getHeight(), this.textArea.getWidth(), + toLine, initialValue, this::onSearchChanged, this::navigate, this::closeNavigate); + addRenderableWidget(this.searchBar); + } + this.textArea.setFocused(false); + this.setFocused(this.searchBar); + this.onSearchChanged(this.searchBar.getValue()); + } + + private void closeNavigate() { + if (this.searchBar != null) { + removeWidget(this.searchBar); + this.searchBar = null; + this.textArea.setHeight(this.textArea.getHeight() + WidgetSearchBar.HEIGHT); + this.textArea.setSearchQuery(null); + this.setFocused(this.textArea); + this.textArea.setFocused(true); + } + } + + private void onSearchChanged(String query) { + if (this.searchBar.isLineMode()) { + this.searchBar.setStatus("", !query.isBlank() && parseLine(query) < 0); + return; + } + this.textArea.setSearchQuery(query); + if (!query.isEmpty()) { + // Stay on the current match while typing, or move to the next one + int pos = TextEditing.findNext(this.textArea.getValue(), query, Math.min(this.textArea.getCursorPos(), this.textArea.getSelectionPos())); + if (pos >= 0) { + this.textArea.selectRange(pos, pos + query.length()); + } + } + this.updateSearchStatus(); + } + + private void updateSearchStatus() { + if (this.searchBar == null || this.searchBar.isLineMode()) { + return; + } + String query = this.searchBar.getValue(); + if (query.isEmpty()) { + this.searchBar.setStatus("", false); + return; + } + int[] matches = this.textArea.getSearchMatches(); + int start = Math.min(this.textArea.getCursorPos(), this.textArea.getSelectionPos()); + int end = Math.max(this.textArea.getCursorPos(), this.textArea.getSelectionPos()); + int current = 0; + for (int i = 0; i < matches.length; i++) { + if (matches[i] == start && end - start == query.length()) { + current = i + 1; + } + } + this.searchBar.setStatus(current + "/" + matches.length, matches.length == 0); + } + + private static int parseLine(String value) { + try { + int line = Integer.parseInt(value.trim()); + return line > 0 ? line : -1; + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * @param direction 1 for the next match, -1 for the previous match. + */ + private void navigate(int direction) { + String query = this.searchBar.getValue(); + String value = this.textArea.getValue(); + if (this.searchBar.isLineMode()) { + int line = parseLine(query); + if (line < 0) { + this.searchBar.setStatus("", true); + return; + } + int pos = TextEditing.lineOffset(value, line); + closeNavigate(); + this.textArea.selectRange(pos, pos); + return; + } + int pos = direction > 0 + ? TextEditing.findNext(value, query, Math.max(this.textArea.getCursorPos(), this.textArea.getSelectionPos())) + : TextEditing.findPrevious(value, query, Math.min(this.textArea.getCursorPos(), this.textArea.getSelectionPos())); + if (pos >= 0) { + this.textArea.selectRange(pos, pos + query.length()); + } + this.updateSearchStatus(); + } + @Override public boolean charTyped(char p_94683_, int p_94684_) { // Make active dialog consume all input if (pendingScriptRemovalDialog != null) { return false; } + if (this.searchBar != null && this.searchBar.isFocused()) { + return this.searchBar.charTyped(p_94683_, p_94684_); + } return super.charTyped(p_94683_, p_94684_); } } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index e7b73c0a8..338c4ad2a 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -26,6 +26,13 @@ "gui.integratedscripting.disk": "Disk", "gui.integratedscripting.script": "Script", "gui.integratedscripting.create_file": "Create File", + "gui.integratedscripting.find": "Find", + "gui.integratedscripting.goto_line": "Go to line", + "gui.integratedscripting.find.label": "Find:", + "gui.integratedscripting.goto_line.label": "Line:", + "gui.integratedscripting.find.previous": "Previous match (Shift+Enter)", + "gui.integratedscripting.find.next": "Next match (Enter)", + "gui.integratedscripting.find.close": "Close (Escape)", "gui.integratedscripting.removal_dialog.title": "Confirm script deletion", "gui.integratedscripting.removal_dialog.message": "Do you want to delete \"%s\"?", "gui.integratedscripting.removal_dialog.delete": "Delete", diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java index 19b651dd9..9781cb344 100644 --- a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -116,4 +116,49 @@ public void testMatchingBracketNone() { assertThat(TextEditing.findMatchingBrackets("abc", 1), nullValue()); } + @Test + public void testFindNext() { + assertThat(TextEditing.findNext("abc abc", "abc", 1), equalTo(4)); + } + + @Test + public void testFindNextWraps() { + assertThat(TextEditing.findNext("abc abc", "abc", 5), equalTo(0)); + } + + @Test + public void testFindNextCaseInsensitive() { + assertThat(TextEditing.findNext("const Foo", "foo", 0), equalTo(6)); + } + + @Test + public void testFindNextMissing() { + assertThat(TextEditing.findNext("abc", "x", 0), equalTo(-1)); + assertThat(TextEditing.findNext("abc", "", 0), equalTo(-1)); + } + + @Test + public void testLineOffset() { + assertThat(TextEditing.lineOffset("a\nbc\nd", 1), equalTo(0)); + assertThat(TextEditing.lineOffset("a\nbc\nd", 3), equalTo(5)); + assertThat(TextEditing.lineOffset("a\nbc\nd", 10), equalTo(5)); + assertThat(TextEditing.lineOffset("a\nbc\nd", 0), equalTo(0)); + } + + @Test + public void testFindAll() { + assertThat(TextEditing.findAll("Item item ITEM", "item"), equalTo(new int[]{0, 5, 10})); + assertThat(TextEditing.findAll("aaaa", "aa"), equalTo(new int[]{0, 2})); + assertThat(TextEditing.findAll("abc", "x"), equalTo(new int[0])); + assertThat(TextEditing.findAll("abc", ""), equalTo(new int[0])); + } + + @Test + public void testFindPrevious() { + assertThat(TextEditing.findPrevious("abc abc abc", "abc", 8), equalTo(4)); + assertThat(TextEditing.findPrevious("abc abc abc", "abc", 4), equalTo(0)); + assertThat(TextEditing.findPrevious("abc abc abc", "abc", 0), equalTo(8)); + assertThat(TextEditing.findPrevious("abc", "x", 2), equalTo(-1)); + } + } From 37205c1bac6e5fa4baafd860c68f7e7a4a6aad5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:35:33 +0000 Subject: [PATCH 25/59] Add toggle comment and move lines to the script editor Commenting out or reordering multiple lines meant editing every line by hand. Add ctrl+/ to toggle line comments and alt+up/down to move the current or selected lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 58 ++++++++++++++++++- .../gui/component/input/WidgetTextArea.java | 8 ++- .../gui/component/input/TextEditingTest.java | 40 +++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java index 30c4a318f..9925f9199 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -16,6 +16,14 @@ private static int end(TextHistory.State state) { return Math.max(state.cursorPos(), state.selectionPos()); } + /** + * @return The end of the selection, excluding a line on which the selection ends at the start. + */ + private static int selectionEnd(TextHistory.State state) { + int end = end(state); + return end > start(state) && state.value().charAt(end - 1) == '\n' ? end - 1 : end; + } + public static int lineStart(String value, int pos) { return value.lastIndexOf('\n', pos - 1) + 1; } @@ -73,9 +81,7 @@ public static TextHistory.State unindent(TextHistory.State state) { private static TextHistory.State mapLines(TextHistory.State state, java.util.function.UnaryOperator mapper) { String value = state.value(); int from = lineStart(value, start(state)); - // A selection ending at the start of a line doesn't include that line - int endPos = end(state) > start(state) && value.charAt(end(state) - 1) == '\n' ? end(state) - 1 : end(state); - int to = lineEnd(value, endPos); + int to = lineEnd(value, selectionEnd(state)); String[] lines = value.substring(from, to).split("\n", -1); String[] newLines = new String[lines.length]; for (int i = 0; i < lines.length; i++) { @@ -251,4 +257,50 @@ public static int lineOffset(String value, int line) { return pos; } + /** + * Comment out the lines touched by the cursor or selection, or uncomment them if they are all commented out. + */ + public static TextHistory.State toggleComment(TextHistory.State state) { + String value = state.value(); + int from = lineStart(value, start(state)); + int to = lineEnd(value, selectionEnd(state)); + boolean allCommented = true; + for (String line : value.substring(from, to).split("\n", -1)) { + if (!line.isBlank() && !line.startsWith("//")) { + allCommented = false; + } + } + boolean uncomment = allCommented; + return mapLines(state, line -> uncomment ? (line.startsWith("//") ? line.substring(2) : line) : "//" + line); + } + + /** + * Move the lines touched by the cursor or selection one line up or down. + * @param direction -1 for up, 1 for down. + */ + public static TextHistory.State moveLines(TextHistory.State state, int direction) { + String value = state.value(); + int from = lineStart(value, start(state)); + int to = lineEnd(value, selectionEnd(state)); + if (direction < 0) { + if (from == 0) { + return state; + } + int otherFrom = lineStart(value, from - 1); + String other = value.substring(otherFrom, from - 1); + String newValue = value.substring(0, otherFrom) + value.substring(from, to) + "\n" + other + value.substring(to); + int shift = -(other.length() + 1); + return new TextHistory.State(newValue, state.cursorPos() + shift, state.selectionPos() + shift); + } else { + if (to == value.length()) { + return state; + } + int otherTo = lineEnd(value, to + 1); + String other = value.substring(to + 1, otherTo); + String newValue = value.substring(0, from) + other + "\n" + value.substring(from, to) + value.substring(otherTo); + int shift = other.length() + 1; + return new TextHistory.State(newValue, state.cursorPos() + shift, state.selectionPos() + shift); + } + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index f481e6358..8eba8fa59 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -479,7 +479,13 @@ private boolean historyKeyPressed(int key, int modifiers) { } private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { - if (Screen.isSelectAll(p_98153_)) { + if (hasControlDown(p_98155_) && p_98153_ == GLFW.GLFW_KEY_SLASH) { + this.setState(TextEditing.toggleComment(this.getState())); + return true; + } else if ((p_98155_ & GLFW.GLFW_MOD_ALT) != 0 && (p_98153_ == GLFW.GLFW_KEY_UP || p_98153_ == GLFW.GLFW_KEY_DOWN)) { + this.setState(TextEditing.moveLines(this.getState(), p_98153_ == GLFW.GLFW_KEY_UP ? -1 : 1)); + return true; + } else if (Screen.isSelectAll(p_98153_)) { this.textFieldHelper.selectAll(); return true; } else if (Screen.isCopy(p_98153_)) { diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java index 9781cb344..50fa4bd94 100644 --- a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -161,4 +161,44 @@ public void testFindPrevious() { assertThat(TextEditing.findPrevious("abc", "x", 2), equalTo(-1)); } + @Test + public void testToggleCommentAdds() { + assertThat(TextEditing.toggleComment(s("a\nb", 3)), equalTo(s("a\n//b", 5))); + } + + @Test + public void testToggleCommentRemoves() { + assertThat(TextEditing.toggleComment(s("//a\n//b", 0, 7)), equalTo(s("a\nb", 0, 3))); + } + + @Test + public void testToggleCommentMixedAdds() { + assertThat(TextEditing.toggleComment(s("//a\nb", 0, 5)), equalTo(s("////a\n//b", 2, 9))); + } + + @Test + public void testMoveLineUp() { + assertThat(TextEditing.moveLines(s("a\nbc\nd", 4), -1), equalTo(s("bc\na\nd", 2))); + } + + @Test + public void testMoveLineUpAtTop() { + assertThat(TextEditing.moveLines(s("a\nb", 1), -1), equalTo(s("a\nb", 1))); + } + + @Test + public void testMoveLineDown() { + assertThat(TextEditing.moveLines(s("a\nbc\nd", 0), 1), equalTo(s("bc\na\nd", 3))); + } + + @Test + public void testMoveLineDownAtBottom() { + assertThat(TextEditing.moveLines(s("a\nb", 2), 1), equalTo(s("a\nb", 2))); + } + + @Test + public void testMoveMultipleLinesDown() { + assertThat(TextEditing.moveLines(s("a\nb\nc", 0, 3), 1), equalTo(s("c\na\nb", 2, 5))); + } + } From 738511b6996ef4ecfaabb475b8ccf35ad6a1cde2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:36:00 +0000 Subject: [PATCH 26/59] Scroll the script editor while drag-selecting Dragging a selection beyond the top or bottom of the editor stopped at the visible lines, so a selection could not be larger than the viewport. The editor now keeps scrolling while a selection is held above or below it, faster the further away the mouse is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 57 ++++++++++++++++--- .../ContainerScreenTerminalScripting.java | 7 +++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 8eba8fa59..7a9f0fd4c 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -93,6 +93,10 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private WidgetScrollBar scrollBar; private int firstRow; private TextHistory history = new TextHistory(); + private boolean draggingSelection; + private double dragMouseX; + private double dragMouseY; + private float dragScrollProgress; @Nullable private String searchQuery; @Nullable @@ -308,6 +312,21 @@ private String getClipboard() { public void tick() { ++this.frameTick; + + // Keep scrolling while a selection is dragged above or below the editor, faster when further away + if (this.draggingSelection) { + double distance = this.dragMouseY < this.getY() ? this.getY() - this.dragMouseY + : this.dragMouseY - this.clampToVisibleRows(this.dragMouseY); + if (distance > 0) { + this.dragScrollProgress += (float) Math.min(2.5, 0.15 + distance / 24); + int rows = (int) this.dragScrollProgress; + this.dragScrollProgress -= rows; + if (rows > 0) { + this.setFirstRow(this.firstRow + (this.dragMouseY < this.getY() ? -rows : rows), true); + this.updateDragSelection(); + } + } + } } @Override @@ -318,8 +337,13 @@ public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { this.setFocused(true); return true; } else { + this.draggingSelection = false; if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { + this.draggingSelection = this.scrollBar == null || mouseX < this.getX() + this.width - SCROLLBAR_WIDTH; + this.dragMouseX = mouseX; + this.dragMouseY = mouseY; + this.dragScrollProgress = 0; this.setFocused(true); long i = Util.getMillis(); DisplayCache displayCache = this.getDisplayCache(); @@ -369,24 +393,41 @@ public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double @Override public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, double offsetX, double offsetY) { - if (this.scrollBar != null && mouseX >= this.getX() + this.width - 12 && mouseX < this.getX() + this.width + if (!this.draggingSelection && this.scrollBar != null && mouseX >= this.getX() + this.width - 12 && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { return this.scrollBar.mouseDragged(mouseX, mouseY, mouseButton, offsetX, offsetY); } - if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width - && mouseY >= this.getY() && mouseY < this.getY() + this.height) { - DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); - int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, this.clampToVisibleRows(mouseY)))); - this.textFieldHelper.setCursorPos(i, true); - this.clearDisplayCache(); - this.setFocused(true); + if (mouseButton == 0 && this.draggingSelection) { + // Scrolling beyond the top or bottom happens in tick, so it continues while the mouse is held still + this.dragMouseX = mouseX; + this.dragMouseY = mouseY; + this.updateDragSelection(); return true; } // Let other drags through, such as spreading items over inventory slots return false; } + @Override + public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { + if (mouseButton == 0) { + this.draggingSelection = false; + this.dragScrollProgress = 0; + } + return super.mouseReleased(mouseX, mouseY, mouseButton); + } + + private void updateDragSelection() { + int x = (int) Math.max(this.getX(), Math.min(this.getX() + this.width - 1, this.dragMouseX)); + int y = this.clampToVisibleRows(Math.max(this.getY(), this.dragMouseY)); + DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); + int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i(x, y))); + this.textFieldHelper.setCursorPos(i, true); + this.clearDisplayCache(); + this.setFocused(true); + } + @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { if (hasControlDown(modifiers) && this.shortcutListener != null && this.shortcutListener.test(typedChar)) { diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index b9ad8bd94..9882ce619 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -584,6 +584,13 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl return super.mouseDragged(mouseX, mouseY, mouseButton, offsetX, offsetY); } + @Override + public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { + // Always let the editor know, as a drag selection can be released anywhere + textArea.mouseReleased(mouseX, mouseY, mouseButton); + return super.mouseReleased(mouseX, mouseY, mouseButton); + } + @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { // Make active dialog consume all input From c8402339bbeb3ee3667a6f9a46495dc4a402d822 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:55:30 +0000 Subject: [PATCH 27/59] Make the indent size of the script editor configurable Tab inserted a fixed 4 spaces, which is wide in the small editor, and doesn't match every code style. Add a client option for the number of spaces, defaulting to 2, which is common for JavaScript. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../integratedscripting/GeneralConfig.java | 2 + .../gui/component/input/TextEditing.java | 20 ++++---- .../gui/component/input/WidgetTextArea.java | 12 ++++- .../ContainerScreenTerminalScripting.java | 1 + .../gui/component/input/TextEditingTest.java | 49 +++++++++++++------ 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/GeneralConfig.java b/src/main/java/org/cyclops/integratedscripting/GeneralConfig.java index 3d6479f2c..f089434d8 100644 --- a/src/main/java/org/cyclops/integratedscripting/GeneralConfig.java +++ b/src/main/java/org/cyclops/integratedscripting/GeneralConfig.java @@ -21,6 +21,8 @@ public class GeneralConfig extends DummyConfig { public static int terminalScriptingBaseConsumption = 1; @ConfigurableProperty(category = "general", comment = "The minimum number of ticks inbetween sending a script change packet from client to server.", minimalValue = 0, configLocation = ModConfig.Type.SERVER) public static int terminalScriptingClientSyncTickInterval = 20; + @ConfigurableProperty(category = "general", comment = "The number of spaces that tab inserts in the scripting terminal editor.", minimalValue = 1, maximalValue = 16, configLocation = ModConfig.Type.CLIENT) + public static int terminalScriptingIndentSize = 2; @ConfigurableProperty(category = "general", comment = "If new processes can be created from guest languages. Only enable this on private servers and if you know what you are doing.", configLocation = ModConfig.Type.SERVER) public static boolean graalAllowCreateProcess = false; diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java index 9925f9199..8f7c0e221 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -6,7 +6,6 @@ */ public class TextEditing { - public static final String INDENT = " "; private static int start(TextHistory.State state) { return Math.min(state.cursorPos(), state.selectionPos()); @@ -47,24 +46,26 @@ private static boolean isMultiLineSelection(TextHistory.State state) { /** * Indent the selected lines, or insert an indent at the cursor if no lines are selected. + * @param indent The string of one indentation level. */ - public static TextHistory.State indent(TextHistory.State state) { + public static TextHistory.State indent(TextHistory.State state, String indent) { String value = state.value(); if (!isMultiLineSelection(state)) { int start = start(state); - String newValue = value.substring(0, start) + INDENT + value.substring(end(state)); - return new TextHistory.State(newValue, start + INDENT.length(), start + INDENT.length()); + String newValue = value.substring(0, start) + indent + value.substring(end(state)); + return new TextHistory.State(newValue, start + indent.length(), start + indent.length()); } - return mapLines(state, line -> INDENT + line); + return mapLines(state, line -> indent + line); } /** * Remove one indent from the start of the lines touched by the cursor or selection. + * @param indent The string of one indentation level. */ - public static TextHistory.State unindent(TextHistory.State state) { + public static TextHistory.State unindent(TextHistory.State state, String indent) { return mapLines(state, line -> { int remove = 0; - while (remove < INDENT.length() && remove < line.length() && line.charAt(remove) == ' ') { + while (remove < indent.length() && remove < line.length() && line.charAt(remove) == ' ') { remove++; } if (remove == 0 && !line.isEmpty() && line.charAt(0) == '\t') { @@ -118,8 +119,9 @@ private static int mapPosition(int pos, int from, String[] lines, String[] newLi /** * Insert a newline that keeps the indentation of the current line, * and indents one more level after an opening bracket. + * @param indentUnit The string of one indentation level. */ - public static TextHistory.State newline(TextHistory.State state) { + public static TextHistory.State newline(TextHistory.State state, String indentUnit) { String value = state.value(); int start = start(state); int end = end(state); @@ -129,7 +131,7 @@ public static TextHistory.State newline(TextHistory.State state) { String before = value.substring(lineStart, start).stripTrailing(); char open = before.isEmpty() ? 0 : before.charAt(before.length() - 1); boolean opens = open == '{' || open == '(' || open == '['; - String insert = "\n" + indent + (opens ? INDENT : ""); + String insert = "\n" + indent + (opens ? indentUnit : ""); int cursor = start + insert.length(); // Put a directly following closing bracket on its own line if (opens && end < value.length() && value.charAt(end) == closing(open)) { diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 7a9f0fd4c..3d534ed89 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -104,6 +104,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { @Nullable private String searchMatchesQuery; private int[] searchMatches = new int[0]; + private String indent = " "; @Nullable private IntPredicate shortcutListener; @@ -207,6 +208,13 @@ public void selectRange(int start, int end) { this.ensureCursorVisible(); } + /** + * @param indent The string that is inserted for one indentation level. + */ + public void setIndent(String indent) { + this.indent = indent; + } + /** * @param searchQuery A query of which all occurrences are highlighted, or null. */ @@ -544,10 +552,10 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { switch (p_98153_) { case 257: case 335: - this.setState(TextEditing.newline(this.getState())); + this.setState(TextEditing.newline(this.getState(), this.indent)); return true; case GLFW.GLFW_KEY_TAB: - this.setState((p_98155_ & GLFW.GLFW_MOD_SHIFT) != 0 ? TextEditing.unindent(this.getState()) : TextEditing.indent(this.getState())); + this.setState((p_98155_ & GLFW.GLFW_MOD_SHIFT) != 0 ? TextEditing.unindent(this.getState(), this.indent) : TextEditing.indent(this.getState(), this.indent)); return true; case 259: this.textFieldHelper.removeFromCursor(-1, cursorStep); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 9882ce619..21e136797 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -131,6 +131,7 @@ public int getTotalRows() { }; textArea = new WidgetTextArea(Minecraft.getInstance().font, this.leftPos + SCRIPT_X + 1, this.topPos + SCRIPT_Y + 1, SCRIPT_WIDTH, SCRIPT_HEIGHT, Component.translatable("gui.integratedscripting.script"), true, true); + textArea.setIndent(" ".repeat(GeneralConfig.terminalScriptingIndentSize)); textArea.setListener(this::onActiveScriptModified); textArea.setShortcutListener(key -> { if (key == GLFW.GLFW_KEY_F || key == GLFW.GLFW_KEY_G) { diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java index 50fa4bd94..729ba3337 100644 --- a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -8,6 +8,9 @@ public class TextEditingTest { + private static final String FOUR = " "; + private static final String TWO = " "; + private static TextHistory.State s(String value, int cursor) { return new TextHistory.State(value, cursor, cursor); } @@ -18,77 +21,77 @@ private static TextHistory.State s(String value, int cursor, int selection) { @Test public void testIndentAtCursor() { - assertThat(TextEditing.indent(s("ab", 1)), equalTo(s("a b", 5))); + assertThat(TextEditing.indent(s("ab", 1), FOUR), equalTo(s("a b", 5))); } @Test public void testIndentReplacesSingleLineSelection() { - assertThat(TextEditing.indent(s("abc", 1, 2)), equalTo(s("a c", 5))); + assertThat(TextEditing.indent(s("abc", 1, 2), FOUR), equalTo(s("a c", 5))); } @Test public void testIndentMultipleLines() { - assertThat(TextEditing.indent(s("a\nb\nc", 0, 3)), equalTo(s(" a\n b\nc", 4, 11))); + assertThat(TextEditing.indent(s("a\nb\nc", 0, 3), FOUR), equalTo(s(" a\n b\nc", 4, 11))); } @Test public void testIndentMultipleLinesSelectionEndingAtLineStart() { - assertThat(TextEditing.indent(s("a\nb\nc", 0, 4)), equalTo(s(" a\n b\nc", 4, 12))); + assertThat(TextEditing.indent(s("a\nb\nc", 0, 4), FOUR), equalTo(s(" a\n b\nc", 4, 12))); } @Test public void testUnindentCurrentLine() { - assertThat(TextEditing.unindent(s("a\n b", 9)), equalTo(s("a\n b", 5))); + assertThat(TextEditing.unindent(s("a\n b", 9), FOUR), equalTo(s("a\n b", 5))); } @Test public void testUnindentCursorInsideIndent() { - assertThat(TextEditing.unindent(s(" b", 2)), equalTo(s("b", 0))); + assertThat(TextEditing.unindent(s(" b", 2), FOUR), equalTo(s("b", 0))); } @Test public void testUnindentPartialIndent() { - assertThat(TextEditing.unindent(s(" b", 3)), equalTo(s("b", 1))); + assertThat(TextEditing.unindent(s(" b", 3), FOUR), equalTo(s("b", 1))); } @Test public void testUnindentNoIndent() { - assertThat(TextEditing.unindent(s("b", 1)), equalTo(s("b", 1))); + assertThat(TextEditing.unindent(s("b", 1), FOUR), equalTo(s("b", 1))); } @Test public void testUnindentMultipleLines() { - assertThat(TextEditing.unindent(s(" a\n b\nc", 0, 11)), equalTo(s("a\nb\nc", 0, 5))); + assertThat(TextEditing.unindent(s(" a\n b\nc", 0, 11), FOUR), equalTo(s("a\nb\nc", 0, 5))); } @Test public void testNewlineKeepsIndent() { - assertThat(TextEditing.newline(s(" a;", 6)), equalTo(s(" a;\n ", 11))); + assertThat(TextEditing.newline(s(" a;", 6), FOUR), equalTo(s(" a;\n ", 11))); } @Test public void testNewlineIndentsAfterOpeningBracket() { - assertThat(TextEditing.newline(s(" if (a) {", 10)), equalTo(s(" if (a) {\n ", 17))); + assertThat(TextEditing.newline(s(" if (a) {", 10), FOUR), equalTo(s(" if (a) {\n ", 17))); } @Test public void testNewlineIndentsAfterOpeningBracketWithTrailingSpace() { - assertThat(TextEditing.newline(s("f(", 2)), equalTo(s("f(\n ", 7))); + assertThat(TextEditing.newline(s("f(", 2), FOUR), equalTo(s("f(\n ", 7))); } @Test public void testNewlineSplitsBracketPair() { - assertThat(TextEditing.newline(s(" {}", 3)), equalTo(s(" {\n \n }", 10))); + assertThat(TextEditing.newline(s(" {}", 3), FOUR), equalTo(s(" {\n \n }", 10))); } @Test public void testNewlineReplacesSelection() { - assertThat(TextEditing.newline(s("abc", 1, 2)), equalTo(s("a\nc", 2))); + assertThat(TextEditing.newline(s("abc", 1, 2), FOUR), equalTo(s("a\nc", 2))); } @Test public void testNewlineInMiddleOfIndent() { - assertThat(TextEditing.newline(s(" a", 2)), equalTo(s(" \n a", 5))); + assertThat(TextEditing.newline(s(" a", 2), FOUR), equalTo(s(" \n a", 5))); } @Test @@ -201,4 +204,20 @@ public void testMoveMultipleLinesDown() { assertThat(TextEditing.moveLines(s("a\nb\nc", 0, 3), 1), equalTo(s("c\na\nb", 2, 5))); } + @Test + public void testIndentTwoSpaces() { + assertThat(TextEditing.indent(s("ab", 1), TWO), equalTo(s("a b", 3))); + assertThat(TextEditing.indent(s("a\nb", 0, 3), TWO), equalTo(s(" a\n b", 2, 7))); + } + + @Test + public void testUnindentTwoSpaces() { + assertThat(TextEditing.unindent(s(" b", 5), TWO), equalTo(s(" b", 3))); + } + + @Test + public void testNewlineTwoSpaces() { + assertThat(TextEditing.newline(s(" if (a) {", 10), TWO), equalTo(s(" if (a) {\n ", 15))); + } + } From 4a9c280cdf76d2d99901f4603d796ebeda5fb97d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:48:44 +0000 Subject: [PATCH 28/59] Add toolbar buttons for editor actions The editor actions were only available through keyboard shortcuts, which players can't discover in-game. Add small buttons for undo, redo, find, go to line and toggle comment next to the terminal title, whose tooltips also show their shortcut. Long translations of the title are shortened so they don't run under the buttons. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/button/ButtonIcon.java | 27 ++++++++ .../gui/component/input/TextHistory.java | 8 +++ .../gui/component/input/WidgetTextArea.java | 34 ++++++++++ .../ContainerScreenTerminalScripting.java | 60 +++++++++++++++++- .../client/gui/image/ScriptImages.java | 6 ++ .../integratedscripting/lang/en_us.json | 5 ++ .../textures/gui/icons.png | Bin 423 -> 1286 bytes 7 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/client/gui/component/button/ButtonIcon.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/button/ButtonIcon.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/button/ButtonIcon.java new file mode 100644 index 000000000..c50bee60d --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/button/ButtonIcon.java @@ -0,0 +1,27 @@ +package org.cyclops.integratedscripting.client.gui.component.button; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.network.chat.Component; +import org.cyclops.cyclopscore.client.gui.component.button.ButtonExtended; +import org.cyclops.cyclopscore.client.gui.image.IImage; + +/** + * A button with a centered icon, which is dimmed when the button is inactive. + * @author rubensworks + */ +public class ButtonIcon extends ButtonExtended { + + private final IImage image; + + public ButtonIcon(int x, int y, int width, int height, Component narrationMessage, OnPress onPress, IImage image) { + super(x, y, width, height, narrationMessage, onPress, true); + this.image = image; + } + + @Override + protected void drawButtonInner(GuiGraphics guiGraphics, int mouseX, int mouseY) { + float color = this.active ? 1F : 0.4F; + this.image.drawWithColor(guiGraphics, this.getX() + (this.width - this.image.getWidth()) / 2, + this.getY() + (this.height - this.image.getHeight()) / 2, color, color, color, 1F); + } +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java index bf5cefa30..92774f2c0 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java @@ -70,6 +70,14 @@ public State redo(State current) { return this.redoStack.pop(); } + public boolean canUndo() { + return !this.undoStack.isEmpty(); + } + + public boolean canRedo() { + return !this.redoStack.isEmpty(); + } + public static record State(String value, int cursorPos, int selectionPos) {} } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 3d534ed89..a0092f987 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -508,6 +508,40 @@ private static boolean hasControlDown(int modifiers) { return (modifiers & (Minecraft.ON_OSX ? GLFW.GLFW_MOD_SUPER : GLFW.GLFW_MOD_CONTROL)) != 0; } + public boolean canUndo() { + return this.history.canUndo(); + } + + public boolean canRedo() { + return this.history.canRedo(); + } + + public void undo() { + this.applyEdit(this.history.undo(this.getState())); + } + + public void redo() { + this.applyEdit(this.history.redo(this.getState())); + } + + /** + * Toggle line comments on the lines touched by the cursor or selection, as an undoable edit. + */ + public void toggleComment() { + TextHistory.State before = this.getState(); + this.setState(TextEditing.toggleComment(before)); + this.recordHistory(before, false); + this.clearDisplayCache(); + } + + private void applyEdit(@Nullable TextHistory.State state) { + if (state != null) { + this.setState(state); + this.clearDisplayCache(); + this.ensureCursorVisible(); + } + } + private boolean historyKeyPressed(int key, int modifiers) { if (!hasControlDown(modifiers)) { return false; diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 21e136797..f7ce4c024 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -1,6 +1,7 @@ package org.cyclops.integratedscripting.client.gui.container; import com.google.common.base.Strings; +import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.mojang.blaze3d.systems.RenderSystem; import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap; @@ -8,6 +9,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -30,6 +32,7 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.client.gui.component.button.ButtonIcon; import org.cyclops.integratedscripting.client.gui.component.input.TextEditing; import org.cyclops.integratedscripting.client.gui.component.input.TextHistory; import org.cyclops.integratedscripting.client.gui.component.input.WidgetDialog; @@ -77,6 +80,10 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended toolbarEditButtons = Lists.newArrayList(); + private ButtonIcon buttonUndo; + private ButtonIcon buttonRedo; + private int toolbarLeft; private WidgetDialog pendingScriptRemovalDialog; @Nullable private WidgetSearchBar searchBar; @@ -154,6 +161,8 @@ public int getTotalRows() { (button) -> getMenu().createNewFile(), true); addRenderableWidget(buttonCreateFile); + this.addToolbar(this.leftPos + SCRIPT_X + SCRIPT_WIDTH, this.topPos + 5); + // Restore the open script in the new editor if (getMenu().getActiveScriptPath() != null) { this.onActiveScriptSelected(); @@ -162,10 +171,53 @@ public int getTotalRows() { } } + /** + * Add buttons for editor actions, which also show their keyboard shortcut in their tooltip. + * @param right The right side of the toolbar. + * @param y The top of the toolbar. + */ + private void addToolbar(int right, int y) { + this.toolbarLeft = right - 50; + String modifier = Minecraft.ON_OSX ? "Cmd" : "Ctrl"; + this.toolbarEditButtons.clear(); + this.buttonUndo = this.addToolbarButton(right - 50, y, ScriptImages.ACTION_UNDO, Component.translatable("gui.integratedscripting.action.undo", modifier), () -> this.textArea.undo()); + this.buttonRedo = this.addToolbarButton(right - 40, y, ScriptImages.ACTION_REDO, Component.translatable("gui.integratedscripting.action.redo", modifier), () -> this.textArea.redo()); + this.addToolbarButton(right - 30, y, ScriptImages.ACTION_FIND, Component.translatable("gui.integratedscripting.action.find", modifier), () -> this.openNavigate(false)); + this.addToolbarButton(right - 20, y, ScriptImages.ACTION_GOTO_LINE, Component.translatable("gui.integratedscripting.action.goto_line", modifier), () -> this.openNavigate(true)); + this.addToolbarButton(right - 10, y, ScriptImages.ACTION_COMMENT, Component.translatable("gui.integratedscripting.action.comment", modifier), () -> this.textArea.toggleComment()); + this.updateToolbar(); + } + + private ButtonIcon addToolbarButton(int x, int y, IImage image, Component tooltip, Runnable action) { + ButtonIcon button = new ButtonIcon(x, y, 10, 10, tooltip, (b) -> { + // Run after the click is handled, as that focuses the button + Minecraft.getInstance().tell(() -> { + // Give focus back to the editor, unless the action focuses something else + this.setFocused(this.textArea); + this.textArea.setFocused(true); + action.run(); + }); + }, image); + button.setTooltip(Tooltip.create(tooltip)); + this.toolbarEditButtons.add(button); + addRenderableWidget(button); + return button; + } + + private void updateToolbar() { + boolean scriptOpen = getMenu().getActiveScript() != null; + for (ButtonIcon button : this.toolbarEditButtons) { + button.active = scriptOpen; + } + this.buttonUndo.active = scriptOpen && this.textArea.canUndo(); + this.buttonRedo.active = scriptOpen && this.textArea.canRedo(); + } + @Override protected void containerTick() { super.containerTick(); textArea.tick(); + this.updateToolbar(); // The matches change when the script is edited while searching this.updateSearchStatus(); @@ -336,8 +388,14 @@ protected Path getHoveredScriptPath(double mouseX, double mouseY) { @Override protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { + // Shorten the title if it would overlap with the toolbar, as translations can be long + Component title = this.title; + int maxTitleWidth = this.toolbarLeft - this.leftPos - this.titleLabelX - 2; + if (this.font.width(title) > maxTitleWidth) { + title = Component.literal(this.font.plainSubstrByWidth(title.getString(), maxTitleWidth - this.font.width("\u2026")) + "\u2026"); + } // The inventory label is left out, so its row can show the card binding - guiGraphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 4210752, false); + guiGraphics.drawString(this.font, title, this.titleLabelX, this.titleLabelY, 4210752, false); // Draw disk label, scaled to the space before the disk field, which fits the English label at normal size String diskLabel = L10NHelpers.localize("gui.integratedscripting.disk") + ":"; diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java index 77cb4ece5..9d6678d8a 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java @@ -16,4 +16,10 @@ public class ScriptImages { public static final Image FILE_OTHER = new Image(ICONS, 0, 0, 12, 12); public static final Image FILE_JS = new Image(ICONS, 12, 0, 12, 12); + public static final Image ACTION_UNDO = new Image(ICONS, 0, 16, 8, 8); + public static final Image ACTION_REDO = new Image(ICONS, 8, 16, 8, 8); + public static final Image ACTION_FIND = new Image(ICONS, 16, 16, 8, 8); + public static final Image ACTION_GOTO_LINE = new Image(ICONS, 24, 16, 8, 8); + public static final Image ACTION_COMMENT = new Image(ICONS, 32, 16, 8, 8); + } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 338c4ad2a..f9f8eae81 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -33,6 +33,11 @@ "gui.integratedscripting.find.previous": "Previous match (Shift+Enter)", "gui.integratedscripting.find.next": "Next match (Enter)", "gui.integratedscripting.find.close": "Close (Escape)", + "gui.integratedscripting.action.undo": "Undo (%s+Z)", + "gui.integratedscripting.action.redo": "Redo (%s+Y)", + "gui.integratedscripting.action.find": "Find (%s+F)", + "gui.integratedscripting.action.goto_line": "Go to line (%s+G)", + "gui.integratedscripting.action.comment": "Toggle comment (%s+/)", "gui.integratedscripting.removal_dialog.title": "Confirm script deletion", "gui.integratedscripting.removal_dialog.message": "Do you want to delete \"%s\"?", "gui.integratedscripting.removal_dialog.delete": "Delete", diff --git a/src/main/resources/assets/integratedscripting/textures/gui/icons.png b/src/main/resources/assets/integratedscripting/textures/gui/icons.png index cf2519fee18e81231e3ca6e0c16d0e23e3a32e16..c896e55b9d6f00d3cd9968ee8d30a137c676f07f 100644 GIT binary patch literal 1286 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEivz{)FAr*7p-Z@=*D@(#5 z@ZnRY8B3=GG-(w`&s)agf7eZG0ZY`!9=;uuwHFB_3fm{`Id@O^vp|&3zR%uFd#q&m z4ySCs`Da)0vApfkd&{aH?7HJ;ZZ`Yu!;c?7-t2w<{(b*;b0F_|fByNr`tKjU9NW%b zvt)MSj+`IQ^(5;a{JJYq_hQ%2H|7jA^UT>Fe6jie?%~Ou_BDdr_4A8%?rvXx@Fio7 z?E5ON51)^luePsId|$`w!1CaA1kZslmH%Jf-}y48zV5{SFA@cN#nvpG6Gu!8~mC77XlsTUMz4+IiOZFl&?aNNu%P_{hPm`$Q zeRiMcPR$IU?-IA)Uc0RS&$+khd*jxhV|`@0+Wv#t_Ro!X^0pr~;Av0%@j0L_vq0Z6 z%i{a4uT^`kc#n!qN|fN+eER_V&bjMPJ+_dW9}zWidE;Muk;4{!hx4}2j;rT6UbDgf zeg5{_k5?YL^zZyp#)|swhd=yY_%r9~-&|3<$cg-)BhIjw-8x^j*Urv<@4J#6=il6a z#sB}rN2Le%=M?+jKG$Bdi_IYZ>h!pBt2^iZKKv|G&toEe!=CSHx;uw>?H%*n{U#SK zf0%D6wBgrb`;xEwStHiY%RjhvfBlZMaCwFYkAG+xv~V(vqR|i-G9mDny~QTv)b$e0 RO~CSv!PC{xWt~$(69D+duk-)_ literal 423 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58CaNs)Vi3hp+Jfy-O<;Pfnj4m_n$;oAYUQb zBgmJ5p-Pp3p`n?9;pcxK{gQ#9)PRBERRRNp)eHs(@%%~gN8NyG`2&1HT>t<7Z*FdW z;>3xE>-~W2-44^A1Em;Cg8YIR9G=}s196hP-CY&+dYP_d_rV@SoVx0iSG9x&i=aI9vT&H0E|=i7Z<`K2e= zM0RRtR5~r%YNz*J$MAZTd-s_;s=t5lVy>FTdPZG=CFO|*-;Jl@9j_W!C8<`)MX5lF!N|bKMAyJj*T^iy(89{Z rz{ Date: Sun, 18 Oct 2026 07:43:32 +0000 Subject: [PATCH 29/59] Make the script editor shortcuts rebindable The shortcuts for undo, redo, find, go to line, toggle comment and moving lines were hardcoded. They are now key mappings that can be changed in the Controls settings, and the toolbar tooltips show the current key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 41 ++++++----- .../ContainerScreenTerminalScripting.java | 23 ++++--- .../integratedscripting/client/key/Keys.java | 69 +++++++++++++++++++ .../proxy/ClientProxy.java | 9 +++ .../integratedscripting/lang/en_us.json | 14 ++-- .../client/key/KeysTest.java | 47 +++++++++++++ 6 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/client/key/Keys.java create mode 100644 src/test/java/org/cyclops/integratedscripting/client/key/KeysTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index a0092f987..152c70c06 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,13 +39,13 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedscripting.client.key.Keys; import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.function.IntPredicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -106,7 +106,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private int[] searchMatches = new int[0]; private String indent = " "; @Nullable - private IntPredicate shortcutListener; + private ShortcutListener shortcutListener; public WidgetTextArea(Font font, int x, int y, int width, int height, Component narrationMessage, boolean scrollBar, boolean showLineNumbers) { super(font, x, y, width, height, narrationMessage); @@ -191,9 +191,9 @@ public void setListenerCursor(@Nullable IInputListener listenerCursor) { } /** - * @param shortcutListener Called with the key of ctrl-shortcuts, returns true if it handled the shortcut. + * @param shortcutListener Called with each key press before the editor handles it, returns true if it handled the key. */ - public void setShortcutListener(@Nullable IntPredicate shortcutListener) { + public void setShortcutListener(@Nullable ShortcutListener shortcutListener) { this.shortcutListener = shortcutListener; } @@ -438,11 +438,11 @@ private void updateDragSelection() { @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { - if (hasControlDown(modifiers) && this.shortcutListener != null && this.shortcutListener.test(typedChar)) { + if (this.shortcutListener != null && this.shortcutListener.keyPressed(typedChar, keyCode, modifiers)) { return true; } TextHistory.State before = this.getState(); - boolean history = this.historyKeyPressed(typedChar, modifiers); + boolean history = this.historyKeyPressed(typedChar, keyCode, modifiers); boolean flag = history || this.textFieldKeyPressed(typedChar, keyCode, modifiers); if (flag) { if (!history) { @@ -542,15 +542,16 @@ private void applyEdit(@Nullable TextHistory.State state) { } } - private boolean historyKeyPressed(int key, int modifiers) { - if (!hasControlDown(modifiers)) { - return false; - } - boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + // Ctrl+Shift+Z, which needs two modifiers, so it can not be a key mapping + private static boolean isRedoAlternative(int key, int modifiers) { + return key == GLFW.GLFW_KEY_Z && hasControlDown(modifiers) && (modifiers & GLFW.GLFW_MOD_SHIFT) != 0 && (modifiers & GLFW.GLFW_MOD_ALT) == 0; + } + + private boolean historyKeyPressed(int key, int scanCode, int modifiers) { TextHistory.State state; - if (key == GLFW.GLFW_KEY_Z && !shift) { + if (Keys.matches(Keys.UNDO, key, scanCode, modifiers)) { state = this.history.undo(this.getState()); - } else if (key == GLFW.GLFW_KEY_Y || (key == GLFW.GLFW_KEY_Z && shift)) { + } else if (Keys.matches(Keys.REDO, key, scanCode, modifiers) || isRedoAlternative(key, modifiers)) { state = this.history.redo(this.getState()); } else { return false; @@ -562,11 +563,14 @@ private boolean historyKeyPressed(int key, int modifiers) { } private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { - if (hasControlDown(p_98155_) && p_98153_ == GLFW.GLFW_KEY_SLASH) { + if (Keys.matches(Keys.COMMENT, p_98153_, p_98154_, p_98155_)) { this.setState(TextEditing.toggleComment(this.getState())); return true; - } else if ((p_98155_ & GLFW.GLFW_MOD_ALT) != 0 && (p_98153_ == GLFW.GLFW_KEY_UP || p_98153_ == GLFW.GLFW_KEY_DOWN)) { - this.setState(TextEditing.moveLines(this.getState(), p_98153_ == GLFW.GLFW_KEY_UP ? -1 : 1)); + } else if (Keys.matches(Keys.MOVE_LINES_UP, p_98153_, p_98154_, p_98155_)) { + this.setState(TextEditing.moveLines(this.getState(), -1)); + return true; + } else if (Keys.matches(Keys.MOVE_LINES_DOWN, p_98153_, p_98154_, p_98155_)) { + this.setState(TextEditing.moveLines(this.getState(), 1)); return true; } else if (Screen.isSelectAll(p_98153_)) { this.textFieldHelper.selectAll(); @@ -1089,4 +1093,9 @@ static class Pos2i { public static interface IMarkupProvider { public List> markupLine(Style style, String line); } + + public static interface ShortcutListener { + public boolean keyPressed(int keyCode, int scanCode, int modifiers); + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index f7ce4c024..9083a1517 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -6,6 +6,7 @@ import com.mojang.blaze3d.systems.RenderSystem; import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; @@ -39,6 +40,7 @@ import org.cyclops.integratedscripting.client.gui.component.input.WidgetSearchBar; import org.cyclops.integratedscripting.client.gui.component.input.WidgetTextArea; import org.cyclops.integratedscripting.client.gui.image.ScriptImages; +import org.cyclops.integratedscripting.client.key.Keys; import org.cyclops.integratedscripting.core.language.LanguageHandlers; import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScripting; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; @@ -140,9 +142,10 @@ public int getTotalRows() { textArea = new WidgetTextArea(Minecraft.getInstance().font, this.leftPos + SCRIPT_X + 1, this.topPos + SCRIPT_Y + 1, SCRIPT_WIDTH, SCRIPT_HEIGHT, Component.translatable("gui.integratedscripting.script"), true, true); textArea.setIndent(" ".repeat(GeneralConfig.terminalScriptingIndentSize)); textArea.setListener(this::onActiveScriptModified); - textArea.setShortcutListener(key -> { - if (key == GLFW.GLFW_KEY_F || key == GLFW.GLFW_KEY_G) { - openNavigate(key == GLFW.GLFW_KEY_G); + textArea.setShortcutListener((key, scanCode, modifiers) -> { + boolean find = Keys.matches(Keys.FIND, key, scanCode, modifiers); + if (find || Keys.matches(Keys.GOTO_LINE, key, scanCode, modifiers)) { + openNavigate(!find); return true; } return false; @@ -178,17 +181,17 @@ public int getTotalRows() { */ private void addToolbar(int right, int y) { this.toolbarLeft = right - 50; - String modifier = Minecraft.ON_OSX ? "Cmd" : "Ctrl"; this.toolbarEditButtons.clear(); - this.buttonUndo = this.addToolbarButton(right - 50, y, ScriptImages.ACTION_UNDO, Component.translatable("gui.integratedscripting.action.undo", modifier), () -> this.textArea.undo()); - this.buttonRedo = this.addToolbarButton(right - 40, y, ScriptImages.ACTION_REDO, Component.translatable("gui.integratedscripting.action.redo", modifier), () -> this.textArea.redo()); - this.addToolbarButton(right - 30, y, ScriptImages.ACTION_FIND, Component.translatable("gui.integratedscripting.action.find", modifier), () -> this.openNavigate(false)); - this.addToolbarButton(right - 20, y, ScriptImages.ACTION_GOTO_LINE, Component.translatable("gui.integratedscripting.action.goto_line", modifier), () -> this.openNavigate(true)); - this.addToolbarButton(right - 10, y, ScriptImages.ACTION_COMMENT, Component.translatable("gui.integratedscripting.action.comment", modifier), () -> this.textArea.toggleComment()); + this.buttonUndo = this.addToolbarButton(right - 50, y, ScriptImages.ACTION_UNDO, Keys.UNDO, () -> this.textArea.undo()); + this.buttonRedo = this.addToolbarButton(right - 40, y, ScriptImages.ACTION_REDO, Keys.REDO, () -> this.textArea.redo()); + this.addToolbarButton(right - 30, y, ScriptImages.ACTION_FIND, Keys.FIND, () -> this.openNavigate(false)); + this.addToolbarButton(right - 20, y, ScriptImages.ACTION_GOTO_LINE, Keys.GOTO_LINE, () -> this.openNavigate(true)); + this.addToolbarButton(right - 10, y, ScriptImages.ACTION_COMMENT, Keys.COMMENT, () -> this.textArea.toggleComment()); this.updateToolbar(); } - private ButtonIcon addToolbarButton(int x, int y, IImage image, Component tooltip, Runnable action) { + private ButtonIcon addToolbarButton(int x, int y, IImage image, KeyMapping keyMapping, Runnable action) { + Component tooltip = Keys.getTooltip(keyMapping); ButtonIcon button = new ButtonIcon(x, y, 10, 10, tooltip, (b) -> { // Run after the click is handled, as that focuses the button Minecraft.getInstance().tell(() -> { diff --git a/src/main/java/org/cyclops/integratedscripting/client/key/Keys.java b/src/main/java/org/cyclops/integratedscripting/client/key/Keys.java new file mode 100644 index 000000000..980fbeee9 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/key/Keys.java @@ -0,0 +1,69 @@ +package org.cyclops.integratedscripting.client.key; + +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.ChatFormatting; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.neoforged.neoforge.client.settings.KeyConflictContext; +import net.neoforged.neoforge.client.settings.KeyModifier; +import org.cyclops.integratedscripting.Reference; +import org.lwjgl.glfw.GLFW; + +import java.util.List; + +/** + * Rebindable keyboard shortcuts of the script editor. + * @author rubensworks + */ +public class Keys { + + public static final String CATEGORY = "key.categories." + Reference.MOD_ID; + + public static final KeyMapping UNDO = create("undo", KeyModifier.CONTROL, GLFW.GLFW_KEY_Z); + public static final KeyMapping REDO = create("redo", KeyModifier.CONTROL, GLFW.GLFW_KEY_Y); + public static final KeyMapping FIND = create("find", KeyModifier.CONTROL, GLFW.GLFW_KEY_F); + public static final KeyMapping GOTO_LINE = create("goto_line", KeyModifier.CONTROL, GLFW.GLFW_KEY_G); + public static final KeyMapping COMMENT = create("comment", KeyModifier.CONTROL, GLFW.GLFW_KEY_SLASH); + public static final KeyMapping MOVE_LINES_UP = create("move_lines_up", KeyModifier.ALT, GLFW.GLFW_KEY_UP); + public static final KeyMapping MOVE_LINES_DOWN = create("move_lines_down", KeyModifier.ALT, GLFW.GLFW_KEY_DOWN); + + public static final List ALL = List.of(UNDO, REDO, FIND, GOTO_LINE, COMMENT, MOVE_LINES_UP, MOVE_LINES_DOWN); + + private static KeyMapping create(String name, KeyModifier modifier, int key) { + return new KeyMapping("key." + Reference.MOD_ID + "." + name, KeyConflictContext.GUI, modifier, + InputConstants.Type.KEYSYM, key, CATEGORY); + } + + /** + * Check if a key event matches a key mapping. + * Modifiers are read from the event instead of the global keyboard state, and must match exactly, + * so that for example Ctrl+Shift+Z does not trigger a Ctrl+Z mapping. + */ + public static boolean matches(KeyMapping keyMapping, int keyCode, int scanCode, int modifiers) { + if (keyMapping.isUnbound() || !keyMapping.getKey().equals(InputConstants.getKey(keyCode, scanCode))) { + return false; + } + int control = Minecraft.ON_OSX ? GLFW.GLFW_MOD_SUPER : GLFW.GLFW_MOD_CONTROL; + int expected = switch (keyMapping.getKeyModifier()) { + case CONTROL -> control; + case SHIFT -> GLFW.GLFW_MOD_SHIFT; + case ALT -> GLFW.GLFW_MOD_ALT; + case NONE -> 0; + }; + return (modifiers & (control | GLFW.GLFW_MOD_SHIFT | GLFW.GLFW_MOD_ALT)) == expected; + } + + /** + * @return The name of the key mapping, followed by its current key, if bound. + */ + public static MutableComponent getTooltip(KeyMapping keyMapping) { + MutableComponent tooltip = Component.translatable(keyMapping.getName()); + if (!keyMapping.isUnbound()) { + tooltip.append(Component.literal(" (").append(keyMapping.getTranslatedKeyMessage()).append(")").withStyle(ChatFormatting.GRAY)); + } + return tooltip; + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/proxy/ClientProxy.java b/src/main/java/org/cyclops/integratedscripting/proxy/ClientProxy.java index 121f7e27b..f5ef88d9e 100644 --- a/src/main/java/org/cyclops/integratedscripting/proxy/ClientProxy.java +++ b/src/main/java/org/cyclops/integratedscripting/proxy/ClientProxy.java @@ -1,8 +1,11 @@ package org.cyclops.integratedscripting.proxy; +import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import org.cyclops.cyclopscore.client.key.IKeyRegistry; import org.cyclops.cyclopscore.init.ModBase; import org.cyclops.cyclopscore.proxy.ClientProxyComponent; import org.cyclops.integratedscripting.IntegratedScripting; +import org.cyclops.integratedscripting.client.key.Keys; /** * Proxy for the client side. @@ -21,4 +24,10 @@ public ModBase getMod() { return IntegratedScripting._instance; } + @Override + public void registerKeyBindings(IKeyRegistry keyRegistry, RegisterKeyMappingsEvent event) { + super.registerKeyBindings(keyRegistry, event); + Keys.ALL.forEach(event::register); + } + } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index f9f8eae81..866aace75 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -33,11 +33,6 @@ "gui.integratedscripting.find.previous": "Previous match (Shift+Enter)", "gui.integratedscripting.find.next": "Next match (Enter)", "gui.integratedscripting.find.close": "Close (Escape)", - "gui.integratedscripting.action.undo": "Undo (%s+Z)", - "gui.integratedscripting.action.redo": "Redo (%s+Y)", - "gui.integratedscripting.action.find": "Find (%s+F)", - "gui.integratedscripting.action.goto_line": "Go to line (%s+G)", - "gui.integratedscripting.action.comment": "Toggle comment (%s+/)", "gui.integratedscripting.removal_dialog.title": "Confirm script deletion", "gui.integratedscripting.removal_dialog.message": "Do you want to delete \"%s\"?", "gui.integratedscripting.removal_dialog.delete": "Delete", @@ -48,6 +43,15 @@ "gui.integratedscripting.binding.next": "Binds to: %s", "gui.integratedscripting.binding.card": "Bound to: %s", + "key.categories.integratedscripting": "Integrated Scripting", + "key.integratedscripting.undo": "Undo", + "key.integratedscripting.redo": "Redo", + "key.integratedscripting.find": "Find", + "key.integratedscripting.goto_line": "Go to line", + "key.integratedscripting.comment": "Toggle comment", + "key.integratedscripting.move_lines_up": "Move lines up", + "key.integratedscripting.move_lines_down": "Move lines down", + "_comment": "Value Type Translation", "valuetype.integratedscripting.error.translation.unknown_to_graal": "Could not find a translator from %s values to Graal.", "valuetype.integratedscripting.error.translation.unknown_to_graal_nbt": "Could not find a translator from %s values to Graal during NBT translation.", diff --git a/src/test/java/org/cyclops/integratedscripting/client/key/KeysTest.java b/src/test/java/org/cyclops/integratedscripting/client/key/KeysTest.java new file mode 100644 index 000000000..a56ad95af --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/client/key/KeysTest.java @@ -0,0 +1,47 @@ +package org.cyclops.integratedscripting.client.key; + +import net.minecraft.client.Minecraft; +import org.junit.Test; +import org.lwjgl.glfw.GLFW; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author rubensworks + */ +public class KeysTest { + + private static final int CONTROL = Minecraft.ON_OSX ? GLFW.GLFW_MOD_SUPER : GLFW.GLFW_MOD_CONTROL; + + @Test + public void testMatchesKeyWithModifier() { + assertTrue(Keys.matches(Keys.UNDO, GLFW.GLFW_KEY_Z, 0, CONTROL)); + assertTrue(Keys.matches(Keys.MOVE_LINES_UP, GLFW.GLFW_KEY_UP, 0, GLFW.GLFW_MOD_ALT)); + } + + @Test + public void testIgnoresLockModifiers() { + assertTrue(Keys.matches(Keys.UNDO, GLFW.GLFW_KEY_Z, 0, CONTROL | GLFW.GLFW_MOD_NUM_LOCK | GLFW.GLFW_MOD_CAPS_LOCK)); + } + + @Test + public void testRejectsMissingOrOtherModifiers() { + assertFalse(Keys.matches(Keys.UNDO, GLFW.GLFW_KEY_Z, 0, 0)); + assertFalse(Keys.matches(Keys.UNDO, GLFW.GLFW_KEY_Z, 0, GLFW.GLFW_MOD_ALT)); + assertFalse(Keys.matches(Keys.MOVE_LINES_UP, GLFW.GLFW_KEY_UP, 0, 0)); + } + + @Test + public void testRejectsExtraModifiers() { + // Ctrl+Shift+Z is redo, so it must not undo + assertFalse(Keys.matches(Keys.UNDO, GLFW.GLFW_KEY_Z, 0, CONTROL | GLFW.GLFW_MOD_SHIFT)); + assertFalse(Keys.matches(Keys.MOVE_LINES_UP, GLFW.GLFW_KEY_UP, 0, GLFW.GLFW_MOD_ALT | GLFW.GLFW_MOD_SHIFT)); + } + + @Test + public void testRejectsOtherKeys() { + assertFalse(Keys.matches(Keys.UNDO, GLFW.GLFW_KEY_Y, 0, CONTROL)); + } + +} From db8c8e65673283d3bcd20fbf724453c55d0f6fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:03:49 +0000 Subject: [PATCH 30/59] Improve syntax highlighting in the script editor Keywords were highlighted by substring, so they also matched inside other words (var in variable, return in returnValue), strings and comments, while strings, numbers and comments were not highlighted at all. Highlighting was also done per wrapped line, so it couldn't handle constructs spanning multiple lines. Tokenize the full script instead, with word boundaries, strings, numbers, and line and block comments, and add a markupScript method to language handlers for this. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../api/language/ILanguageHandler.java | 18 ++++ .../gui/component/input/WidgetTextArea.java | 58 ++++++++++- .../ContainerScreenTerminalScripting.java | 5 +- .../language/LanguageHandlerJavaScript.java | 96 ++++++++++++------ .../LanguageHandlerJavaScriptMarkupTest.java | 97 +++++++++++++++++++ 5 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index 8b47c9c7c..dad294ad5 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -10,6 +10,7 @@ import javax.annotation.Nullable; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; @@ -29,6 +30,23 @@ public interface ILanguageHandler { public List> markupLine(String line); + /** + * Mark up a full script, which allows constructs such as block comments to span multiple lines. + * @param script The script contents. + * @return Styled segments that together form the full script. + */ + public default List> markupScript(String script) { + List> segments = new ArrayList<>(); + String[] lines = script.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + segments.add(Pair.of(Style.EMPTY, "\n")); + } + segments.addAll(markupLine(lines[i])); + } + return segments; + } + public IScriptFactory getScriptFactory() throws EvaluationException; /** diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index e664e42a1..91540415b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -956,10 +956,13 @@ private DisplayCache rebuildDisplayCache() { } } + // Mark up the full text at once, so that constructs such as block comments can span multiple lines + Markup markup = new Markup(this.markup(s)); List linesAll = Lists.newArrayList(); for (int k3 = 0; k3 < linesRaw.size(); k3++) { Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, (k3 - this.firstRow) * 9)); // Offset firstRow! - linesAll.add(new LineInfo(this.markupLine(linesRaw.get(k3).getRight(), linesRaw.get(k3).getLeft()), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumbers.getInt(k3), linesHasCursor.get(k3))); + int lineStartPos = linePositionsOld.getInt(k3); + linesAll.add(new LineInfo(markup.slice(s, lineStartPos, lineStartPos + linesRaw.get(k3).getLeft().length()), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumbers.getInt(k3), linesHasCursor.get(k3))); } // --- Changed --- @@ -1018,11 +1021,52 @@ private DisplayCache rebuildDisplayCache() { } } - private List> markupLine(Style style, String line) { + private List> markup(String text) { if (this.markupProvider != null) { - return this.markupProvider.markupLine(style, line); + List> segments = this.markupProvider.markup(text); + // Ignore markup that doesn't match the text + if (segments.stream().mapToInt(segment -> segment.getRight().length()).sum() == text.length()) { + return segments; + } + } + return Stream.of(Pair.of(Style.EMPTY, text)).collect(Collectors.toList()); + } + + /** + * Styled segments of a text, which can be sliced by position. + */ + static class Markup { + private final int[] starts; + private final Style[] styles; + + Markup(List> segments) { + this.starts = new int[segments.size()]; + this.styles = new Style[segments.size()]; + int pos = 0; + for (int i = 0; i < segments.size(); i++) { + this.starts[i] = pos; + this.styles[i] = segments.get(i).getLeft(); + pos += segments.get(i).getRight().length(); + } + } + + List> slice(String text, int from, int to) { + List> result = Lists.newArrayList(); + if (from >= to) { + return result; + } + int i = Math.max(0, findLineFromPos(this.starts, from)); + while (i < this.starts.length && this.starts[i] < to) { + int segmentEnd = i + 1 < this.starts.length ? this.starts[i + 1] : text.length(); + int start = Math.max(from, this.starts[i]); + int end = Math.min(to, segmentEnd); + if (start < end) { + result.add(Pair.of(this.styles[i], text.substring(start, end))); + } + i++; + } + return result; } - return Stream.of(Pair.of(style, line)).collect(Collectors.toList()); } static int findLineFromPos(int[] p_98150_, int p_98151_) { @@ -1142,7 +1186,11 @@ static class Pos2i { } public static interface IMarkupProvider { - public List> markupLine(Style style, String line); + /** + * @param text The full text. + * @return Styled segments that together form the full text. + */ + public List> markup(String text); } public static interface ShortcutListener { diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index a5fcd0cdf..82603fa4c 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -13,6 +13,7 @@ import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.StringUtil; @@ -158,10 +159,10 @@ public int getTotalRows() { }); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); - textArea.setMarkupProvider((style, line) -> { + textArea.setMarkupProvider(text -> { Path path = getMenu().getActiveScriptPath(); ILanguageHandler languageHandler = path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; - return languageHandler != null ? languageHandler.markupLine(line) : Stream.of(Pair.of(style, line)).collect(Collectors.toList()); + return languageHandler != null ? languageHandler.markupScript(text) : Stream.of(Pair.of(Style.EMPTY, text)).collect(Collectors.toList()); }); addRenderableWidget(textArea); diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index 0d1e4e166..8c4c09c84 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -43,6 +43,7 @@ public class LanguageHandlerJavaScript implements ILanguageHandler { public static final Style SYMBOL = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(99, 163, 92))); // Green public static final Style CONSTANT = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(0, 134, 179))); // Blue public static final Style KEYWORD = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(167, 29, 93))); // Red-purple + public static final Style STRING = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(24, 54, 145))); // Dark blue // Keywords and literals, which can never be the name of a member in a (non-strict) script public static final Set RESERVED_WORDS = Set.of( @@ -58,14 +59,17 @@ public class LanguageHandlerJavaScript implements ILanguageHandler { public LanguageHandlerJavaScript() { this.tokenStyles = Maps.newHashMap(); - this.tokenStyles.put("const", KEYWORD); - this.tokenStyles.put("delete", KEYWORD); - this.tokenStyles.put("let", KEYWORD); - this.tokenStyles.put("var", KEYWORD); - this.tokenStyles.put("function", KEYWORD); - this.tokenStyles.put("return", KEYWORD); - this.tokenStyles.put("true", KEYWORD); - this.tokenStyles.put("false", KEYWORD); + for (String keyword : new String[]{ + "const", "delete", "let", "var", "function", "return", "if", "else", "for", "while", "do", "break", + "continue", "switch", "case", "default", "new", "this", "typeof", "instanceof", "in", "of", "class", + "extends", "super", "import", "export", "from", "try", "catch", "finally", "throw", "async", "await", + "yield", "void" + }) { + this.tokenStyles.put(keyword, KEYWORD); + } + for (String constant : new String[]{"true", "false", "null", "undefined"}) { + this.tokenStyles.put(constant, CONSTANT); + } this.tokenStyles.put("{", SYMBOL); this.tokenStyles.put("}", SYMBOL); @@ -97,36 +101,68 @@ public List getExtensions() { @Override public List> markupLine(String line) { - // TODO: in the future, write/use JS lexer/tokenizer + return this.markupScript(line); + } + @Override + public List> markupScript(String script) { List> segments = Lists.newArrayList(); - segments.add(Pair.of(Style.EMPTY, line)); - - // Split up string by tokens - for (Map.Entry entry : this.tokenStyles.entrySet()) { - String token = entry.getKey(); - Style tokenStyle = entry.getValue(); - List> segmentsNew = Lists.newArrayList(); - - for (Pair segment : segments) { - Style styleOriginal = segment.getLeft(); - String hayStack = segment.getRight(); - int pos; - while ((pos = hayStack.indexOf(token)) >= 0) { - segmentsNew.add(Pair.of(styleOriginal, hayStack.substring(0, pos))); - segmentsNew.add(Pair.of(tokenStyle, hayStack.substring(pos, pos + token.length()))); - hayStack = hayStack.substring(pos + token.length()); + int length = script.length(); + int i = 0; + while (i < length) { + char c = script.charAt(i); + int end; + Style style; + if (c == '/' && i + 1 < length && script.charAt(i + 1) == '/') { + end = script.indexOf('\n', i); + end = end < 0 ? length : end; + style = COMMENT; + } else if (c == '/' && i + 1 < length && script.charAt(i + 1) == '*') { + end = script.indexOf("*/", i + 2); + end = end < 0 ? length : end + 2; + style = COMMENT; + } else if (c == '"' || c == '\'' || c == '`') { + end = i + 1; + while (end < length && script.charAt(end) != c && (c == '`' || script.charAt(end) != '\n')) { + end += script.charAt(end) == '\\' ? 2 : 1; } - - segmentsNew.add(Pair.of(styleOriginal, hayStack)); + if (end < length && script.charAt(end) == c) { + end++; + } + end = Math.min(length, end); + style = STRING; + } else if (Character.isDigit(c)) { + end = i + 1; + while (end < length && (Character.isLetterOrDigit(script.charAt(end)) || script.charAt(end) == '.' || script.charAt(end) == '_')) { + end++; + } + style = CONSTANT; + } else if (Character.isJavaIdentifierStart(c)) { + end = i + 1; + while (end < length && Character.isJavaIdentifierPart(script.charAt(end))) { + end++; + } + style = this.tokenStyles.getOrDefault(script.substring(i, end), Style.EMPTY); + } else { + end = i + 1; + style = this.tokenStyles.getOrDefault(String.valueOf(c), Style.EMPTY); } - - segments = segmentsNew; + addSegment(segments, style, script.substring(i, end)); + i = end; } - return segments; } + private static void addSegment(List> segments, Style style, String text) { + // Merge with the previous segment if it has the same style + if (!segments.isEmpty() && segments.get(segments.size() - 1).getLeft().equals(style)) { + Pair last = segments.remove(segments.size() - 1); + segments.add(Pair.of(style, last.getRight() + text)); + } else { + segments.add(Pair.of(style, text)); + } + } + @Override public Set getReservedWords() { return RESERVED_WORDS; diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java new file mode 100644 index 000000000..20e782f9b --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java @@ -0,0 +1,97 @@ +package org.cyclops.integratedscripting.core.language; + +import net.minecraft.network.chat.Style; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.Test; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +public class LanguageHandlerJavaScriptMarkupTest { + + private static final LanguageHandlerJavaScript HANDLER = new LanguageHandlerJavaScript(); + + private static List> markup(String script) { + List> segments = HANDLER.markupScript(script); + assertThat(segments.stream().map(Pair::getRight).collect(Collectors.joining()), equalTo(script)); + return segments; + } + + private static Style styleOf(String script, String token) { + int pos = script.indexOf(token); + int i = 0; + for (Pair segment : markup(script)) { + if (pos < i + segment.getRight().length()) { + return segment.getLeft(); + } + i += segment.getRight().length(); + } + throw new AssertionError("Token not found"); + } + + @Test + public void testKeyword() { + assertThat(styleOf("const a = 1;", "const"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testKeywordInsideIdentifier() { + assertThat(styleOf("const variable = 1;", "variable"), equalTo(Style.EMPTY)); + assertThat(styleOf("let outlet = 1;", "outlet"), equalTo(Style.EMPTY)); + assertThat(styleOf("returnValue();", "returnValue"), equalTo(Style.EMPTY)); + } + + @Test + public void testSymbol() { + assertThat(styleOf("f();", "("), equalTo(LanguageHandlerJavaScript.SYMBOL)); + } + + @Test + public void testNumber() { + assertThat(styleOf("x = 1.5;", "1.5"), equalTo(LanguageHandlerJavaScript.CONSTANT)); + assertThat(markup("x = 1.5;").stream().anyMatch(s -> s.getRight().equals("1.5")), equalTo(true)); + } + + @Test + public void testConstant() { + assertThat(styleOf("return true;", "true"), equalTo(LanguageHandlerJavaScript.CONSTANT)); + } + + @Test + public void testStrings() { + assertThat(styleOf("x = \"let it be\";", "let"), equalTo(LanguageHandlerJavaScript.STRING)); + assertThat(styleOf("x = 'delete';", "delete"), equalTo(LanguageHandlerJavaScript.STRING)); + assertThat(styleOf("x = `a ${b}`;", "b"), equalTo(LanguageHandlerJavaScript.STRING)); + } + + @Test + public void testEscapedQuote() { + assertThat(styleOf("x = 'it\\'s var';", "var"), equalTo(LanguageHandlerJavaScript.STRING)); + } + + @Test + public void testUnterminatedStringEndsAtLine() { + assertThat(styleOf("x = 'abc\nconst y;", "const"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testLineComment() { + assertThat(styleOf("// const a\nconst b;", "const a"), equalTo(LanguageHandlerJavaScript.COMMENT)); + assertThat(styleOf("// const a\nconst b;", "const b"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testBlockCommentSpansLines() { + assertThat(styleOf("/* a\nreturn b */ return", "return b"), equalTo(LanguageHandlerJavaScript.COMMENT)); + assertThat(styleOf("/* a\nreturn b */ let", "let"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testMarkupLineCompatible() { + assertThat(HANDLER.markupLine("const a;"), equalTo(HANDLER.markupScript("const a;"))); + } + +} From bf7170f41e8f94ce1678bc62756c014b1855e002 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:43:32 +0000 Subject: [PATCH 31/59] Document the script editor shortcuts in the info book The shortcuts were only discoverable through the toolbar tooltips. A new info book section lists them with the keybinding appendix, so it shows the keys as currently bound, plus the keys that can not be changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../assets/integratedscripting/lang/en_us.json | 4 ++++ .../integratedscripting/info/scripting_info.xml | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 866aace75..7f0ff2f4f 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -138,6 +138,10 @@ "info_book.integratedscripting.terminal.text10": "After that, you can insert a &lVariable Card&r into the slot on the right-hand side of the screen to bind it to this script member.", "info_book.integratedscripting.terminal.text11": "This &lVariable Card&r can now be used elsewhere in the network, by for example showing a variable member inside a &lDisplay Panel&r, or by using a function member as an operator in an item list filter.", "info_book.integratedscripting.terminal.text12": "The next chapter will provide more details on how to write scripts.", + "info_book.integratedscripting.terminal_shortcuts": "Editor Shortcuts", + "info_book.integratedscripting.terminal_shortcuts.text1": "The script editor of the &lScripting Terminal&r has the keyboard shortcuts shown below. You can change them in the &oControls&r settings, under &oIntegrated Scripting&r. Most of these actions are also available as buttons above the editor.", + "info_book.integratedscripting.terminal_shortcuts.text2": "Some keys can not be changed: &oTab&r and &oShift+Tab&r indent and unindent the selected lines, &oPage Up&r and &oPage Down&r move by a page, &oCtrl+Left&r and &oCtrl+Right&r (&oOption&r on Mac) move by word, &oHome&r and &oEnd&r (also &oCmd+Left&r and &oCmd+Right&r on Mac) move to the start or end of the line, &oCtrl+Home&r and &oCtrl+End&r (also &oCmd+Up&r and &oCmd+Down&r on Mac) move to the start or end of the script, and &oCtrl+Shift+Z&r (&oCmd+Shift+Z&r on Mac) also redoes.", + "info_book.integratedscripting.terminal_shortcuts.text3": "While finding text or going to a line, &oEnter&r and &oShift+Enter&r go to the next and previous match, and &oEscape&r closes the search bar.", "info_book.integratedscripting.writing": "Writing Scripts", diff --git a/src/main/resources/data/integratedscripting/info/scripting_info.xml b/src/main/resources/data/integratedscripting/info/scripting_info.xml index b1ce31129..84b7818ef 100644 --- a/src/main/resources/data/integratedscripting/info/scripting_info.xml +++ b/src/main/resources/data/integratedscripting/info/scripting_info.xml @@ -55,6 +55,19 @@ integratedscripting:crafting/part_terminal_scripting +
+ info_book.integratedscripting.terminal_shortcuts.text1 + info_book.integratedscripting.terminal_shortcuts.text2 + info_book.integratedscripting.terminal_shortcuts.text3 + key.integratedscripting.undo + key.integratedscripting.redo + key.integratedscripting.find + key.integratedscripting.goto_line + key.integratedscripting.comment + key.integratedscripting.move_lines_up + key.integratedscripting.move_lines_down +
+
info_book.integratedscripting.writing.js.text1 From 7c6fa7198893ff1b1f2616c9259d2de8c435fd95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:50:41 +0000 Subject: [PATCH 32/59] Show syntax errors in the script editor Syntax errors only showed up when a variable card using the script was evaluated somewhere, for example as an error in a display panel. Check the active script for syntax errors on the server, without executing it, once it hasn't changed for half a second. Mark the line of the first error with a red line number and gutter background, mark its position on the scrollbar, and show its message when hovering it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../api/language/ILanguageHandler.java | 13 ++++ .../api/language/ScriptSyntaxError.java | 11 +++ .../gui/component/input/WidgetTextArea.java | 53 +++++++++++++- .../ContainerScreenTerminalScripting.java | 6 ++ .../language/LanguageHandlerJavaScript.java | 29 ++++++++ .../container/ContainerTerminalScripting.java | 64 +++++++++++++++++ .../TerminalScriptingSyntaxErrorPacket.java | 71 +++++++++++++++++++ .../proxy/CommonProxy.java | 2 + .../integratedscripting/lang/en_us.json | 2 +- .../LanguageHandlerJavaScriptSyntaxTest.java | 43 +++++++++++ 10 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java create mode 100644 src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java create mode 100644 src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index 70f75810e..8b47c9c7c 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -8,6 +8,8 @@ import org.cyclops.integrateddynamics.api.evaluate.EvaluationException; import org.cyclops.integratedscripting.api.network.IScriptFactory; +import javax.annotation.Nullable; +import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Set; @@ -36,6 +38,17 @@ public default Set getReservedWords() { return Collections.emptySet(); } + /** + * Check a script for syntax errors, without executing it. + * @param path The script path. + * @param script The script contents. + * @return The first syntax error, or null if there is none or if checking is not supported. + */ + @Nullable + public default ScriptSyntaxError checkSyntax(Path path, String script) { + return null; + } + public void onServerStarted(); } diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java new file mode 100644 index 000000000..b41bb28a3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java @@ -0,0 +1,11 @@ +package org.cyclops.integratedscripting.api.language; + +/** + * A syntax error in a script. + * @param message The error message. + * @param line The 1-based line of the error. + * @param column The 1-based column of the error. + * @author rubensworks + */ +public record ScriptSyntaxError(String message, int line, int column) { +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 152c70c06..e664e42a1 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,6 +39,7 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.client.key.Keys; import org.lwjgl.glfw.GLFW; @@ -107,6 +108,8 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private String indent = " "; @Nullable private ShortcutListener shortcutListener; + @Nullable + private ScriptSyntaxError syntaxError; public WidgetTextArea(Font font, int x, int y, int width, int height, Component narrationMessage, boolean scrollBar, boolean showLineNumbers) { super(font, x, y, width, height, narrationMessage); @@ -208,6 +211,10 @@ public void selectRange(int start, int end) { this.ensureCursorVisible(); } + public void setSyntaxError(@Nullable ScriptSyntaxError syntaxError) { + this.syntaxError = syntaxError; + } + /** * @param indent The string that is inserted for one indentation level. */ @@ -747,6 +754,16 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float } } + // Mark the gutter of the line with a syntax error + if (this.syntaxError != null && this.showLineNumbers) { + for (LineInfo line : lines) { + if (line.lineNumber + 1 == this.syntaxError.line()) { + // Fill the space between the editor border and the gutter separator + guiGraphics.fill(this.getX() - 1, line.y - 1, this.getX() + getLinesXOffset() - 2, line.y + ROW_HEIGHT - 1, Helpers.RGBAToInt(255, 160, 160, 255)); + } + } + } + // Draw lines int lastLineNumber = -1; for(LineInfo line : lines) { @@ -760,7 +777,7 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float this.getX(), line.y - offsetY + 2, 0.5f, - line.hasCursor ? 0 : Helpers.RGBToInt(120, 120, 120), + this.syntaxError != null && this.syntaxError.line() == line.lineNumber + 1 ? Helpers.RGBToInt(170, 0, 0) : (line.hasCursor ? 0 : Helpers.RGBToInt(120, 120, 120)), false, Font.DisplayMode.NORMAL ); @@ -768,6 +785,10 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float lastLineNumber = line.lineNumber; } + if (this.syntaxError != null) { + this.renderSyntaxError(guiGraphics, displayCache, lines, mouseX, mouseY); + } + // Show highlighting and cursor this.renderHighlight(displayCache.selection); if (displayCache.cursor != null) { @@ -777,6 +798,36 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float // Render scrollbar if (this.scrollBar != null) { this.scrollBar.render(guiGraphics, getX(), getY(), partialTicks); + + // Mark the position of the syntax error on the scrollbar, so it can be found when scrolled out of view + if (this.syntaxError != null && displayCache.linesTotal > this.scrollBar.getVisibleRows()) { + int errorRow = findLineFromPos(displayCache.lineStarts, TextEditing.lineOffset(this.getValue(), this.syntaxError.line())); + int y = this.getY() + (int) ((long) errorRow * (this.getHeight() - 2) / displayCache.linesTotal); + int x = this.getX() + this.getWidth() - SCROLLBAR_WIDTH + 1; + guiGraphics.fill(x, y, x + SCROLLBAR_WIDTH - 2, y + 2, Helpers.RGBAToInt(220, 0, 0, 255)); + } + } + } + + private void renderSyntaxError(GuiGraphics guiGraphics, DisplayCache displayCache, List lines, int mouseX, int mouseY) { + String value = this.getValue(); + // Underline the character at the error position + int pos = Math.min(value.length(), TextEditing.lineOffset(value, this.syntaxError.line()) + Math.max(0, this.syntaxError.column() - 1)); + int line = findLineFromPos(displayCache.lineStarts, pos); + int row = line - this.firstRow; + if (row >= 0 && row < this.getHeight() / ROW_HEIGHT) { + int x = this.font.width(value.substring(displayCache.lineStarts[line], pos)); + int width = pos < value.length() && value.charAt(pos) != '\n' ? this.font.width(value.substring(pos, pos + 1)) : 4; + Pos2i screenPos = this.convertLocalToScreen(new Pos2i(x, row * ROW_HEIGHT)); + guiGraphics.fill(screenPos.x, screenPos.y + ROW_HEIGHT - 2, screenPos.x + width, screenPos.y + ROW_HEIGHT - 1, Helpers.RGBAToInt(220, 0, 0, 255)); + } + + // Show the message when hovering the error line + for (LineInfo lineInfo : lines) { + if (lineInfo.lineNumber + 1 == this.syntaxError.line() && mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() + && mouseY >= lineInfo.y && mouseY < lineInfo.y + ROW_HEIGHT && Minecraft.getInstance().screen != null) { + Minecraft.getInstance().screen.setTooltipForNextRenderPass(this.font.split(Component.literal(this.syntaxError.message()), 200)); + } } } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 9083a1517..a5fcd0cdf 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -151,6 +151,11 @@ public int getTotalRows() { return false; }); getMenu().setRemoteScriptChangeListener(this::onRemoteScriptChanged); + getMenu().setSyntaxErrorListener((disk, path) -> { + if (disk == getMenu().getActiveDisk() && path.equals(getMenu().getActiveScriptPath())) { + this.textArea.setSyntaxError(getMenu().getSyntaxError(disk, path)); + } + }); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); textArea.setMarkupProvider((style, line) -> { @@ -579,6 +584,7 @@ private void onActiveScriptSelected() { String script = getMenu().getActiveScript(); this.textArea.setValue(script == null ? "" : script); Path path = getMenu().getActiveScriptPath(); + this.textArea.setSyntaxError(path == null ? null : getMenu().getSyntaxError(getMenu().getActiveDisk(), path)); this.textArea.setHistory(path == null ? new TextHistory() : scriptHistories.computeIfAbsent(Pair.of(getMenu().getActiveDisk(), path), k -> new TextHistory())); } diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index 6c3e6d5c0..0d1e4e166 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -12,9 +12,17 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.api.network.IScriptFactory; import org.cyclops.integratedscripting.client.gui.image.ScriptImages; import org.cyclops.integratedscripting.core.network.GraalScriptFactory; +import org.cyclops.integratedscripting.evaluate.ScriptHelpers; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.PolyglotException; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.SourceSection; + +import javax.annotation.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -129,6 +137,27 @@ public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; } + @Nullable + @Override + public ScriptSyntaxError checkSyntax(Path path, String script) { + try (Context context = ScriptHelpers.createBaseContext(null)) { + // Parsing doesn't execute the script + context.parse(Source.newBuilder("js", script, path.toString()).build()); + return null; + } catch (PolyglotException e) { + if (!e.isSyntaxError()) { + return null; + } + SourceSection location = e.getSourceLocation(); + // Only keep the first line, as the following lines contain a source excerpt + return new ScriptSyntaxError(e.getMessage().split("\n", 2)[0], + location != null && location.hasLines() ? location.getStartLine() : 1, + location != null && location.hasColumns() ? location.getStartColumn() : 1); + } catch (IOException e) { + return null; + } + } + @Override public void onServerStarted() { this.generateTypeScriptTypes(); diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index e41ac3169..5e216c843 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -18,6 +18,7 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; import org.cyclops.cyclopscore.helper.ValueNotifierHelpers; import org.cyclops.cyclopscore.inventory.SimpleInventory; import org.cyclops.cyclopscore.inventory.container.InventoryContainer; @@ -36,6 +37,7 @@ import org.cyclops.integratedscripting.RegistryEntries; import org.cyclops.integratedscripting.api.item.IScriptVariableFacade; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.api.network.IScriptingData; import org.cyclops.integratedscripting.api.network.IScriptingNetwork; import org.cyclops.integratedscripting.core.evaluate.ScriptVariableFacadeHandler; @@ -45,6 +47,7 @@ import org.cyclops.integratedscripting.network.packet.TerminalScriptingCreateNewScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingSyntaxErrorPacket; import org.cyclops.integratedscripting.part.PartTypeTerminalScripting; import javax.annotation.Nullable; @@ -59,6 +62,8 @@ */ public class ContainerTerminalScripting extends InventoryContainer implements IDirtyMarkListener { + public static final int SYNTAX_CHECK_DELAY_TICKS = 10; + public static Pattern INVALID_MEMBER_NAME = Pattern.compile("[^0-9a-zA-Z_]"); public static Pattern VALID_MEMBER_NAME = Pattern.compile("[0-9a-zA-Z_]+"); @@ -77,6 +82,14 @@ public class ContainerTerminalScripting extends InventoryContainer implements ID private final int selectionId; @Nullable private BiConsumer remoteScriptChangeListener; + private final Map, ScriptSyntaxError> syntaxErrors = Maps.newHashMap(); + @Nullable + private BiConsumer syntaxErrorListener; + @Nullable + private Triple syntaxCheckPending; + @Nullable + private Triple syntaxCheckDone; + private int syntaxCheckTicks; public ContainerTerminalScripting(int id, Inventory playerInventory, RegistryFriendlyByteBuf packetBuffer) { this(id, playerInventory, PartHelpers.readPartTarget(packetBuffer), Optional.empty(), @@ -220,7 +233,58 @@ public void broadcastChanges() { } } }); + + this.checkActiveScriptSyntax(); + } + } + + /** + * Check the active script for syntax errors once it hasn't changed for a while, and send the result to the client. + */ + protected void checkActiveScriptSyntax() { + Path path = getActiveScriptPath(); + int disk = getActiveDisk(); + if (path == null || disk < 0) { + return; + } + String script = ScriptingNetworkHelpers.getScriptingData().getScripts(disk).get(path); + if (script == null) { + return; } + Triple key = Triple.of(disk, path, script); + if (!key.equals(this.syntaxCheckPending)) { + this.syntaxCheckPending = key; + this.syntaxCheckTicks = 0; + return; + } + if (key.equals(this.syntaxCheckDone) || ++this.syntaxCheckTicks < SYNTAX_CHECK_DELAY_TICKS) { + return; + } + this.syntaxCheckDone = key; + ILanguageHandler languageHandler = LanguageHandlers.REGISTRY.getProvider(path); + ScriptSyntaxError error = languageHandler == null ? null : languageHandler.checkSyntax(path, script); + IntegratedScripting._instance.getPacketHandler() + .sendToPlayer(new TerminalScriptingSyntaxErrorPacket(disk, path, error), (ServerPlayer) player); + } + + public void setSyntaxErrorListener(@Nullable BiConsumer syntaxErrorListener) { + this.syntaxErrorListener = syntaxErrorListener; + } + + public void setSyntaxError(int disk, Path path, @Nullable ScriptSyntaxError error) { + if (error == null) { + this.syntaxErrors.remove(Pair.of(disk, path)); + } else { + this.syntaxErrors.put(Pair.of(disk, path), error); + } + if (this.syntaxErrorListener != null) { + this.syntaxErrorListener.accept(disk, path); + } + } + + @Nullable + public ScriptSyntaxError getSyntaxError(int disk, Path path) { + return this.syntaxErrors.get(Pair.of(disk, path)); } @Override diff --git a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java new file mode 100644 index 000000000..eacb71ecc --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingSyntaxErrorPacket.java @@ -0,0 +1,71 @@ +package org.cyclops.integratedscripting.network.packet; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.cyclopscore.network.CodecField; +import org.cyclops.cyclopscore.network.PacketCodec; +import org.cyclops.integratedscripting.Reference; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; +import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScripting; + +import javax.annotation.Nullable; +import java.nio.file.Path; + +/** + * Packet for sending the syntax error of a script from server to client. + * @author rubensworks + * + */ +public class TerminalScriptingSyntaxErrorPacket extends PacketCodec { + + public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "terminal_scripting_syntax_error")); + public static final StreamCodec CODEC = getCodec(TerminalScriptingSyntaxErrorPacket::new); + + @CodecField + private int disk; + @CodecField + private String path; + @CodecField + private String message; + @CodecField + private int line; + @CodecField + private int column; + + public TerminalScriptingSyntaxErrorPacket() { + super(ID); + } + + public TerminalScriptingSyntaxErrorPacket(int disk, Path path, @Nullable ScriptSyntaxError error) { + super(ID); + this.disk = disk; + this.path = path.toString(); + this.message = error == null ? "" : error.message(); + this.line = error == null ? 0 : error.line(); + this.column = error == null ? 0 : error.column(); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + @OnlyIn(Dist.CLIENT) + public void actionClient(Level world, Player player) { + if(player.containerMenu instanceof ContainerTerminalScripting container) { + container.setSyntaxError(disk, Path.of(path), message.isEmpty() ? null : new ScriptSyntaxError(message, line, column)); + } + } + + @Override + public void actionServer(Level world, ServerPlayer player) { + + } +} diff --git a/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java b/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java index 826341979..9f579a612 100644 --- a/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java +++ b/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java @@ -7,6 +7,7 @@ import org.cyclops.integratedscripting.network.packet.TerminalScriptingCreateNewScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingSyntaxErrorPacket; /** * Proxy for server and client side. @@ -27,5 +28,6 @@ public void registerPacketHandlers(PacketHandler packetHandler) { packetHandler.register(TerminalScriptingModifiedScriptPacket.ID, TerminalScriptingModifiedScriptPacket.CODEC); packetHandler.register(TerminalScriptingCreateNewScriptPacket.ID, TerminalScriptingCreateNewScriptPacket.CODEC); packetHandler.register(TerminalScriptingDeleteScriptPacket.ID, TerminalScriptingDeleteScriptPacket.CODEC); + packetHandler.register(TerminalScriptingSyntaxErrorPacket.ID, TerminalScriptingSyntaxErrorPacket.CODEC); } } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 7f0ff2f4f..126856f1f 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -132,7 +132,7 @@ "info_book.integratedscripting.terminal.text4": "If the disk does not contain any script files yet, you can create them by clicking on the \"+\"-button on the bottom-left side of the screen.", "info_book.integratedscripting.terminal.text5": "The script file list on the left-hand side of the screen is used to select a script for editing. When clicking on a script, its editable contents will show up on the right-hand-side of the screen.", "info_book.integratedscripting.terminal.text6": "The text field on the right-hand side of the screen allows you to edit the contents of script files, which will be saved automatically after each modification.", - "info_book.integratedscripting.terminal.text7": "While the editor will perform limited syntax highlighting, it is not able to detect syntax errors at this time. Syntax errors will be reported when executing scripts via &lVariable Cards&r.", + "info_book.integratedscripting.terminal.text7": "The editor performs limited syntax highlighting, and marks the line of the first syntax error in red. Hover over that line to see the error message.", "info_book.integratedscripting.terminal.text8": "In order to use a script member as &lIntegrated Dynamics&r logic, you can bind it to a &lVariable Card&r.", "info_book.integratedscripting.terminal.text9": "You can do this by first selecting a script member in the text editor by selecting it with your cursor, such as a variable name or function name.", "info_book.integratedscripting.terminal.text10": "After that, you can insert a &lVariable Card&r into the slot on the right-hand side of the screen to bind it to this script member.", diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java new file mode 100644 index 000000000..87fb24ef0 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptSyntaxTest.java @@ -0,0 +1,43 @@ +package org.cyclops.integratedscripting.core.language; + +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; +import org.junit.Test; + +import java.nio.file.Path; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + +public class LanguageHandlerJavaScriptSyntaxTest { + + private static ScriptSyntaxError check(String script) { + return new LanguageHandlerJavaScript().checkSyntax(Path.of("script0.js"), script); + } + + @Test + public void testValid() { + assertThat(check("const a = 1;\nfunction f(b) { return a + b; }"), nullValue()); + } + + @Test + public void testValidDoesNotExecute() { + assertThat(check("throw new Error('runtime');"), nullValue()); + } + + @Test + public void testSyntaxError() { + ScriptSyntaxError error = check("const a = 1;\nconst = ;"); + assertThat(error.line(), equalTo(2)); + assertThat(error.column(), equalTo(7)); + assertTrue(error.message(), error.message().contains("SyntaxError")); + } + + @Test + public void testUnclosedBracket() { + ScriptSyntaxError error = check("function f() {\n return 1;\n"); + assertTrue(error.message(), error.message().contains("SyntaxError")); + } + +} From f105ac8b97d0a8a34845549f1caeedeee89ed137 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:08:12 +0000 Subject: [PATCH 33/59] Only re-wrap and re-highlight the editor text when it changes The full script was wrapped and highlighted again on every key press, including cursor movement, scrolling and selection changes, which took 9 ms for a 500-line script and 41 ms for a 2000-line script. Cache the text layout until the text changes, and only build the styled text of visible lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 139 ++++++++++++------ 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 91540415b..3bc4177e0 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -93,6 +93,8 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { @Nullable private WidgetScrollBar scrollBar; private int firstRow; + @Nullable + private TextLayout layout; private TextHistory history = new TextHistory(); private boolean draggingSelection; private double dragMouseX; @@ -159,7 +161,8 @@ public void setHeight(int height) { private DisplayCache createEmptyDisplayCache() { Pos2i pos = convertLocalToScreen(new Pos2i(0, 0)); - return new DisplayCache("", pos, true, new int[]{0}, new LineInfo[]{new LineInfo(Collections.emptyList(), pos.x, pos.y, 0, false)}, new LineInfo[]{new LineInfo(Collections.emptyList(), pos.x, pos.y, 0, false)}, new Rect2i[0], 0, 0); + TextLayout layout = new TextLayout("", new int[]{0}, new String[]{""}, new int[]{0}, new Markup(Collections.emptyList())); + return new DisplayCache("", pos, true, new int[]{0}, new LineInfo[]{new LineInfo(layout, 0, pos.x, pos.y, false)}, new LineInfo[]{new LineInfo(layout, 0, pos.x, pos.y, false)}, new Rect2i[0], 0, 0); } public void setFirstRow(int firstRow, boolean propagateToScrollbar) { @@ -251,10 +254,13 @@ public void setHistory(TextHistory history) { public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { this.markupProvider = markupProvider; + this.layout = null; } public void setValue(String value) { this.firstRow = 0; + // Another script may have the same contents but another language + this.layout = null; this.setValuePassive(value); textFieldHelper.setCursorToStart(); @@ -767,7 +773,7 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float // Draw lines int lastLineNumber = -1; for(LineInfo line : lines) { - guiGraphics.drawString(this.font, line.asComponent, line.x, line.y - offsetY, -16777216, false); + guiGraphics.drawString(this.font, line.getComponent(), line.x, line.y - offsetY, -16777216, false); // Draw line number if (this.showLineNumbers && lastLineNumber != line.lineNumber) { RenderHelpers.drawScaledString( @@ -920,50 +926,25 @@ private DisplayCache rebuildDisplayCache() { } else { int cursorPos = this.textFieldHelper.getCursorPos(); int selectionPos = this.textFieldHelper.getSelectionPos(); - IntList linePositionsOld = new IntArrayList(); - List> linesRaw = Lists.newArrayList(); - IntList lineNumbers = new IntArrayList(); - List linesHasCursor = Lists.newArrayList(); - MutableBoolean mutableboolean = new MutableBoolean(); - StringSplitter stringsplitter = this.font.getSplitter(); - MutableInt lineNumber = new MutableInt(); - stringsplitter.splitLines(s, getTextWidth() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { - String stringPart = s.substring(startPos, endPos); - boolean hasNewLine = stringPart.endsWith("\n"); - mutableboolean.setValue(hasNewLine); - linePositionsOld.add(startPos); - linesRaw.add(Pair.of(StringUtils.stripEnd(stringPart, " \n"), style)); - lineNumbers.add(lineNumber.intValue()); - linesHasCursor.add(cursorPos >= startPos && cursorPos < (hasNewLine ? endPos : s.indexOf("\n", endPos))); - if (hasNewLine) { - lineNumber.increment(); - } - }); - // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there - if (mutableboolean.isTrue()) { - linePositionsOld.add(s.length()); - linesRaw.add(Pair.of("", Style.EMPTY)); - lineNumbers.add(lineNumber.intValue()); - linesHasCursor.add(cursorPos == s.length()); - } + TextLayout layout = this.getLayout(s); // Keep scroll position within bounds, as the text may have shrunk if (this.scrollBar != null) { - int maxFirstRow = Math.max(0, linesRaw.size() - this.scrollBar.getVisibleRows()); + int maxFirstRow = Math.max(0, layout.lineStarts.length - this.scrollBar.getVisibleRows()); if (this.firstRow > maxFirstRow) { this.firstRow = maxFirstRow; this.scrollBar.setFirstRow(this.firstRow, false); } } - // Mark up the full text at once, so that constructs such as block comments can span multiple lines - Markup markup = new Markup(this.markup(s)); + int cursorLineNumber = layout.lineNumbers[findLineFromPos(layout.lineStarts, cursorPos)]; List linesAll = Lists.newArrayList(); - for (int k3 = 0; k3 < linesRaw.size(); k3++) { + for (int k3 = 0; k3 < layout.lineStarts.length; k3++) { Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, (k3 - this.firstRow) * 9)); // Offset firstRow! - int lineStartPos = linePositionsOld.getInt(k3); - linesAll.add(new LineInfo(markup.slice(s, lineStartPos, lineStartPos + linesRaw.get(k3).getLeft().length()), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumbers.getInt(k3), linesHasCursor.get(k3))); + linesAll.add(new LineInfo(layout, k3, bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, layout.lineNumbers[k3] == cursorLineNumber)); } + IntList linePositionsOld = IntArrayList.wrap(layout.lineStarts); + StringSplitter stringsplitter = this.font.getSplitter(); // --- Changed --- // Slice lines based on scroll position @@ -1021,6 +1002,39 @@ private DisplayCache rebuildDisplayCache() { } } + /** + * @return The wrapped and marked up lines of a text, which is only recomputed when the text changes. + */ + private TextLayout getLayout(String s) { + if (this.layout == null || !this.layout.text.equals(s)) { + IntList lineStarts = new IntArrayList(); + List contents = Lists.newArrayList(); + IntList lineNumbers = new IntArrayList(); + MutableBoolean trailingNewline = new MutableBoolean(); + MutableInt lineNumber = new MutableInt(); + this.font.getSplitter().splitLines(s, getTextWidth() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { + String stringPart = s.substring(startPos, endPos); + boolean hasNewLine = stringPart.endsWith("\n"); + trailingNewline.setValue(hasNewLine); + lineStarts.add(startPos); + contents.add(StringUtils.stripEnd(stringPart, " \n")); + lineNumbers.add(lineNumber.intValue()); + if (hasNewLine) { + lineNumber.increment(); + } + }); + // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there + if (trailingNewline.isTrue()) { + lineStarts.add(s.length()); + contents.add(""); + lineNumbers.add(lineNumber.intValue()); + } + // Mark up the full text at once, so that constructs such as block comments can span multiple lines + this.layout = new TextLayout(s, lineStarts.toIntArray(), contents.toArray(new String[0]), lineNumbers.toIntArray(), new Markup(this.markup(s))); + } + return this.layout; + } + private List> markup(String text) { if (this.markupProvider != null) { List> segments = this.markupProvider.markup(text); @@ -1148,29 +1162,64 @@ public int findLineEnd(int p_98219_) { } } + /** + * The wrapped lines of a text, independent of the scroll and cursor position. + */ + @OnlyIn(Dist.CLIENT) + static class TextLayout { + final String text; + final int[] lineStarts; + final String[] contents; + final int[] lineNumbers; + final Markup markup; + final Component[] components; + + TextLayout(String text, int[] lineStarts, String[] contents, int[] lineNumbers, Markup markup) { + this.text = text; + this.lineStarts = lineStarts; + this.contents = contents; + this.lineNumbers = lineNumbers; + this.markup = markup; + this.components = new Component[lineStarts.length]; + } + + /** + * Lazily create the styled component of a line, as only visible lines are rendered. + */ + Component getComponent(int line) { + if (this.components[line] == null) { + MutableComponent component = Component.literal(""); + for (Pair value : this.markup.slice(this.text, this.lineStarts[line], this.lineStarts[line] + this.contents[line].length())) { + component = component.append(Component.literal(value.getRight()).setStyle(value.getLeft())); + } + this.components[line] = component; + } + return this.components[line]; + } + } + @OnlyIn(Dist.CLIENT) static class LineInfo { - final List> contentsStyled; + private final TextLayout layout; + private final int line; final String contents; - final Component asComponent; final int x; final int y; final int lineNumber; final boolean hasCursor; - public LineInfo(List> contentsStyled, int x, int y, int lineNumber, boolean hasCursor) { - this.contentsStyled = contentsStyled; - this.contents = contentsStyled.stream().map(Pair::getRight).collect(Collectors.joining()); + public LineInfo(TextLayout layout, int line, int x, int y, boolean hasCursor) { + this.layout = layout; + this.line = line; + this.contents = layout.contents[line]; this.x = x; this.y = y; - this.lineNumber = lineNumber; + this.lineNumber = layout.lineNumbers[line]; this.hasCursor = hasCursor; + } - MutableComponent component = Component.literal(""); - for (Pair value : contentsStyled) { - component = component.append(Component.literal(value.getRight()).setStyle(value.getLeft())); - } - this.asComponent = component; + public Component getComponent() { + return this.layout.getComponent(this.line); } } From d5fe10eb44c0e6ad9213cf8bef0a5aa7c6c90452 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:27:29 +0000 Subject: [PATCH 34/59] Show full script paths when hovering the script list Paths in the script list are truncated, so files with a common prefix, such as inventory_sorting_main.js and inventory_sorting_test.js, or files in subdirectories could not be told apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/container/ContainerScreenTerminalScripting.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 82603fa4c..5ff157ae4 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -363,8 +363,12 @@ protected void renderScriptPaths(GuiGraphics guiGraphics, int mouseX, int mouseY Font.DisplayMode.NORMAL ); - // If hovering, render removal button + // If hovering, show the full path, as it may be truncated, and render removal button if (hovering) { + // Don't draw over an open dialog + if (pendingScriptRemovalDialog == null) { + this.setTooltipForNextRenderPass(Component.literal(path.toString())); + } guiGraphics.pose().pushPose(); float scale = 0.4F; int size = (int) (Images.ERROR.getWidth() * scale); From d88a8f3a2c3a9e7abad598365d6e3b955002aca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:43:15 +0000 Subject: [PATCH 35/59] Allow renaming scripts in the scripting terminal New scripts were always named script0.js, script1.js and so on, and could only be renamed or moved into directories through the file system, which players on a server can't access. Right-click a script to rename it, which also allows moving it into a directory or changing its language through its extension. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetDialog.java | 41 +++++++- .../ContainerScreenTerminalScripting.java | 99 ++++++++++++++++--- .../container/ContainerTerminalScripting.java | 29 ++++++ .../TerminalScriptingRenameScriptPacket.java | 68 +++++++++++++ .../proxy/CommonProxy.java | 2 + .../integratedscripting/lang/en_us.json | 6 ++ .../info/scripting_info.xml | 1 + .../core/network/ScriptingDataTest.java | 32 ++++++ 8 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingRenameScriptPacket.java diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java index b7d77701c..b4a33396b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java @@ -5,6 +5,7 @@ import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.narration.NarratedElementType; import net.minecraft.client.gui.narration.NarrationElementOutput; @@ -16,6 +17,8 @@ import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.integratedscripting.Reference; +import javax.annotation.Nullable; + /** * Shows a confirm/cancel dialog * @author rubensworks @@ -32,10 +35,21 @@ public class WidgetDialog extends AbstractWidget implements GuiEventListener { private final Component message; private final ButtonText buttonConfirm; private final ButtonText buttonCancel; + @Nullable + private final EditBox input; public WidgetDialog(Font font, int x, int y, Screen parent, Component title, Component message, Component confirm, Component cancel, Button.OnPress confirmCallback, Button.OnPress cancelCallback) { + this(font, x, y, parent, title, message, confirm, cancel, confirmCallback, cancelCallback, null); + } + + /** + * @param inputValue If not null, a text input is shown with this initial value. + */ + public WidgetDialog(Font font, int x, int y, Screen parent, + Component title, Component message, Component confirm, Component cancel, + Button.OnPress confirmCallback, Button.OnPress cancelCallback, @Nullable String inputValue) { super(x, y, WIDTH, HEIGHT, message); this.font = font; this.parent = parent; @@ -44,6 +58,14 @@ public WidgetDialog(Font font, int x, int y, Screen parent, this.buttonConfirm = new ButtonText(x + 50, y + HEIGHT - 15 - 5, 50, 15, confirm, confirm, confirmCallback, true); this.buttonCancel = new ButtonText(x + WIDTH - 50 - 50, y + HEIGHT - 15 - 5, 50, 15, cancel, cancel, cancelCallback, true); + if (inputValue != null) { + this.input = new EditBox(font, x + 10, y + 31, WIDTH - 20, 12, message); + this.input.setMaxLength(256); + this.input.setValue(inputValue); + this.input.setFocused(true); + } else { + this.input = null; + } } protected void drawBackground(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { @@ -65,16 +87,33 @@ protected void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, flo guiGraphics.drawCenteredString(font, title, this.getX() + this.width / 2, this.getY() + 4, 16777215); FormattedCharSequence formattedcharsequence = message.getVisualOrderText(); - guiGraphics.drawString(font, message, (this.getX() + this.width / 2 - font.width(formattedcharsequence) / 2), (this.getY() + 25), 4210752, false); + guiGraphics.drawString(font, message, (this.getX() + this.width / 2 - font.width(formattedcharsequence) / 2), (this.getY() + (this.input != null ? 19 : 25)), 4210752, false); + if (this.input != null) { + this.input.render(guiGraphics, mouseX, mouseY, partialTicks); + } } @Override public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { return this.buttonConfirm.mouseClicked(mouseX, mouseY, mouseButton) || this.buttonCancel.mouseClicked(mouseX, mouseY, mouseButton) + || (this.input != null && this.input.mouseClicked(mouseX, mouseY, mouseButton)) || super.mouseClicked(mouseX, mouseY, mouseButton); } + @Nullable + public EditBox getInput() { + return input; + } + + public void confirm() { + this.buttonConfirm.onPress(); + } + + public void cancel() { + this.buttonCancel.onPress(); + } + @Override protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { narrationElementOutput.add(NarratedElementType.TITLE, message); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 5ff157ae4..8f81d2acc 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -12,6 +12,7 @@ import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.resources.ResourceLocation; @@ -46,9 +47,11 @@ import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScripting; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingRenameScriptPacket; import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.Collections; import java.util.List; @@ -87,7 +90,7 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended= this.leftPos + PATHS_X + PATHS_WIDTH - (int) (Images.ERROR.getWidth() * 0.4F) - 1) { this.fieldDisk.playDownSound(Minecraft.getInstance().getSoundManager()); // Show confirmation dialog - pendingScriptRemovalDialog = new WidgetDialog(font, leftPos + getBaseXSize() / 2 - WidgetDialog.WIDTH / 2, topPos + 50, this, + pendingDialog = new WidgetDialog(font, leftPos + getBaseXSize() / 2 - WidgetDialog.WIDTH / 2, topPos + 50, this, Component.translatable("gui.integratedscripting.removal_dialog.title"), Component.translatable("gui.integratedscripting.removal_dialog.message", hoveredScriptPath.toString()), Component.translatable("gui.integratedscripting.removal_dialog.delete"), Component.translatable("gui.integratedscripting.removal_dialog.keep"), (b) -> { - removeWidget(pendingScriptRemovalDialog); - pendingScriptRemovalDialog = null; + removeWidget(pendingDialog); + pendingDialog = null; this.removeScript(hoveredScriptPath); }, (b) -> { - removeWidget(pendingScriptRemovalDialog); - pendingScriptRemovalDialog = null; + removeWidget(pendingDialog); + pendingDialog = null; }); - addRenderableWidget(pendingScriptRemovalDialog); + addRenderableWidget(pendingDialog); return true; } else { @@ -631,6 +641,53 @@ private void onSelectionModified() { getMenu().setSelection(selected); } + private void openRenameDialog(Path path) { + pendingDialog = new WidgetDialog(font, leftPos + getBaseXSize() / 2 - WidgetDialog.WIDTH / 2, topPos + 50, this, + Component.translatable("gui.integratedscripting.rename_dialog.title"), + Component.translatable("gui.integratedscripting.rename_dialog.message"), + Component.translatable("gui.integratedscripting.rename_dialog.rename"), + Component.translatable("gui.integratedscripting.rename_dialog.cancel"), + (b) -> { + String name = pendingDialog.getInput().getValue(); + removeWidget(pendingDialog); + pendingDialog = null; + this.renameScript(path, name); + }, + (b) -> { + removeWidget(pendingDialog); + pendingDialog = null; + }, path.toString()); + addRenderableWidget(pendingDialog); + } + + private void renameScript(Path from, String name) { + int disk = getMenu().getActiveDisk(); + Map scripts = getActiveScripts(); + Path to; + try { + to = Path.of(name.trim()); + } catch (InvalidPathException e) { + return; + } + if (scripts == null || to.toString().isEmpty() || scripts.containsKey(to) || !scripts.containsKey(from)) { + return; + } + + // Apply the rename locally, so an open script stays open with its editor state + String script = scripts.get(from); + boolean active = from.equals(getMenu().getActiveScriptPath()); + getMenu().setLastScript(disk, to, script); + getMenu().setLastScript(disk, from, null); + TextHistory history = scriptHistories.remove(Pair.of(disk, from)); + if (history != null) { + scriptHistories.put(Pair.of(disk, to), history); + } + if (active) { + getMenu().setActiveScriptPath(to); + } + IntegratedScripting._instance.getPacketHandler().sendToServer(new TerminalScriptingRenameScriptPacket(disk, from, to)); + } + private void removeScript(Path path) { IntegratedScripting._instance.getPacketHandler() .sendToServer(new TerminalScriptingDeleteScriptPacket(getMenu().getActiveDisk(), path)); @@ -647,7 +704,7 @@ public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double @Override public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, double offsetX, double offsetY) { // Make active dialog consume all input - if (pendingScriptRemovalDialog != null) { + if (pendingDialog != null) { return false; } @@ -667,7 +724,17 @@ public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { // Make active dialog consume all input - if (typedChar != GLFW.GLFW_KEY_ESCAPE && pendingScriptRemovalDialog != null) { + if (pendingDialog != null && pendingDialog.getInput() != null) { + if (typedChar == GLFW.GLFW_KEY_ESCAPE) { + pendingDialog.cancel(); + } else if (typedChar == GLFW.GLFW_KEY_ENTER || typedChar == GLFW.GLFW_KEY_KP_ENTER) { + pendingDialog.confirm(); + } else { + pendingDialog.getInput().keyPressed(typedChar, keyCode, modifiers); + } + return true; + } + if (typedChar != GLFW.GLFW_KEY_ESCAPE && pendingDialog != null) { return false; } @@ -796,8 +863,8 @@ private void navigate(int direction) { @Override public boolean charTyped(char p_94683_, int p_94684_) { // Make active dialog consume all input - if (pendingScriptRemovalDialog != null) { - return false; + if (pendingDialog != null) { + return pendingDialog.getInput() != null && pendingDialog.getInput().charTyped(p_94683_, p_94684_); } if (this.searchBar != null && this.searchBar.isFocused()) { return this.searchBar.charTyped(p_94683_, p_94684_); diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index 5e216c843..21ce2e97a 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -338,6 +338,35 @@ public void setServerScript(int disk, Path path, @Nullable String script) { ScriptingNetworkHelpers.getScriptingData().setScript(disk, path, script, IScriptingData.ChangeLocation.MEMORY); } + /** + * Rename a script on the server. + * The client already applied the rename to its own state, so it is corrected if the rename fails. + */ + public void renameServerScript(int disk, Path from, Path to) { + IScriptingData scriptingData = ScriptingNetworkHelpers.getScriptingData(); + String script = scriptingData.getScripts(disk).get(from); + if (script != null && !scriptingData.getScripts(disk).containsKey(to)) { + scriptingData.setScript(disk, to, script, IScriptingData.ChangeLocation.MEMORY); + // Only remove the original if the new path was accepted + if (script.equals(scriptingData.getScripts(disk).get(to))) { + scriptingData.setScript(disk, from, null, IScriptingData.ChangeLocation.MEMORY); + } + } + + // Send the actual state of both paths, which also corrects the client if the rename failed + for (Path path : new Path[]{from, to}) { + String value = scriptingData.getScripts(disk).get(path); + setLastScript(disk, path, value); + if (value != null) { + IntegratedScripting._instance.getPacketHandler() + .sendToPlayer(new TerminalScriptingModifiedScriptPacket(disk, path, value), (ServerPlayer) player); + } else { + IntegratedScripting._instance.getPacketHandler() + .sendToPlayer(new TerminalScriptingDeleteScriptPacket(disk, path), (ServerPlayer) player); + } + } + } + public void createNewServerScript(int disk) { // Determine unique script name Set existingScripts = ScriptingNetworkHelpers.getScriptingData().getScripts(disk).keySet(); diff --git a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingRenameScriptPacket.java b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingRenameScriptPacket.java new file mode 100644 index 000000000..4701fd614 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingRenameScriptPacket.java @@ -0,0 +1,68 @@ +package org.cyclops.integratedscripting.network.packet; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.cyclopscore.network.CodecField; +import org.cyclops.cyclopscore.network.PacketCodec; +import org.cyclops.integratedscripting.Reference; +import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScripting; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +/** + * Packet for renaming a script from client to server. + * @author rubensworks + * + */ +public class TerminalScriptingRenameScriptPacket extends PacketCodec { + + public static final Type ID = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "terminal_scripting_rename_script")); + public static final StreamCodec CODEC = getCodec(TerminalScriptingRenameScriptPacket::new); + + @CodecField + private int disk; + @CodecField + private String from; + @CodecField + private String to; + + public TerminalScriptingRenameScriptPacket() { + super(ID); + } + + public TerminalScriptingRenameScriptPacket(int disk, Path from, Path to) { + super(ID); + this.disk = disk; + this.from = from.toString(); + this.to = to.toString(); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + @OnlyIn(Dist.CLIENT) + public void actionClient(Level world, Player player) { + + } + + @Override + public void actionServer(Level world, ServerPlayer player) { + if(player.containerMenu instanceof ContainerTerminalScripting container) { + try { + container.renameServerScript(disk, Path.of(from), Path.of(to)); + } catch (InvalidPathException e) { + // Ignore invalid paths + } + } + } +} diff --git a/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java b/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java index 9f579a612..64eed3f3d 100644 --- a/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java +++ b/src/main/java/org/cyclops/integratedscripting/proxy/CommonProxy.java @@ -7,6 +7,7 @@ import org.cyclops.integratedscripting.network.packet.TerminalScriptingCreateNewScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingDeleteScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingRenameScriptPacket; import org.cyclops.integratedscripting.network.packet.TerminalScriptingSyntaxErrorPacket; /** @@ -29,5 +30,6 @@ public void registerPacketHandlers(PacketHandler packetHandler) { packetHandler.register(TerminalScriptingCreateNewScriptPacket.ID, TerminalScriptingCreateNewScriptPacket.CODEC); packetHandler.register(TerminalScriptingDeleteScriptPacket.ID, TerminalScriptingDeleteScriptPacket.CODEC); packetHandler.register(TerminalScriptingSyntaxErrorPacket.ID, TerminalScriptingSyntaxErrorPacket.CODEC); + packetHandler.register(TerminalScriptingRenameScriptPacket.ID, TerminalScriptingRenameScriptPacket.CODEC); } } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 126856f1f..3e8ba40a8 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -37,6 +37,11 @@ "gui.integratedscripting.removal_dialog.message": "Do you want to delete \"%s\"?", "gui.integratedscripting.removal_dialog.delete": "Delete", "gui.integratedscripting.removal_dialog.keep": "Keep", + "gui.integratedscripting.rename_dialog.title": "Rename script", + "gui.integratedscripting.rename_dialog.message": "New path, such as dir/name.js:", + "gui.integratedscripting.rename_dialog.rename": "Rename", + "gui.integratedscripting.rename_dialog.cancel": "Cancel", + "gui.integratedscripting.rename_hint": "Right-click to rename", "gui.integratedscripting.error.invalid_member": "Select a valid function name or variable name inside the script editor to bind with.", "gui.integratedscripting.error.invalid_member.current": "Current selection: \"%s\"", "gui.integratedscripting.error.reserved_member": "\"%s\" is a keyword, so it can not be bound. Select a function name or variable name instead.", @@ -142,6 +147,7 @@ "info_book.integratedscripting.terminal_shortcuts.text1": "The script editor of the &lScripting Terminal&r has the keyboard shortcuts shown below. You can change them in the &oControls&r settings, under &oIntegrated Scripting&r. Most of these actions are also available as buttons above the editor.", "info_book.integratedscripting.terminal_shortcuts.text2": "Some keys can not be changed: &oTab&r and &oShift+Tab&r indent and unindent the selected lines, &oPage Up&r and &oPage Down&r move by a page, &oCtrl+Left&r and &oCtrl+Right&r (&oOption&r on Mac) move by word, &oHome&r and &oEnd&r (also &oCmd+Left&r and &oCmd+Right&r on Mac) move to the start or end of the line, &oCtrl+Home&r and &oCtrl+End&r (also &oCmd+Up&r and &oCmd+Down&r on Mac) move to the start or end of the script, and &oCtrl+Shift+Z&r (&oCmd+Shift+Z&r on Mac) also redoes.", "info_book.integratedscripting.terminal_shortcuts.text3": "While finding text or going to a line, &oEnter&r and &oShift+Enter&r go to the next and previous match, and &oEscape&r closes the search bar.", + "info_book.integratedscripting.terminal_shortcuts.text4": "To rename a script, right-click it in the script list.", "info_book.integratedscripting.writing": "Writing Scripts", diff --git a/src/main/resources/data/integratedscripting/info/scripting_info.xml b/src/main/resources/data/integratedscripting/info/scripting_info.xml index 84b7818ef..94d6191ad 100644 --- a/src/main/resources/data/integratedscripting/info/scripting_info.xml +++ b/src/main/resources/data/integratedscripting/info/scripting_info.xml @@ -59,6 +59,7 @@ info_book.integratedscripting.terminal_shortcuts.text1 info_book.integratedscripting.terminal_shortcuts.text2 info_book.integratedscripting.terminal_shortcuts.text3 + info_book.integratedscripting.terminal_shortcuts.text4 key.integratedscripting.undo key.integratedscripting.redo key.integratedscripting.find diff --git a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java index 5ab3cf714..4f062a42b 100644 --- a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java +++ b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java @@ -227,6 +227,38 @@ public void testFlushKeepsFilePermissions() throws IOException { } } + @Test + public void testDiskReloadKeepsUnflushedChanges() throws IOException { + Path rootPath = Files.createTempDirectory("integratedscripting-test"); + ScriptingData scriptingData = new ScriptingData(rootPath); + try { + Path diskPath = rootPath.resolve("scripting-disks").resolve("654"); + Files.createDirectories(diskPath); + Files.writeString(diskPath.resolve("old.js"), "old"); + Files.writeString(diskPath.resolve("other.js"), "other"); + scriptingData.initialize(); + + // Rename old.js to new.js in memory, without flushing it to disk yet + scriptingData.setScript(654, Path.of("new.js"), "old", IScriptingData.ChangeLocation.MEMORY); + scriptingData.setScript(654, Path.of("old.js"), null, IScriptingData.ChangeLocation.MEMORY); + + // A file watcher reload, for example caused by another file, must not undo these changes + scriptingData.initializeDiskScripts(654); + assertThat(scriptingData.getScripts(654).get(Path.of("new.js")), equalTo("old")); + assertThat(scriptingData.getScripts(654).get(Path.of("old.js")), nullValue()); + assertThat(scriptingData.getScripts(654).get(Path.of("other.js")), equalTo("other")); + + // After flushing, the disk contents are used again + scriptingData.tick(); + scriptingData.initializeDiskScripts(654); + assertThat(scriptingData.getScripts(654).get(Path.of("new.js")), equalTo("old")); + assertFalse(Files.exists(diskPath.resolve("old.js"))); + } finally { + scriptingData.close(); + FileUtils.deleteDirectory(rootPath.toFile()); + } + } + @Test public void testDiskReloadDuringFirstFlushKeepsScript() throws IOException { Path rootPath = Files.createTempDirectory("integratedscripting-test"); From bfc3760ff0dee86b8a4e95453b3ff2d98adcc762 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:37:02 +0000 Subject: [PATCH 36/59] Add a focus mode to enlarge the script editor The editor only uses a small part of the screen, showing about a dozen wrapped lines. Add a toolbar button to show only the editor, using most of the screen, with the toolbar moving along above it. Escape or the button returns to the normal terminal. See #44 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../ContainerScreenTerminalScripting.java | 84 +++++++++++++++++- .../client/gui/image/ScriptImages.java | 2 + .../integratedscripting/lang/en_us.json | 3 + .../textures/gui/icons.png | Bin 1286 -> 1389 bytes .../info/scripting_info.xml | 1 + 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 8f81d2acc..32e457e4b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -11,6 +11,7 @@ import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; @@ -78,6 +79,7 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended { @@ -171,9 +181,15 @@ public int getTotalRows() { buttonCreateFile = new ButtonText(this.leftPos + 19, this.topPos + 222, 56, 10, Component.translatable("gui.integratedscripting.create_file"), Component.literal("+"), (button) -> getMenu().createNewFile(), true); - addRenderableWidget(buttonCreateFile); + if (!focusMode) { + addRenderableWidget(buttonCreateFile); + } - this.addToolbar(this.leftPos + SCRIPT_X + SCRIPT_WIDTH, this.topPos + 5); + if (focusMode) { + this.addToolbar(this.width - FOCUS_MARGIN, FOCUS_MARGIN - 12); + } else { + this.addToolbar(this.leftPos + SCRIPT_X + SCRIPT_WIDTH, this.topPos + 5); + } // Restore the open script in the new editor if (getMenu().getActiveScriptPath() != null) { @@ -189,6 +205,13 @@ public int getTotalRows() { * @param y The top of the toolbar. */ private void addToolbar(int right, int y) { + Component focusLabel = Component.translatable(focusMode ? "gui.integratedscripting.focus_mode.exit" : "gui.integratedscripting.focus_mode.enter"); + buttonFocus = new ButtonIcon(right - 10, y, 10, 10, focusLabel, (button) -> this.toggleFocusMode(), + focusMode ? ScriptImages.ACTION_FOCUS_EXIT : ScriptImages.ACTION_FOCUS_ENTER); + buttonFocus.setTooltip(Tooltip.create(focusLabel)); + addRenderableWidget(buttonFocus); + right -= 10; + this.toolbarLeft = right - 50; this.toolbarEditButtons.clear(); this.buttonUndo = this.addToolbarButton(right - 50, y, ScriptImages.ACTION_UNDO, Keys.UNDO, () -> this.textArea.undo()); @@ -283,8 +306,44 @@ protected int getErrorY() { return 139; } + private void toggleFocusMode() { + this.focusMode = !this.focusMode; + // Recreates the widgets, which keeps the open script + this.rebuildWidgets(); + } + + @Override + protected void drawCurrentScreen(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + if (focusMode) { + // Only show the editor, without the container contents + guiGraphics.fill(textArea.getX() - 2, textArea.getY() - 2, textArea.getX() + textArea.getWidth() + 2, textArea.getY() + textArea.getHeight() + 2, Helpers.RGBAToInt(85, 85, 85, 255)); + guiGraphics.fill(textArea.getX() - 1, textArea.getY() - 1, textArea.getX() + textArea.getWidth() + 1, textArea.getY() + textArea.getHeight() + 1, Helpers.RGBAToInt(224, 224, 224, 255)); + Path path = getMenu().getActiveScriptPath(); + if (path != null) { + guiGraphics.drawString(font, path.toString(), FOCUS_MARGIN, FOCUS_MARGIN - 11, 16777215); + } + for (net.minecraft.client.gui.components.Renderable renderable : this.renderables) { + renderable.render(guiGraphics, mouseX, mouseY, partialTicks); + } + } else { + super.drawCurrentScreen(guiGraphics, mouseX, mouseY, partialTicks); + } + } + + @Override + protected void renderTooltip(GuiGraphics guiGraphics, int x, int y) { + // Slots are hidden in focus mode + if (!focusMode) { + super.renderTooltip(guiGraphics, x, y); + } + } + @Override protected void renderBg(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { + // Only the editor is shown in focus mode + if (focusMode) { + return; + } super.renderBg(guiGraphics, partialTicks, mouseX, mouseY); RenderHelpers.bindTexture(this.texture); fieldDisk.render(guiGraphics, mouseX, mouseY, partialTicks); @@ -408,7 +467,7 @@ protected Path getHoveredScriptPath(double mouseX, double mouseY) { protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { // Shorten the title if it would overlap with the toolbar, as translations can be long Component title = this.title; - int maxTitleWidth = this.toolbarLeft - this.leftPos - this.titleLabelX - 2; + int maxTitleWidth = this.toolbarLeft - this.leftPos - this.titleLabelX - 1; if (this.font.width(title) > maxTitleWidth) { title = Component.literal(this.font.plainSubstrByWidth(title.getString(), maxTitleWidth - this.font.width("\u2026")) + "\u2026"); } @@ -464,6 +523,17 @@ public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { return pendingDialog.mouseClicked(mouseX, mouseY, mouseButton); } + // Only the editor widgets are visible in focus mode, so don't let clicks reach the container + if (focusMode) { + for (GuiEventListener child : this.children()) { + if (child.mouseClicked(mouseX, mouseY, mouseButton)) { + this.setFocused(child); + return true; + } + } + return true; + } + // Handle script path clicks Path hoveredScriptPath = getHoveredScriptPath(mouseX, mouseY); if (hoveredScriptPath != null && mouseButton == 1) { @@ -747,6 +817,12 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return true; } + // Leave focus mode instead of closing the screen + if (focusMode && typedChar == GLFW.GLFW_KEY_ESCAPE && pendingDialog == null) { + this.toggleFocusMode(); + return true; + } + if (textArea.isFocused()) { boolean ret = textArea.keyPressed(typedChar, keyCode, modifiers); if (typedChar != GLFW.GLFW_KEY_ESCAPE) { diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java index 9d6678d8a..0796b72ef 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java @@ -21,5 +21,7 @@ public class ScriptImages { public static final Image ACTION_FIND = new Image(ICONS, 16, 16, 8, 8); public static final Image ACTION_GOTO_LINE = new Image(ICONS, 24, 16, 8, 8); public static final Image ACTION_COMMENT = new Image(ICONS, 32, 16, 8, 8); + public static final Image ACTION_FOCUS_ENTER = new Image(ICONS, 48, 16, 8, 8); + public static final Image ACTION_FOCUS_EXIT = new Image(ICONS, 56, 16, 8, 8); } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 3e8ba40a8..63dd0056e 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -27,6 +27,8 @@ "gui.integratedscripting.script": "Script", "gui.integratedscripting.create_file": "Create File", "gui.integratedscripting.find": "Find", + "gui.integratedscripting.focus_mode.enter": "Enlarge editor", + "gui.integratedscripting.focus_mode.exit": "Back to normal size (Escape)", "gui.integratedscripting.goto_line": "Go to line", "gui.integratedscripting.find.label": "Find:", "gui.integratedscripting.goto_line.label": "Line:", @@ -148,6 +150,7 @@ "info_book.integratedscripting.terminal_shortcuts.text2": "Some keys can not be changed: &oTab&r and &oShift+Tab&r indent and unindent the selected lines, &oPage Up&r and &oPage Down&r move by a page, &oCtrl+Left&r and &oCtrl+Right&r (&oOption&r on Mac) move by word, &oHome&r and &oEnd&r (also &oCmd+Left&r and &oCmd+Right&r on Mac) move to the start or end of the line, &oCtrl+Home&r and &oCtrl+End&r (also &oCmd+Up&r and &oCmd+Down&r on Mac) move to the start or end of the script, and &oCtrl+Shift+Z&r (&oCmd+Shift+Z&r on Mac) also redoes.", "info_book.integratedscripting.terminal_shortcuts.text3": "While finding text or going to a line, &oEnter&r and &oShift+Enter&r go to the next and previous match, and &oEscape&r closes the search bar.", "info_book.integratedscripting.terminal_shortcuts.text4": "To rename a script, right-click it in the script list.", + "info_book.integratedscripting.terminal_shortcuts.text5": "In focus mode, &oEscape&r returns to the normal terminal.", "info_book.integratedscripting.writing": "Writing Scripts", diff --git a/src/main/resources/assets/integratedscripting/textures/gui/icons.png b/src/main/resources/assets/integratedscripting/textures/gui/icons.png index c896e55b9d6f00d3cd9968ee8d30a137c676f07f..26a3a8cae54a382f803f80dff4e3a8d1b239011f 100644 GIT binary patch delta 688 zcmV;h0#E&h3hfGzBYy=nNklqX>NE_LvglyBstAGwd zE7`dN-r|jjq9<(plL^ju&I|(vaba^mNf+?$bUF>QaU6gCal!F;+`V*r;I`b)H^y;1 zef#$9(TzWU{(QbYj^pW{FCQM={?peVuY7yJ?{qf|*G>MVYkwv`aQp5|e(1XU({BLy zb6y>Q^?ClMuRomo!#i>R^d{H)^!3N!^Z?$EH$4UT=gS9se|SgUd<{SZ;QiPP!2Egt z`g&^r%0EE5C0)O4Z!^3$YVZYyBHRi*^!)d+k^1iLduYK)H_if#_2142S2w*vBp0eC_ zo=np;4bwC|I?npBt$!%8yziQSI5L~2>Ad}=aJ+PE_*>l#!_SHNoJp~q%;Pwoj>qF} z{>bmrIQ#v6cPS7r+569?X&MfP!}%k}^;lc4c{m)N6@NYW8{ID_!QpTihGE#;i*8Mp zYbMa8=k53V-8vL=NQPlJEp9(*9%sHj9*?`_ee<|m&l!ec`2PL-Q!fF3w|Or)j^p{V zKFlA6|0dG>*u4Gn`In9)*5f`l{!5NVUgLB+xwvF6_rvh(q4{F3`^#Q=cG@rMA{_$n~7VFHf=lTZR77X1s$ Wb`>^g)EMai0000@9*!Aw>O*3;pdNU&u)MB@bt#F2YjTPVYqDg=Pnt3;D7qv7=Gxw`Rq3Ue4aN4 z;PgEI?%~O~KYS6lFK=?WyN9R2=>dEl?|KUG^T#)EF5O zTz`JZzw{Vzy%w(lOdkuL{+(MIa)S?F%j*00?=OCx_bq%C7_Qf9{#yW`#TS7fOaLHE z03hsLF3|1w`+wtwg!OuT(LVS4{o!=m`D07ZFL4b6UhjAWFdyQV%(tBebsWcG9LHz( zbNbxUKNOiicF8{+0bcKB7=8`r-wfP*m^YivVYl0@rjPv2?PtASug(SHHLE}HHn;PE z+-|qSFbs!b`14SHX>cz&YB=}0^?JQJ4aF3aVHggx+ke0Zxt*`d<2W9lSXz;%f&D)e z!#Iw^Znv9We+CXGaJZ zecU%~zaccx)9{};60rKiKXmoK1z4?~Z^5;{f4F8>9{}Lv_$n~7(E^47lfVKh7X1f= WJXFfpbSbg`0000info_book.integratedscripting.terminal_shortcuts.text2 info_book.integratedscripting.terminal_shortcuts.text3 info_book.integratedscripting.terminal_shortcuts.text4 + info_book.integratedscripting.terminal_shortcuts.text5 key.integratedscripting.undo key.integratedscripting.redo key.integratedscripting.find From 8b0ec4358ae4a6f8d7368559061d9983b497b732 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:55:37 +0000 Subject: [PATCH 37/59] Fix the scripts directory path in the info book The External Editing section said that the directories of all disks are in world/integratedscripting/, while they are in world/integratedscripting/scripting-disks/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- src/main/resources/assets/integratedscripting/lang/en_us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 63dd0056e..e5bcab5d8 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -198,7 +198,7 @@ "info_book.integratedscripting.advanced.external_editing": "External Editing", "info_book.integratedscripting.advanced.external_editing.text1": "If you have access to the file system on which your singleplayer or multiplayer game is running, you can access script files through any external editor. This allows you to create scripts using your favorite IDE (such as Vi, but definitely not Emacs).", - "info_book.integratedscripting.advanced.external_editing.text2": "Script files are accessible via the &oworld/integratedscripting/&r directory (see &osaves/&r for singleplayer worlds). In here, you will find directories for all &lScripting Disks&r by their id. Inside of each &lScripting Disk&r directory, you will find all script files stored in that disk.", + "info_book.integratedscripting.advanced.external_editing.text2": "Script files are accessible via the &oworld/integratedscripting/scripting-disks/&r directory (see &osaves/&r for singleplayer worlds). In here, you will find directories for all &lScripting Disks&r by their id. Inside of each &lScripting Disk&r directory, you will find all script files stored in that disk.", "info_book.integratedscripting.advanced.external_editing.text3": "You can safely modify these script files through an external editor, even if the game is running. After externally saving the file, they will be updated automatically in the game, and dependent variables will be updated as well.", "info_book.integratedscripting.advanced.external_editing.text4": "If your external editor supports this (modern IDEs do), modifying a script file through the &lScripting Terminal&r will also cause the files to be updated in your external editor.", From 120ad29dc3536cb2ff115d247abb4706ab140725 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:58:01 +0000 Subject: [PATCH 38/59] Add a button to open the scripts folder of a disk Editing scripts in an external editor required finding the disk's folder inside the world save. In singleplayer, and when hosting a LAN world, a button below the script list now opens the folder of the selected disk, creating it if needed, and its tooltip shows the folder's path. It is hidden when playing on another server, as the folder is not on the player's own computer then. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../ContainerScreenTerminalScripting.java | 55 +++++++++++++++++- .../client/gui/image/ScriptImages.java | 1 + .../core/network/ScriptingData.java | 2 +- .../integratedscripting/lang/en_us.json | 2 + .../textures/gui/icons.png | Bin 1389 -> 1431 bytes .../info/scripting_info.xml | 1 + 6 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 32e457e4b..b51c9cb93 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -14,6 +14,7 @@ import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.ChatFormatting; +import net.minecraft.Util; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.resources.ResourceLocation; @@ -22,6 +23,7 @@ import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import org.apache.commons.lang3.tuple.Pair; +import org.apache.logging.log4j.Level; import org.cyclops.cyclopscore.client.gui.component.WidgetScrollBar; import org.cyclops.cyclopscore.client.gui.component.button.ButtonText; import org.cyclops.cyclopscore.client.gui.component.input.WidgetArrowedListField; @@ -53,6 +55,8 @@ import javax.annotation.Nullable; import java.nio.file.InvalidPathException; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.List; @@ -88,6 +92,9 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended toolbarEditButtons = Lists.newArrayList(); private ButtonIcon buttonUndo; private ButtonIcon buttonRedo; @@ -179,10 +186,24 @@ public int getTotalRows() { }); addRenderableWidget(textArea); - buttonCreateFile = new ButtonText(this.leftPos + 19, this.topPos + 222, 56, 10, Component.translatable("gui.integratedscripting.create_file"), Component.literal("+"), + // The scripts folder can only be opened if this client is running the server, such as in singleplayer + boolean canOpenFolder = Minecraft.getInstance().hasSingleplayerServer(); + buttonCreateFile = new ButtonText(this.leftPos + PATHS_X, this.topPos + 222, canOpenFolder ? PATHS_WIDTH - 12 : PATHS_WIDTH, 10, Component.translatable("gui.integratedscripting.create_file"), Component.literal("+"), (button) -> getMenu().createNewFile(), true); + buttonOpenFolder = null; + buttonOpenFolderDisk = Integer.MIN_VALUE; if (!focusMode) { addRenderableWidget(buttonCreateFile); + if (canOpenFolder) { + buttonOpenFolder = new ButtonIcon(this.leftPos + PATHS_X + PATHS_WIDTH - 10, this.topPos + 222, 10, 10, + Component.translatable("gui.integratedscripting.open_folder"), (button) -> { + this.openDiskFolder(); + // Run after the click is handled, as that focuses the button + Minecraft.getInstance().tell(() -> this.setFocused(null)); + }, ScriptImages.ACTION_OPEN_FOLDER); + addRenderableWidget(buttonOpenFolder); + this.updateOpenFolderButton(); + } } if (focusMode) { @@ -239,6 +260,37 @@ private ButtonIcon addToolbarButton(int x, int y, IImage image, KeyMapping keyMa return button; } + protected Path getDiskFolder(int disk) { + return IntegratedScripting._instance.scriptingData.getDiskPath(disk).toAbsolutePath().normalize(); + } + + private void updateOpenFolderButton() { + int disk = getMenu().getActiveDisk(); + if (this.buttonOpenFolder != null && disk != this.buttonOpenFolderDisk) { + this.buttonOpenFolderDisk = disk; + this.buttonOpenFolder.active = disk >= 0; + Component tooltip = Component.translatable("gui.integratedscripting.open_folder"); + if (disk >= 0) { + tooltip = tooltip.copy().append("\n").append(Component.literal(this.getDiskFolder(disk).toString()).withStyle(ChatFormatting.GRAY)); + } + this.buttonOpenFolder.setTooltip(Tooltip.create(tooltip)); + } + } + + private void openDiskFolder() { + int disk = getMenu().getActiveDisk(); + if (disk >= 0) { + Path path = this.getDiskFolder(disk); + try { + // The folder of a disk without scripts may not exist yet + Files.createDirectories(path); + } catch (IOException e) { + IntegratedScripting.clog(Level.ERROR, "Could not create the scripts folder " + path + ": " + e.getMessage()); + } + Util.getPlatform().openPath(path); + } + } + private void updateToolbar() { boolean scriptOpen = getMenu().getActiveScript() != null; for (ButtonIcon button : this.toolbarEditButtons) { @@ -252,6 +304,7 @@ private void updateToolbar() { protected void containerTick() { super.containerTick(); textArea.tick(); + this.updateOpenFolderButton(); this.updateToolbar(); // The matches change when the script is edited while searching this.updateSearchStatus(); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java index 0796b72ef..4a980017b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java @@ -21,6 +21,7 @@ public class ScriptImages { public static final Image ACTION_FIND = new Image(ICONS, 16, 16, 8, 8); public static final Image ACTION_GOTO_LINE = new Image(ICONS, 24, 16, 8, 8); public static final Image ACTION_COMMENT = new Image(ICONS, 32, 16, 8, 8); + public static final Image ACTION_OPEN_FOLDER = new Image(ICONS, 40, 16, 8, 8); public static final Image ACTION_FOCUS_ENTER = new Image(ICONS, 48, 16, 8, 8); public static final Image ACTION_FOCUS_EXIT = new Image(ICONS, 56, 16, 8, 8); diff --git a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java index eea57b951..b36962848 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java +++ b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java @@ -101,7 +101,7 @@ protected Path getDisksPath() { return rootPath.resolve("scripting-disks"); } - protected Path getDiskPath(int id) { + public Path getDiskPath(int id) { return getDisksPath().resolve(String.valueOf(id)); } diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index e5bcab5d8..83af42152 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -26,6 +26,7 @@ "gui.integratedscripting.disk": "Disk", "gui.integratedscripting.script": "Script", "gui.integratedscripting.create_file": "Create File", + "gui.integratedscripting.open_folder": "Open scripts folder", "gui.integratedscripting.find": "Find", "gui.integratedscripting.focus_mode.enter": "Enlarge editor", "gui.integratedscripting.focus_mode.exit": "Back to normal size (Escape)", @@ -201,6 +202,7 @@ "info_book.integratedscripting.advanced.external_editing.text2": "Script files are accessible via the &oworld/integratedscripting/scripting-disks/&r directory (see &osaves/&r for singleplayer worlds). In here, you will find directories for all &lScripting Disks&r by their id. Inside of each &lScripting Disk&r directory, you will find all script files stored in that disk.", "info_book.integratedscripting.advanced.external_editing.text3": "You can safely modify these script files through an external editor, even if the game is running. After externally saving the file, they will be updated automatically in the game, and dependent variables will be updated as well.", "info_book.integratedscripting.advanced.external_editing.text4": "If your external editor supports this (modern IDEs do), modifying a script file through the &lScripting Terminal&r will also cause the files to be updated in your external editor.", + "info_book.integratedscripting.advanced.external_editing.text5": "In singleplayer, the folder button below the script list of the &lScripting Terminal&r opens the directory of the selected disk.", "info_book.integratedscripting.advanced.logging": "Logging", "info_book.integratedscripting.advanced.logging.text1": "For complex scripts, the need may arise to debug them by temporarily writing to external log files.", diff --git a/src/main/resources/assets/integratedscripting/textures/gui/icons.png b/src/main/resources/assets/integratedscripting/textures/gui/icons.png index 26a3a8cae54a382f803f80dff4e3a8d1b239011f..930ed750296ede14d5e713e1887eab87e77b6892 100644 GIT binary patch literal 1431 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFNI8PVHkcv5P?;I?=9L3-e zXgQf_&tj2~Cand$+ol|tW>&}gj$!+ubFx43_w@v%9IrVvq2TFN6B<^O(seEjsAMc?y!yS=-2{%=2j zzwYBx#{HKM8{9GfIsM(tKL=iaPx$0~{qX!c#sl*|pJt4Re_sFN&)Kc^dj$K>zc1R) zKYu^39{+>46&Jl5fMyhbX*Yjz^g4qJ!@a(<3}@bdUth0x_WG~W!T;9g9(cWVC!4{3 z-5vjSZZSRSTT>BT_Wu6<`frc->_6n*`0(p3-TmAQTYe}Av~V(j(B2bFcVCw5(ouQy z{rB!;Hgo-+f8BRFH~Rbi-*>KW%e}je&FSV-o&?Lxd7JN)&-1x#!h6}|u1tFE{cG|f zk$KX`Kh{{y)%%~fcUk5vz5cyoeP3^EKi1sTp7_Jgus$tFNBs%V*N;E$n0^1#ZnJZB z`|rojKJi`V?{ve6K4ag@1r{=T`IhUB->N!y_~D1(i_QBlJ}wfH;cHKnXgj<3#kG{3 z=}fWJcYxZ~Ivcj9NVI+a^v%TA|Cp@|-(ics>4necAAel9{f=4x?wU8BcJ8nH`|D0U z(~Cb9Ey|Uh_}uezyhdB*thUD=E$sgZ^*wEg{$B^Qb2?Y_v81H=CQekSJ`3=X4cGz5l92;A8JdKTlNo~6NY Qzyg=S)78&qol`;+0NW5#{r~^~ literal 1389 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJD%6Hgb%kcv5P@0=~X6~)jH z_^^=iO^{bela`0{&1Ee)myfM(;B(Txxs&N$TVbDk?d$HUB4m{@G@DgNPI zi52hWLYex1g$F-Y+}T@F{b1Xj^XA72ZEAmidwa3hzW!g$KHI*>FX!va%iaBV`MUi2 zn(71ZOU!w$`~F*+t!7{F`<+kSi(S7j=QI2$j+1TpTKRAJdiy!Q4_g=h|DtyN_m{87 zAADiB*Kcmi`s4X&_Uify_j@(G4lED;-rza#r}F==s!Y4<@p7N?@3P-nchzV@y6_uRYN*qmpT89@85~{mn?q&tt;0Q(7*Zn z{IX0bzV^dEm5+aoh+bc5Au~_Z_J{Ocef5J63nbbSZ`|q5^PQHhc46uBx8Hx?wGt`z zXiK!v&9l#vOW$WF*T4H|?dKiQ=Ox<`fBgIVJ6NBwetV_9W#8kUo9xcWxBXYU^ykK& zzn^|i?^C(<*v{;qcb{9_;}WZ#J5uz&AO7%q;m@S?zfZ?iY<0Kuy<9TS<+SZ9n{##h z?d|v3natn5*WlmP>i!F9eVoncjt@N4fp3kYiS7zz?+_Zsv@|!<< zzCNityYqil&r_ubN-n!C!%i>ms=O$4>YP1Wl+TUNM{dpt2(P_kp1R}f5x!&=UHS4Rqf7=;$-+f&C<=TDwJDXinfo_book.integratedscripting.advanced.external_editing.text2 info_book.integratedscripting.advanced.external_editing.text3 info_book.integratedscripting.advanced.external_editing.text4 + info_book.integratedscripting.advanced.external_editing.text5
From e117d1b1e0343af0e24b7835ac8156a380cb9c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:22:08 +0000 Subject: [PATCH 39/59] Fix wrong operator names in the info book The examples and explanations referred to itemstackStackable, itemstackStacksize, stackable and stacksize, which do not exist. They are now itemstackIsStackable, itemstackSize, isStackable and size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- src/main/resources/assets/integratedscripting/lang/en_us.json | 4 ++-- src/main/resources/assets/integratedscripting/lang/pt_br.json | 4 ++-- src/main/resources/assets/integratedscripting/lang/zh_cn.json | 4 ++-- .../data/integratedscripting/info/scripting_info.xml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 7f0ff2f4f..938b8f544 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -174,7 +174,7 @@ "info_book.integratedscripting.writing.methods.text1": "To counter the verbosity of global functions, you can write code more compactly using &lobject methods&r.", "info_book.integratedscripting.writing.methods.text2": "Object value types such as &8Blocks&0, &8Items&0, &8Fluids&0, ... will have &lmethods&r attached to them when used in JavaScript.", "info_book.integratedscripting.writing.methods.text3": "Object methods are just plain functions, but their first argument is tied to the object value.", - "info_book.integratedscripting.writing.methods.text4": "For example, the global function &oitemstackStackable&r takes a single &8Item&0 argument and outputs a &9Boolean&0. This function is available as a method on &8Item&0 values via the name &ostackable&r, which takes zero arguments.", + "info_book.integratedscripting.writing.methods.text4": "For example, the global function &oitemstackIsStackable&r takes a single &8Item&0 argument and outputs a &9Boolean&0. This function is available as a method on &8Item&0 values via the name &oisStackable&r, which takes zero arguments.", "info_book.integratedscripting.writing.methods.text5": "Global functions that accept two or more arguments will be available as methods on object values, with all arguments shifted by one. For example, the global function &oitemstackStrength&r accepts an &8Item&0 and a &8Block&0 argument, but is also available as method on &8Items&0 with a single &8Block&0 argument.", "info_book.integratedscripting.advanced": "Advanced", @@ -232,5 +232,5 @@ "info_book.integratedscripting.tutorials.functions.text4": "Concretely, we will read the list of items using the &lInventory Reader&r, and &lFilter&r it using our newly created function. You can bind the function to a &lVariable Card&r by selecting it in the editor and inserting an empty &lVariable Card&r, which will store it as an &2Operator&0.", "info_book.integratedscripting.tutorials.functions.text5": "You are free to use any implementation of the filter function for this tutorial. Hereafter, you can find a valid example script which you are free to copy.", "info_book.integratedscripting.tutorials.functions.text6": "To complete the tutorial, show the result of the filtered list in a &lDisplay Panel&r.", - "info_book.integratedscripting.tutorials.functions.text7": "In this example script, you will see that you can invoke built-in methods on items such as &oisStackable()&r and &ostacksize()&r. More details on which methods are available can be found later in the full manual." + "info_book.integratedscripting.tutorials.functions.text7": "In this example script, you will see that you can invoke built-in methods on items such as &oisStackable()&r and &osize()&r. More details on which methods are available can be found later in the full manual." } diff --git a/src/main/resources/assets/integratedscripting/lang/pt_br.json b/src/main/resources/assets/integratedscripting/lang/pt_br.json index 7539ce55a..703a3b7fb 100644 --- a/src/main/resources/assets/integratedscripting/lang/pt_br.json +++ b/src/main/resources/assets/integratedscripting/lang/pt_br.json @@ -117,7 +117,7 @@ "info_book.integratedscripting.writing.methods.text1": "Para combater a verbosidade das funções globais, você pode escrever código de forma mais compacta usando &lmétodos de objeto&r.", "info_book.integratedscripting.writing.methods.text2": "Tipos de valor de objeto como &8Blocos&0, &8Itens&0, &8Fluidos&0, ... terão &lmétodos&r anexados a eles quando usados em JavaScript.", "info_book.integratedscripting.writing.methods.text3": "Métodos de objeto são apenas funções simples, mas seu primeiro argumento está vinculado ao valor do objeto.", - "info_book.integratedscripting.writing.methods.text4": "Por exemplo, a função global &oitemstackStackable&r recebe um único argumento de &8Item&0 e retorna um &9Booleano&0. Esta função está disponível como um método em valores de &8Item&0 através do nome &ostackable&r, que não recebe argumentos.", + "info_book.integratedscripting.writing.methods.text4": "Por exemplo, a função global &oitemstackIsStackable&r recebe um único argumento de &8Item&0 e retorna um &9Booleano&0. Esta função está disponível como um método em valores de &8Item&0 através do nome &oisStackable&r, que não recebe argumentos.", "info_book.integratedscripting.writing.methods.text5": "Funções globais que aceitam dois ou mais argumentos estarão disponíveis como métodos em valores de objeto, com todos os argumentos deslocados por um. Por exemplo, a função global &oitemstackStrength&r aceita um argumento de &8Item&0 e &8Bloco&0, mas também está disponível como método em &8Itens&0 com um único argumento de &8Bloco&0.", "info_book.integratedscripting.advanced": "Avançado", "info_book.integratedscripting.advanced.transient_storage": "Armazenamento Transiente", @@ -165,5 +165,5 @@ "info_book.integratedscripting.tutorials.functions.text4": "Concretamente, leremos a lista de itens usando o &lLeitor de Inventário&r e a &lFiltraremos&r usando nossa função recém-criada. Você pode vincular a função a um &lCartão de Variável&r selecionando-a no editor e inserindo um &lCartão de Variável&r vazio, que a armazenará como um &2Operador&0.", "info_book.integratedscripting.tutorials.functions.text5": "Você está livre para usar qualquer implementação da função de filtro para este tutorial. A seguir, você pode encontrar um exemplo de script válido que você pode copiar.", "info_book.integratedscripting.tutorials.functions.text6": "Para completar o tutorial, mostre o resultado da lista filtrada em um &lPainel de Exibição&r.", - "info_book.integratedscripting.tutorials.functions.text7": "Neste exemplo de script, você verá que pode invocar métodos integrados em itens, como &oisStackable()&r e &ostacksize()&r. Mais detalhes sobre quais métodos estão disponíveis podem ser encontrados mais adiante no manual completo." + "info_book.integratedscripting.tutorials.functions.text7": "Neste exemplo de script, você verá que pode invocar métodos integrados em itens, como &oisStackable()&r e &osize()&r. Mais detalhes sobre quais métodos estão disponíveis podem ser encontrados mais adiante no manual completo." } diff --git a/src/main/resources/assets/integratedscripting/lang/zh_cn.json b/src/main/resources/assets/integratedscripting/lang/zh_cn.json index fb185637b..e9bd2c5da 100644 --- a/src/main/resources/assets/integratedscripting/lang/zh_cn.json +++ b/src/main/resources/assets/integratedscripting/lang/zh_cn.json @@ -115,7 +115,7 @@ "info_book.integratedscripting.writing.methods.text1": "全局函数的语法较为繁琐。使用更为紧凑的&l对象方法&r有时可以简化脚本。", "info_book.integratedscripting.writing.methods.text2": "在JavaScript中使用时,&8方块&0、&8物品s&0和&8流体s&0等对象类型均有对应&l方法&r。", "info_book.integratedscripting.writing.methods.text3": "对象方法与相应的普通函数一致,但在调用时会将对象绑定到首个参数上。", - "info_book.integratedscripting.writing.methods.text4": "例如,全局函数&oitemstackStackable&r接受单个&8物品&0参数,并返回一个&9布尔型&0。相应的对象方法为&ostackable&r,不接受参数。", + "info_book.integratedscripting.writing.methods.text4": "例如,全局函数&oitemstackIsStackable&r接受单个&8物品&0参数,并返回一个&9布尔型&0。相应的对象方法为&oisStackable&r,不接受参数。", "info_book.integratedscripting.writing.methods.text5": "接受两个及以上参数的全局函数也有相应的对象方法:首个参数绑定至对象,其余参数顺延。比如,全局函数&oitemstackStrength&r接受一个&8物品&0和一个&8方块&0参数;在&8物品&0上的相应方法只接受后一&8方块&0参数。", "info_book.integratedscripting.advanced": "进阶", "info_book.integratedscripting.advanced.transient_storage": "暂态存储", @@ -162,5 +162,5 @@ "info_book.integratedscripting.tutorials.functions.text4": "详细说明一下,我们会通过&l物品栏读取器&r读出物品列表,再使用新建的函数&l过滤&r该列表。插入空&l变量卡&r并在编辑界面中选择函数,即可将其绑定至&l变量卡&r,此函数会存储为&2运算符&0。", "info_book.integratedscripting.tutorials.functions.text5": "此教程中可以使用任意方式实现过滤函数。在此给出一份有效示例脚本,以供随意复制。", "info_book.integratedscripting.tutorials.functions.text6": "在&l显示器&r中展示列表的过滤结果,即可完成本教程。", - "info_book.integratedscripting.tutorials.functions.text7": "此示例脚本中调用了物品的内置方法,诸如&oisStackable()&r和&ostacksize()&r。有关哪些方法可用的详细信息,后续可见完整手册。" + "info_book.integratedscripting.tutorials.functions.text7": "此示例脚本中调用了物品的内置方法,诸如&oisStackable()&r和&osize()&r。有关哪些方法可用的详细信息,后续可见完整手册。" } diff --git a/src/main/resources/data/integratedscripting/info/scripting_info.xml b/src/main/resources/data/integratedscripting/info/scripting_info.xml index 84b7818ef..7794437b5 100644 --- a/src/main/resources/data/integratedscripting/info/scripting_info.xml +++ b/src/main/resources/data/integratedscripting/info/scripting_info.xml @@ -124,7 +124,7 @@ const isOdd = (value) => value % 2 !== 0; info_book.integratedscripting.writing.globals.text3 info_book.integratedscripting.writing.globals.text4 function filterItem(item) { - return idContext.ops.itemstackStackable(item) && idContext.ops.itemstackStacksize(item) >= 16; + return idContext.ops.itemstackIsStackable(item) && idContext.ops.itemstackSize(item) >= 16; } const isStackable = idContext.ops.itemstackIsStackable; const size = idContext.ops.itemstackSize; From ee16f1a8456514651f06abc56f77e8874c398dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:32:50 +0000 Subject: [PATCH 40/59] Wrap long lines at spaces and punctuation in the script editor Lines were broken at any character, splitting identifiers such as itemstackIsStackable over two lines. Lines now break after spaces, commas and brackets, before member accesses, and around operators, and wrapped lines are marked in the gutter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 67 +++++++++++++++++++ .../gui/component/input/WidgetTextArea.java | 14 ++-- .../gui/component/input/TextEditingTest.java | 37 ++++++++++ 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java index 8f7c0e221..5c67e4fdd 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -1,5 +1,10 @@ package org.cyclops.integratedscripting.client.gui.component.input; +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntList; + +import java.util.function.IntToDoubleFunction; + /** * Code editing operations on the state of a text area. * @author rubensworks @@ -305,4 +310,66 @@ public static TextHistory.State moveLines(TextHistory.State state, int direction } } + + private static final String BREAK_AFTER = ",;([{"; + private static final String OPERATORS = "+-*/%=&|<>!?:"; + + private static boolean canBreakBefore(String value, int pos) { + char prev = value.charAt(pos - 1); + char cur = value.charAt(pos); + if (cur == ' ') { + return false; + } + if (prev == ' ' || BREAK_AFTER.indexOf(prev) >= 0) { + return true; + } + // Don't split multi-character operators such as && and >= + if ((OPERATORS.indexOf(prev) >= 0) != (OPERATORS.indexOf(cur) >= 0)) { + return true; + } + // Keep member access chains readable, but don't split numbers + return cur == '.' && !Character.isDigit(prev); + } + + /** + * Wrap lines to the given width, preferably at spaces and punctuation, so identifiers are not split. + * Trailing spaces may exceed the width. + * @param value A text. + * @param maxWidth The maximum line width. + * @param charWidth The width of a character. + * @return The start positions of all wrapped lines. + */ + public static int[] wrapLines(String value, float maxWidth, IntToDoubleFunction charWidth) { + IntList starts = new IntArrayList(); + int start = 0; + while (true) { + starts.add(start); + int end = value.indexOf('\n', start); + if (end < 0) { + end = value.length(); + } + float width = 0; + int lastBreak = -1; + for (int i = start; i < end; i++) { + if (i > start && canBreakBefore(value, i)) { + lastBreak = i; + } + char c = value.charAt(i); + width += (float) charWidth.applyAsDouble(c); + if (width > maxWidth && i > start && c != ' ') { + start = lastBreak > start ? lastBreak : i; + starts.add(start); + width = 0; + lastBreak = -1; + i = start - 1; + } + } + if (end >= value.length() - 1) { + break; + } + start = end + 1; + } + return starts.toIntArray(); + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 3bc4177e0..05987749f 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -787,6 +787,10 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float false, Font.DisplayMode.NORMAL ); + } else if (this.showLineNumbers) { + // Mark wrapped continuation lines + RenderHelpers.drawScaledString(guiGraphics, font, "\u21B3", this.getX() + 4, line.y - offsetY + 2, 0.5f, + Helpers.RGBToInt(160, 160, 160), false, Font.DisplayMode.NORMAL); } lastLineNumber = line.lineNumber; } @@ -1012,17 +1016,19 @@ private TextLayout getLayout(String s) { IntList lineNumbers = new IntArrayList(); MutableBoolean trailingNewline = new MutableBoolean(); MutableInt lineNumber = new MutableInt(); - this.font.getSplitter().splitLines(s, getTextWidth() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { - String stringPart = s.substring(startPos, endPos); + StringSplitter splitter = this.font.getSplitter(); + int[] starts = TextEditing.wrapLines(s, getTextWidth(), c -> splitter.stringWidth(String.valueOf((char) c))); + for (int i = 0; i < starts.length; i++) { + String stringPart = s.substring(starts[i], i + 1 < starts.length ? starts[i + 1] : s.length()); boolean hasNewLine = stringPart.endsWith("\n"); trailingNewline.setValue(hasNewLine); - lineStarts.add(startPos); + lineStarts.add(starts[i]); contents.add(StringUtils.stripEnd(stringPart, " \n")); lineNumbers.add(lineNumber.intValue()); if (hasNewLine) { lineNumber.increment(); } - }); + } // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there if (trailingNewline.isTrue()) { lineStarts.add(s.length()); diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java index 729ba3337..8ef894782 100644 --- a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -220,4 +220,41 @@ public void testNewlineTwoSpaces() { assertThat(TextEditing.newline(s(" if (a) {", 10), TWO), equalTo(s(" if (a) {\n ", 15))); } + + private static int[] wrap(String value, int width) { + return TextEditing.wrapLines(value, width, c -> 1); + } + + @Test + public void testWrapLinesFits() { + assertThat(wrap("", 10), equalTo(new int[]{0})); + assertThat(wrap("abc\ndef", 10), equalTo(new int[]{0, 4})); + assertThat(wrap("abc\n", 10), equalTo(new int[]{0})); + assertThat(wrap("a\n\nb", 10), equalTo(new int[]{0, 2, 3})); + } + + @Test + public void testWrapLinesAtSpace() { + // Trailing spaces stay on the line + assertThat(wrap("return abc", 8), equalTo(new int[]{0, 7})); + assertThat(wrap("a bc", 3), equalTo(new int[]{0, 4})); + } + + @Test + public void testWrapLinesAtPunctuation() { + assertThat(wrap("ctx.ops.size(item)", 10), equalTo(new int[]{0, 7, 13})); + assertThat(wrap("abc,def", 5), equalTo(new int[]{0, 4})); + } + + @Test + public void testWrapLinesKeepsOperatorsAndNumbers() { + assertThat(wrap("a>=b", 2), equalTo(new int[]{0, 1, 3})); + assertThat(wrap("x=12.5", 5), equalTo(new int[]{0, 2})); + } + + @Test + public void testWrapLinesSplitsLongTokens() { + assertThat(wrap("abcdefgh", 3), equalTo(new int[]{0, 3, 6})); + } + } From 0989a66758739ad4e547b8f24c251d9a992f4529 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:21:24 +0000 Subject: [PATCH 41/59] Add completion of idContext.ops in the script editor Typing after idContext.ops. now shows the matching global operators with their signatures. Up and Down select one, Tab or Enter inserts it, and Escape closes the list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../api/language/ILanguageHandler.java | 10 ++ .../api/language/ScriptCompletions.java | 20 +++ .../gui/component/input/WidgetTextArea.java | 138 ++++++++++++++++++ .../ContainerScreenTerminalScripting.java | 15 ++ .../core/language/JavaScriptCompletions.java | 54 +++++++ .../language/LanguageHandlerJavaScript.java | 29 ++++ .../language/JavaScriptCompletionsTest.java | 49 +++++++ 7 files changed, 315 insertions(+) create mode 100644 src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java create mode 100644 src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java create mode 100644 src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index 2b7a39850..551a0eb03 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -66,6 +66,16 @@ public default Set getTopLevelMembers(String script) { return null; } + /** + * @param script The script contents. + * @param cursor The cursor position. + * @return Completion candidates at the cursor, or null if there are none. + */ + @Nullable + public default ScriptCompletions getCompletions(String script, int cursor) { + return null; + } + /** * Check a script for syntax errors, without executing it. * @param path The script path. diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java new file mode 100644 index 000000000..095a3b034 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java @@ -0,0 +1,20 @@ +package org.cyclops.integratedscripting.api.language; + +import java.util.List; + +/** + * Completion candidates at a position in a script. + * @param start The position from which the text before the cursor is replaced when accepting a candidate. + * @param items The candidates. + * @author rubensworks + */ +public record ScriptCompletions(int start, List items) { + + /** + * @param name The text to insert. + * @param detail A short description, such as a signature. + */ + public record Item(String name, String detail) { + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 05987749f..81045c7ae 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,6 +39,7 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedscripting.api.language.ScriptCompletions; import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.client.key.Keys; import org.lwjgl.glfw.GLFW; @@ -47,6 +48,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -68,6 +70,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { public static final int ROW_HEIGHT = 9; + private static final int COMPLETION_ROWS = 6; public static final int SCROLLBAR_WIDTH = 14; private final TextFieldHelperExtended textFieldHelper; @@ -85,6 +88,11 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private IInputListener listenerCursor; @Nullable private IMarkupProvider markupProvider; + @Nullable + private BiFunction completionProvider; + @Nullable + private ScriptCompletions completions; + private int completionIndex; private WidgetTextArea.DisplayCache emptyDisplayCache = createEmptyDisplayCache(); @Nullable private WidgetTextArea.DisplayCache displayCache = emptyDisplayCache; @@ -257,6 +265,71 @@ public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { this.layout = null; } + public void setCompletionProvider(@Nullable BiFunction completionProvider) { + this.completionProvider = completionProvider; + } + + /** + * Only show completions while typing, not when moving the cursor. + */ + private void updateCompletions(boolean textChanged) { + if (textChanged && this.completionProvider != null) { + ScriptCompletions completionsNew = this.completionProvider.apply(this.getValue(), this.getCursorPos()); + if (completionsNew == null || this.completions == null || !completionsNew.items().equals(this.completions.items())) { + this.completionIndex = 0; + } + this.completions = completionsNew; + } else { + this.completions = null; + } + } + + /** + * @return If completions were shown. + */ + public boolean dismissCompletions() { + boolean shown = this.completions != null; + this.completions = null; + return shown; + } + + private boolean completionKeyPressed(int key) { + int count = this.completions.items().size(); + switch (key) { + case GLFW.GLFW_KEY_DOWN: + this.completionIndex = (this.completionIndex + 1) % count; + return true; + case GLFW.GLFW_KEY_UP: + this.completionIndex = (this.completionIndex + count - 1) % count; + return true; + case GLFW.GLFW_KEY_TAB: + case GLFW.GLFW_KEY_ENTER: + case GLFW.GLFW_KEY_KP_ENTER: + this.acceptCompletion(); + return true; + case GLFW.GLFW_KEY_ESCAPE: + this.completions = null; + return true; + default: + return false; + } + } + + private void acceptCompletion() { + TextHistory.State before = this.getState(); + // Replace the text directly instead of selecting it, as a selection would change the bound member + String insert = this.completions.items().get(this.completionIndex).name() + "("; + String value = this.getValue(); + int cursorPos = this.getCursorPos(); + int start = this.completions.start(); + int newCursorPos = start + insert.length(); + this.completions = null; + this.setState(new TextHistory.State(value.substring(0, start) + insert + value.substring(cursorPos), newCursorPos, newCursorPos)); + this.recordHistory(before, false); + this.clearDisplayCache(); + this.ensureCursorVisible(); + } + public void setValue(String value) { this.firstRow = 0; // Another script may have the same contents but another language @@ -352,6 +425,7 @@ public void tick() { @Override public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { + this.completions = null; if (mouseButton == 1 && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { // Don't select everything on right-click, as typing would then replace the whole script @@ -451,6 +525,9 @@ private void updateDragSelection() { @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { + if (this.completions != null && this.completionKeyPressed(typedChar)) { + return true; + } if (this.shortcutListener != null && this.shortcutListener.keyPressed(typedChar, keyCode, modifiers)) { return true; } @@ -461,6 +538,7 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { if (!history) { this.recordHistory(before, false); } + this.updateCompletions(!history && !before.value().equals(this.getValue())); this.clearDisplayCache(); this.setFocused(true); this.ensureCursorVisible(); @@ -481,6 +559,7 @@ public boolean charTyped(char typedChar, int keyCode) { TextHistory.State before = this.getState(); this.textFieldHelper.insertText(Character.toString(typedChar)); this.recordHistory(before, true); + this.updateCompletions(true); this.clearDisplayCache(); this.setFocused(true); this.ensureCursorVisible(); @@ -803,6 +882,11 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float this.renderHighlight(displayCache.selection); if (displayCache.cursor != null) { this.renderCursor(guiGraphics, displayCache.cursor, displayCache.cursorAtEnd); + + // Show completions while typing + if (this.isFocused() && this.completions != null) { + this.renderCompletions(guiGraphics, this.convertLocalToScreen(displayCache.cursor)); + } } // Render scrollbar @@ -854,6 +938,60 @@ private void renderCharBackground(GuiGraphics guiGraphics, DisplayCache displayC guiGraphics.fill(screenPos.x, screenPos.y - 1, screenPos.x + width, screenPos.y + ROW_HEIGHT - 1, color); } + private void renderCompletions(GuiGraphics guiGraphics, Pos2i cursor) { + List items = this.completions.items(); + int rows = Math.min(items.size(), COMPLETION_ROWS); + int firstItem = Math.max(0, Math.min(this.completionIndex - rows / 2, items.size() - rows)); + // Grow beyond the editor if needed, as signatures can be long + int width = getTextWidth(); + for (int i = 0; i < rows; i++) { + ScriptCompletions.Item item = items.get(firstItem + i); + width = Math.max(width, this.font.width(item.name()) + 4 + this.font.width(item.detail()) + 2); + } + Pair xAndWidth = this.fitHorizontally(width); + int x = xAndWidth.getLeft(); + width = xAndWidth.getRight(); + int height = rows * ROW_HEIGHT + 2; + // Show below the line of the cursor if possible, otherwise above it + int y = cursor.y + ROW_HEIGHT; + if (y + height > this.getY() + this.getHeight() && cursor.y - height - 1 >= this.getY()) { + y = cursor.y - height - 1; + } + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate(0, 0, 200); + guiGraphics.fill(x - 1, y - 1, x + width + 1, y + height + 1, Helpers.RGBAToInt(80, 80, 80, 255)); + guiGraphics.fill(x, y, x + width, y + height, Helpers.RGBAToInt(250, 250, 250, 255)); + for (int i = 0; i < rows; i++) { + ScriptCompletions.Item item = items.get(firstItem + i); + int rowY = y + 1 + i * ROW_HEIGHT; + if (firstItem + i == this.completionIndex) { + guiGraphics.fill(x, rowY, x + width, rowY + ROW_HEIGHT, Helpers.RGBAToInt(190, 210, 255, 255)); + } + String name = this.font.plainSubstrByWidth(item.name(), width - 2); + guiGraphics.drawString(this.font, name, x + 1, rowY + 1, 0, false); + int detailX = x + 1 + this.font.width(name) + 4; + int detailWidth = x + width - 1 - detailX; + if (detailWidth > 10) { + String detail = item.detail(); + if (this.font.width(detail) > detailWidth) { + detail = this.font.plainSubstrByWidth(detail, detailWidth - this.font.width("\u2026")) + "\u2026"; + } + guiGraphics.drawString(this.font, detail, detailX, rowY + 1, Helpers.RGBToInt(120, 120, 120), false); + } + } + guiGraphics.pose().popPose(); + } + + /** + * @return The x position and width of a popup at the start of the text, moved left if it would leave the screen. + */ + private Pair fitHorizontally(int width) { + int screenWidth = Minecraft.getInstance().getWindow().getGuiScaledWidth(); + width = Math.min(width, screenWidth - 4); + int x = Math.min(this.getX() + getLinesXOffset(), screenWidth - 2 - width); + return Pair.of(x, width); + } + private void renderCursor(GuiGraphics guiGraphics, Pos2i pos, boolean cursorAtEnd) { if (this.isFocused() && this.frameTick / 6 % 2 == 0) { pos = this.convertLocalToScreen(pos); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index e19101ca9..312a9167c 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -182,6 +182,10 @@ public int getTotalRows() { }); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); + textArea.setCompletionProvider((text, cursor) -> { + ILanguageHandler languageHandler = getActiveLanguageHandler(); + return languageHandler != null ? languageHandler.getCompletions(text, cursor) : null; + }); textArea.setMarkupProvider(text -> { Path path = getMenu().getActiveScriptPath(); ILanguageHandler languageHandler = path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; @@ -733,6 +737,12 @@ public void setFirstRow(int firstRow) { this.firstRow = firstRow; } + @Nullable + private ILanguageHandler getActiveLanguageHandler() { + Path path = getMenu().getActiveScriptPath(); + return path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; + } + private void onActiveScriptSelected() { String script = getMenu().getActiveScript(); this.textArea.setValue(script == null ? "" : script); @@ -894,6 +904,11 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return true; } + // Close completions before leaving focus mode or closing the screen + if (typedChar == GLFW.GLFW_KEY_ESCAPE && this.textArea.isFocused() && this.textArea.dismissCompletions()) { + return true; + } + // Leave focus mode instead of closing the screen if (focusMode && typedChar == GLFW.GLFW_KEY_ESCAPE && pendingDialog == null) { this.toggleFocusMode(); diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java new file mode 100644 index 000000000..dfb8d68f5 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java @@ -0,0 +1,54 @@ +package org.cyclops.integratedscripting.core.language; + +import com.google.common.collect.Lists; +import org.cyclops.integratedscripting.api.language.ScriptCompletions; + +import javax.annotation.Nullable; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Completion of the global operators on idContext.ops in JavaScript. + * @author rubensworks + */ +public class JavaScriptCompletions { + + public static final String OPS_PREFIX = "idContext.ops."; + + /** + * @param script A script. + * @param cursor The cursor position. + * @param signatures Operator names mapped to their signatures. + * @return The operators matching the name before the cursor, or null if the cursor is not after idContext.ops. + */ + @Nullable + public static ScriptCompletions complete(String script, int cursor, Map signatures) { + int start = cursor; + while (start > 0 && Character.isJavaIdentifierPart(script.charAt(start - 1))) { + start--; + } + if (!script.startsWith(OPS_PREFIX, start - OPS_PREFIX.length())) { + return null; + } + // Don't complete in the middle of a name + if (cursor < script.length() && Character.isJavaIdentifierPart(script.charAt(cursor))) { + return null; + } + String prefix = script.substring(start, cursor); + String prefixLower = prefix.toLowerCase(Locale.ROOT); + List items = Lists.newArrayList(); + for (Map.Entry entry : signatures.entrySet()) { + if (entry.getKey().toLowerCase(Locale.ROOT).startsWith(prefixLower)) { + items.add(new ScriptCompletions.Item(entry.getKey(), entry.getValue())); + } + } + if (items.isEmpty() || (items.size() == 1 && items.get(0).name().equals(prefix))) { + return null; + } + items.sort(Comparator.comparing(ScriptCompletions.Item::name)); + return new ScriptCompletions(start, items); + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index 0fe44e486..13b65039a 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -8,10 +8,14 @@ import org.apache.commons.lang3.tuple.Pair; import org.cyclops.cyclopscore.client.gui.image.IImage; import org.cyclops.cyclopscore.helper.Helpers; +import org.cyclops.cyclopscore.helper.L10NHelpers; import org.cyclops.integrateddynamics.api.evaluate.EvaluationException; +import org.cyclops.integrateddynamics.api.evaluate.operator.IOperator; +import org.cyclops.integrateddynamics.core.evaluate.operator.Operators; import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.api.language.ScriptCompletions; import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.api.network.IScriptFactory; import org.cyclops.integratedscripting.client.gui.image.ScriptImages; @@ -31,6 +35,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** * @author rubensworks @@ -55,6 +60,8 @@ public class LanguageHandlerJavaScript implements ILanguageHandler { public final Map tokenStyles; private final IScriptFactory scriptFactory; + @Nullable + private Map operatorSignatures; public LanguageHandlerJavaScript() { this.tokenStyles = Maps.newHashMap(); @@ -174,6 +181,28 @@ public Set getTopLevelMembers(String script) { return JavaScriptDeclarations.find(script); } + @Nullable + @Override + public ScriptCompletions getCompletions(String script, int cursor) { + return JavaScriptCompletions.complete(script, cursor, getOperatorSignatures()); + } + + protected Map getOperatorSignatures() { + // Operators are registered once, so this can be cached + if (this.operatorSignatures == null) { + Map signatures = Maps.newHashMap(); + for (Map.Entry entry : Operators.REGISTRY.getGlobalInteractOperators().entrySet()) { + IOperator operator = entry.getValue(); + String inputs = Arrays.stream(operator.getInputTypes()) + .map(type -> L10NHelpers.localize(type.getTranslationKey())) + .collect(Collectors.joining(", ")); + signatures.put(entry.getKey(), "(" + inputs + "): " + L10NHelpers.localize(operator.getOutputType().getTranslationKey())); + } + this.operatorSignatures = signatures; + } + return this.operatorSignatures; + } + @Override public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java new file mode 100644 index 000000000..bd0fda702 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java @@ -0,0 +1,49 @@ +package org.cyclops.integratedscripting.core.language; + +import com.google.common.collect.Lists; +import org.cyclops.integratedscripting.api.language.ScriptCompletions; +import org.junit.Test; + +import java.util.Map; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +public class JavaScriptCompletionsTest { + + private static final Map SIGNATURES = Map.of( + "itemstackSize", "(Item): Integer", + "itemstackIsStackable", "(Item): Boolean", + "integerMax", "(Integer, Integer): Integer" + ); + + private static ScriptCompletions complete(String script) { + return JavaScriptCompletions.complete(script, script.length(), SIGNATURES); + } + + @Test + public void testCompleteAll() { + assertThat(complete("const a = idContext.ops."), equalTo(new ScriptCompletions(24, Lists.newArrayList( + new ScriptCompletions.Item("integerMax", "(Integer, Integer): Integer"), + new ScriptCompletions.Item("itemstackIsStackable", "(Item): Boolean"), + new ScriptCompletions.Item("itemstackSize", "(Item): Integer") + )))); + } + + @Test + public void testCompletePrefixIgnoringCase() { + assertThat(complete("idContext.ops.ITEMSTACKs"), equalTo(new ScriptCompletions(14, Lists.newArrayList( + new ScriptCompletions.Item("itemstackSize", "(Item): Integer") + )))); + } + + @Test + public void testCompleteNone() { + assertThat(complete("const a = ops."), nullValue()); + assertThat(complete("idContext.ops.foo"), nullValue()); + assertThat(complete("idContext.ops.itemstackSize"), nullValue()); + assertThat(JavaScriptCompletions.complete("idContext.ops.itemstack", 18, SIGNATURES), nullValue()); + } + +} From 62276da05f9fc950b7d6629635124f8ae8e4b86d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:48:14 +0000 Subject: [PATCH 42/59] Only bind variable cards to top-level declarations Moving the cursor through a function body rebound the card to whichever word was under the cursor, such as a parameter or a method name, which can not be evaluated. The cursor now only selects names of top-level functions, classes and variables, and keeps the previous one otherwise. Explicit selections are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../api/language/ILanguageHandler.java | 10 ++ .../ContainerScreenTerminalScripting.java | 10 ++ .../core/language/JavaScriptDeclarations.java | 119 ++++++++++++++++++ .../language/LanguageHandlerJavaScript.java | 6 + .../language/JavaScriptDeclarationsTest.java | 53 ++++++++ 5 files changed, 198 insertions(+) create mode 100644 src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java create mode 100644 src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index dad294ad5..2b7a39850 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -56,6 +56,16 @@ public default Set getReservedWords() { return Collections.emptySet(); } + /** + * @param script The script contents. + * @return The names of the members declared at the top level of the script, + * or null if they can not be determined. + */ + @Nullable + public default Set getTopLevelMembers(String script) { + return null; + } + /** * Check a script for syntax errors, without executing it. * @param path The script path. diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index b51c9cb93..ca73cbc7b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -61,6 +61,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -758,6 +759,15 @@ private void onSelectionModified() { startPos = value.length() - matcherToStart.end() + 1; } selected = value.substring(startPos, endPos); + + // Only bind to declarations, so moving through function bodies doesn't rebind the card + Path path = getMenu().getActiveScriptPath(); + ILanguageHandler languageHandler = path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; + Set members = languageHandler != null ? languageHandler.getTopLevelMembers(value) : null; + if (members != null && !members.contains(selected)) { + String previous = getMenu().getSelectedMember(); + selected = members.contains(previous) ? previous : ""; + } } } diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java new file mode 100644 index 000000000..9ed54f34a --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarations.java @@ -0,0 +1,119 @@ +package org.cyclops.integratedscripting.core.language; + +import com.google.common.collect.Sets; + +import javax.annotation.Nullable; +import java.util.Set; + +/** + * Finds the names declared at the top level of a JavaScript script, without executing or fully parsing it. + * @author rubensworks + */ +public class JavaScriptDeclarations { + + // Characters after which a slash starts a regex instead of a division + private static final String REGEX_PRECEDERS = "(,=:[!&|?{};+-*%<>~^"; + // Characters after which a function or class is an expression instead of a declaration + private static final String EXPRESSION_PRECEDERS = "(,=:[!&|?+-*%<>~^"; + + /** + * @param script A script. + * @return The names of the top-level functions, classes and variables, + * or null if the script contains declarations that are not supported, such as destructuring. + */ + @Nullable + public static Set find(String script) { + Set names = Sets.newHashSet(); + int depth = 0; + int length = script.length(); + // The last significant character, to detect member accesses and regexes + char previous = ';'; + String previousWord = ""; + // If a name is expected next, after a declaration keyword or a comma in a variable declaration + boolean expectName = false; + boolean inVariableDeclaration = false; + int i = 0; + while (i < length) { + char c = script.charAt(i); + if (Character.isWhitespace(c)) { + i++; + } else if (c == '/' && i + 1 < length && script.charAt(i + 1) == '/') { + int end = script.indexOf('\n', i); + i = end < 0 ? length : end; + } else if (c == '/' && i + 1 < length && script.charAt(i + 1) == '*') { + int end = script.indexOf("*/", i + 2); + i = end < 0 ? length : end + 2; + } else if (c == '"' || c == '\'' || c == '`' + || (c == '/' && (REGEX_PRECEDERS.indexOf(previous) >= 0 || previousWord.equals("return")))) { + i = skipQuoted(script, i, c); + previous = 'a'; + previousWord = ""; + expectName = false; + } else if (Character.isJavaIdentifierStart(c)) { + int start = i; + while (i < length && Character.isJavaIdentifierPart(script.charAt(i))) { + i++; + } + String word = script.substring(start, i); + if (depth == 0 && previous != '.') { + if (expectName) { + names.add(word); + expectName = false; + } else if ((word.equals("function") || word.equals("class")) + && EXPRESSION_PRECEDERS.indexOf(previous) < 0 && !previousWord.equals("return")) { + expectName = true; + inVariableDeclaration = false; + } else if (word.equals("var") || word.equals("let") || word.equals("const")) { + expectName = true; + inVariableDeclaration = true; + } + } + previous = 'a'; + previousWord = word; + } else { + if (depth == 0) { + if (expectName && (c == '{' || c == '[') && inVariableDeclaration) { + // Destructuring + return null; + } + if (c == ';') { + inVariableDeclaration = false; + } else if (c == ',' && inVariableDeclaration) { + expectName = true; + } else if (c != '*') { + // Allow generator functions + expectName = false; + } + } + if (c == '{' || c == '(' || c == '[') { + depth++; + } else if ((c == '}' || c == ')' || c == ']') && depth > 0) { + depth--; + } + previous = c; + previousWord = ""; + i++; + } + } + return names; + } + + private static int skipQuoted(String script, int start, char quote) { + int i = start + 1; + while (i < script.length()) { + char c = script.charAt(i); + if (c == '\\') { + i += 2; + } else if (c == quote) { + return i + 1; + } else if (c == '\n' && quote != '`') { + // Unterminated + return i; + } else { + i++; + } + } + return i; + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index 8c4c09c84..0fe44e486 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -168,6 +168,12 @@ public Set getReservedWords() { return RESERVED_WORDS; } + @Nullable + @Override + public Set getTopLevelMembers(String script) { + return JavaScriptDeclarations.find(script); + } + @Override public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java new file mode 100644 index 000000000..8e9cb995a --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptDeclarationsTest.java @@ -0,0 +1,53 @@ +package org.cyclops.integratedscripting.core.language; + +import com.google.common.collect.Sets; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +public class JavaScriptDeclarationsTest { + + @Test + public void testDeclarations() { + assertThat(JavaScriptDeclarations.find("function a(x, y) { return x; }\nconst b = 1;\nlet c = 2\nvar d\nclass E {}\nasync function f() {}\nfunction* g() {}\nexport const h = 3;"), + equalTo(Sets.newHashSet("a", "b", "c", "d", "E", "f", "g", "h"))); + } + + @Test + public void testMultipleDeclarators() { + assertThat(JavaScriptDeclarations.find("const a = f(1, 2), b = [3, 4], c = { x: 1, y: 2 };\nfoo(d, e);"), + equalTo(Sets.newHashSet("a", "b", "c"))); + } + + @Test + public void testIgnoresNested() { + assertThat(JavaScriptDeclarations.find("function a(item) {\n const b = item.size();\n function c() {}\n return b;\n}\nconst d = (x) => { let e = x; return e; };"), + equalTo(Sets.newHashSet("a", "d"))); + } + + @Test + public void testIgnoresExpressions() { + assertThat(JavaScriptDeclarations.find("const a = function b() {};\nconst c = class D {};\nfoo(function e() {});"), + equalTo(Sets.newHashSet("a", "c"))); + } + + @Test + public void testIgnoresStringsCommentsAndRegexes() { + assertThat(JavaScriptDeclarations.find("// const a = 1;\n/* function b() {} */\nconst c = \"const d = '{';\";\nconst e = /[{(]/.test(c);\nconst f = `${c} {`;\nconst g = 4 / 2;"), + equalTo(Sets.newHashSet("c", "e", "f", "g"))); + } + + @Test + public void testIgnoresMemberAccesses() { + assertThat(JavaScriptDeclarations.find("const a = idContext.ops.function;"), equalTo(Sets.newHashSet("a"))); + } + + @Test + public void testDestructuringUnsupported() { + assertThat(JavaScriptDeclarations.find("const { a, b } = c;"), nullValue()); + assertThat(JavaScriptDeclarations.find("const [a, b] = c;"), nullValue()); + } + +} From ad76d8b938e2e233e162cb589bf6c141ea96fd79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:03:38 +0000 Subject: [PATCH 43/59] Show the last evaluation of the bound card in the scripting terminal Hovering the binding label of a bound card now shows the last value or error of its member as evaluated by the network, and the last error from calling it as a function. It shows that it was not evaluated yet when the script changed since. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../IntegratedScripting.java | 2 + .../ContainerScreenTerminalScripting.java | 16 ++- .../evaluate/ScriptEvaluationStatuses.java | 107 ++++++++++++++++++ .../core/evaluate/ScriptVariable.java | 11 ++ .../evaluate/ScriptHelpers.java | 10 +- .../gametest/GameTestsScripts.java | 56 +++++++++ .../container/ContainerTerminalScripting.java | 23 +++- .../integratedscripting/lang/en_us.json | 4 + 8 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptEvaluationStatuses.java diff --git a/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java b/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java index b1281162d..d97174697 100644 --- a/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java @@ -37,6 +37,7 @@ import org.cyclops.integratedscripting.command.CommandTestScript; import org.cyclops.integratedscripting.component.DataComponentDiskIdConfig; import org.cyclops.integratedscripting.core.client.model.ScriptingVariableModelProviders; +import org.cyclops.integratedscripting.core.evaluate.ScriptEvaluationStatuses; import org.cyclops.integratedscripting.core.evaluate.ScriptVariableFacadeHandler; import org.cyclops.integratedscripting.core.packageddependencies.PackagedDependenciesLoader; import org.cyclops.integratedscripting.core.language.LanguageHandlerRegistry; @@ -128,6 +129,7 @@ protected void onServerStopping(ServerStoppingEvent event) { } this.scriptingData = null; EvaluationExceptionResolutionHelpers.reset(); + ScriptEvaluationStatuses.reset(); } @SubscribeEvent diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index ca73cbc7b..e19101ca9 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -16,9 +16,11 @@ import net.minecraft.ChatFormatting; import net.minecraft.Util; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.Style; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.StringUtil; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; @@ -538,7 +540,19 @@ protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { Component bindingLabel = getBindingLabel(); if (bindingLabel != null) { int right = getErrorX() - 2; - guiGraphics.drawString(font, bindingLabel, right - font.width(bindingLabel), getErrorY() + 2, 4210752, false); + int left = right - font.width(bindingLabel); + guiGraphics.drawString(font, bindingLabel, left, getErrorY() + 2, 4210752, false); + + // Show the last evaluation of the bound card + if (getMenu().getCardMember() != null && pendingDialog == null + && mouseX >= this.leftPos + left && mouseX < this.leftPos + right + && mouseY >= this.topPos + getErrorY() + 1 && mouseY < this.topPos + getErrorY() + 2 + font.lineHeight) { + List lines = Lists.newArrayList(); + for (MutableComponent line : getMenu().getCardEvaluation()) { + lines.addAll(font.split(line, 200)); + } + this.setTooltipForNextRenderPass(lines); + } } displayErrors.drawForeground(guiGraphics.pose(), getMenu().getReadErrors(), getErrorX(), getErrorY(), mouseX, mouseY, this, this.leftPos, this.topPos); diff --git a/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptEvaluationStatuses.java b/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptEvaluationStatuses.java new file mode 100644 index 000000000..b7d6407b9 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptEvaluationStatuses.java @@ -0,0 +1,107 @@ +package org.cyclops.integratedscripting.core.evaluate; + +import com.google.common.collect.Lists; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import org.apache.commons.lang3.tuple.Triple; +import org.cyclops.integrateddynamics.api.evaluate.variable.IValue; +import org.cyclops.integratedscripting.IntegratedScripting; +import org.cyclops.integratedscripting.core.network.ScriptingData; + +import javax.annotation.Nullable; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The last evaluation results of script members, so they can be shown while editing. + * @author rubensworks + */ +public class ScriptEvaluationStatuses { + + private static final int MAX_VALUE_LENGTH = 100; + private static final Map, Status> STATUSES = new ConcurrentHashMap<>(); + + public static void recordValue(int disk, Path path, String member, IValue value) { + Status status = getOrCreate(disk, path, member); + status.value = value; + status.readError = null; + } + + public static void recordReadError(int disk, Path path, String member, MutableComponent error) { + Status status = getOrCreate(disk, path, member); + status.value = null; + status.readError = error; + // Read errors are also created by the exception factory, so don't show them twice + status.callError = null; + } + + public static void recordCallError(int disk, Path path, String member, MutableComponent error) { + getOrCreate(disk, path, member).callError = error; + } + + private static Status getOrCreate(int disk, Path path, String member) { + String script = getScript(disk, path); + Status status = STATUSES.get(Triple.of(disk, path, member)); + if (status == null || !Objects.equals(status.script, script)) { + status = new Status(script); + STATUSES.put(Triple.of(disk, path, member), status); + } + return status; + } + + @Nullable + private static String getScript(int disk, Path path) { + // Scripting data is not available outside of a running server, such as in unit tests + ScriptingData scriptingData = IntegratedScripting._instance == null ? null : IntegratedScripting._instance.scriptingData; + return scriptingData == null ? null : scriptingData.getScripts(disk).get(path); + } + + /** + * @return Lines describing the last evaluation of the member since its script last changed. + */ + public static List describe(int disk, Path path, String member) { + Status status = STATUSES.get(Triple.of(disk, path, member)); + List lines = Lists.newArrayList(); + if (status == null || !Objects.equals(status.script, getScript(disk, path)) || (status.value == null && status.readError == null)) { + lines.add(Component.translatable("gui.integratedscripting.evaluation.none").withStyle(ChatFormatting.GRAY)); + return lines; + } + if (status.readError != null) { + lines.add(Component.translatable("gui.integratedscripting.evaluation.error", status.readError.copy()).withStyle(ChatFormatting.RED)); + } else { + String value = status.value.getType().toCompactString(status.value).getString(); + if (value.length() > MAX_VALUE_LENGTH) { + value = value.substring(0, MAX_VALUE_LENGTH) + "…"; + } + lines.add(Component.translatable("gui.integratedscripting.evaluation.value", Component.literal(value).withStyle(ChatFormatting.WHITE)).withStyle(ChatFormatting.GRAY)); + } + if (status.callError != null) { + lines.add(Component.translatable("gui.integratedscripting.evaluation.call_error", status.callError.copy()).withStyle(ChatFormatting.RED)); + } + return lines; + } + + public static void reset() { + STATUSES.clear(); + } + + private static class Status { + @Nullable + private final String script; + @Nullable + private IValue value; + @Nullable + private MutableComponent readError; + @Nullable + private MutableComponent callError; + + private Status(@Nullable String script) { + this.script = script; + } + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptVariable.java b/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptVariable.java index 1b0c1d7ff..379c22b7a 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptVariable.java +++ b/src/main/java/org/cyclops/integratedscripting/core/evaluate/ScriptVariable.java @@ -44,6 +44,17 @@ public IValueType getType() { @Override public IValue getValue() throws EvaluationException { + try { + IValue value = this.evaluate(); + ScriptEvaluationStatuses.recordValue(this.disk, this.path, this.member, value); + return value; + } catch (EvaluationException e) { + ScriptEvaluationStatuses.recordReadError(this.disk, this.path, this.member, e.getErrorMessage()); + throw e; + } + } + + protected IValue evaluate() throws EvaluationException { if (this.lastEvaluationException != null) { throw this.lastEvaluationException; } diff --git a/src/main/java/org/cyclops/integratedscripting/evaluate/ScriptHelpers.java b/src/main/java/org/cyclops/integratedscripting/evaluate/ScriptHelpers.java index 997f90eff..1766fa08a 100644 --- a/src/main/java/org/cyclops/integratedscripting/evaluate/ScriptHelpers.java +++ b/src/main/java/org/cyclops/integratedscripting/evaluate/ScriptHelpers.java @@ -8,6 +8,7 @@ import org.cyclops.integrateddynamics.core.evaluate.variable.ValueTypeOperator; import org.cyclops.integratedscripting.GeneralConfig; import org.cyclops.integratedscripting.api.evaluate.translation.IEvaluationExceptionFactory; +import org.cyclops.integratedscripting.core.evaluate.ScriptEvaluationStatuses; import org.cyclops.integratedscripting.core.packageddependencies.UnsafeHelper; import org.cyclops.integratedscripting.evaluate.translation.ValueTranslators; import org.graalvm.polyglot.*; @@ -144,9 +145,12 @@ public static IEvaluationExceptionFactory getDummyEvaluationExceptionFactory() { public static IEvaluationExceptionFactory getEvaluationExceptionFactory(int disk, Path path, String member) { EvaluationExceptionResolutionHelpers.expungeStaleEvaluationExceptions(); - return message -> EvaluationExceptionResolutionHelpers.resolveOnScriptChange( - new EvaluationException(Component.translatable("script.integratedscripting.error.script_exec", member, path.toString(), disk, message)), - disk, path); + return message -> { + ScriptEvaluationStatuses.recordCallError(disk, path, member, message); + return EvaluationExceptionResolutionHelpers.resolveOnScriptChange( + new EvaluationException(Component.translatable("script.integratedscripting.error.script_exec", member, path.toString(), disk, message)), + disk, path); + }; } } diff --git a/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java b/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java index b7c2186e9..07c20d95d 100644 --- a/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java +++ b/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java @@ -6,6 +6,8 @@ import net.minecraft.gametest.framework.GameTestAssertException; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.contents.TranslatableContents; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.neoforged.neoforge.gametest.GameTestHolder; @@ -20,9 +22,11 @@ import org.cyclops.integrateddynamics.part.PartTypePanelDisplay; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.network.IScriptingData; +import org.cyclops.integratedscripting.core.evaluate.ScriptEvaluationStatuses; import org.cyclops.integratedscripting.core.network.ScriptingNetworkHelpers; import java.nio.file.Path; +import java.util.List; import static org.cyclops.integrateddynamics.gametest.GameTestHelpersIntegratedDynamics.*; import static org.cyclops.integratedscripting.gametest.GameTestHelpersIntegratedScripting.createBasicNetwork; @@ -188,4 +192,56 @@ public void testScriptsDisplayScriptInfiniteLoop(GameTestHelper helper) { }); } + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testScriptsEvaluationStatus(GameTestHelper helper) { + GameTestHelpersIntegratedScripting.NetworkPositions positions = createBasicNetwork(helper, POS); + Path path = Path.of("script0.js"); + ScriptingNetworkHelpers.getScriptingData().setScript(positions.diskId(), path, "const abc = 3", IScriptingData.ChangeLocation.MEMORY); + helper.assertTrue(getEvaluationStatusKey(positions.diskId(), path, "abc").equals("gui.integratedscripting.evaluation.none"), "Script must not be evaluated yet"); + + ItemStack variableScript = createVariableForScript(helper.getLevel(), positions.diskId(), path, "abc"); + placeVariableInDisplayPanel(helper.getLevel(), positions.displayPanel(), variableScript); + + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue(getEvaluationStatusKey(positions.diskId(), path, "abc").equals("gui.integratedscripting.evaluation.value"), "Value must be recorded"); + helper.assertTrue(ScriptEvaluationStatuses.describe(positions.diskId(), path, "abc").get(0).getString().contains("3"), "Value must be 3"); + }) + .thenExecute(() -> ScriptingNetworkHelpers.getScriptingData().setScript(positions.diskId(), path, "const abc = doesNotExist();", IScriptingData.ChangeLocation.MEMORY)) + .thenWaitUntil(() -> helper.assertTrue(getEvaluationStatusKey(positions.diskId(), path, "abc").equals("gui.integratedscripting.evaluation.error"), "Error must be recorded")) + .thenSucceed(); + } + + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testScriptsEvaluationStatusCallError(GameTestHelper helper) { + GameTestHelpersIntegratedScripting.NetworkPositions positions = createBasicNetwork(helper, POS); + Path path = Path.of("script0.js"); + ScriptingNetworkHelpers.getScriptingData().setScript(positions.diskId(), path, "function abc(a, b) { throw new Error('boom ' + (a + b)); }", IScriptingData.ChangeLocation.MEMORY); + + // Apply the function in a display panel + ItemStack variableScript = createVariableForScript(helper.getLevel(), positions.diskId(), path, "abc"); + ItemStack variableConst1 = createVariableForValue(helper.getLevel(), ValueTypes.INTEGER, ValueTypeInteger.ValueInteger.of(3)); + ItemStack variableConst2 = createVariableForValue(helper.getLevel(), ValueTypes.INTEGER, ValueTypeInteger.ValueInteger.of(8)); + positions.variableStore().getInventory().setItem(0, variableScript); + positions.variableStore().getInventory().setItem(1, variableConst1); + positions.variableStore().getInventory().setItem(2, variableConst2); + ItemStack variableApplied = createVariableForOperator(helper.getLevel(), Operators.OPERATOR_APPLY_2, new int[]{ + getVariableFacade(helper.getLevel(), variableScript).getId(), + getVariableFacade(helper.getLevel(), variableConst1).getId(), + getVariableFacade(helper.getLevel(), variableConst2).getId() + }); + placeVariableInDisplayPanel(helper.getLevel(), positions.displayPanel(), variableApplied); + + helper.succeedWhen(() -> { + List lines = ScriptEvaluationStatuses.describe(positions.diskId(), path, "abc"); + helper.assertTrue(lines.size() == 2, "The value and call error must be described"); + helper.assertTrue(((TranslatableContents) lines.get(1).getContents()).getKey().equals("gui.integratedscripting.evaluation.call_error"), "Call error must be recorded"); + helper.assertTrue(lines.get(1).getString().contains("boom 11"), "Call error must contain the thrown message"); + }); + } + + private static String getEvaluationStatusKey(int disk, Path path, String member) { + return ((TranslatableContents) ScriptEvaluationStatuses.describe(disk, path, member).get(0).getContents()).getKey(); + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java index 21ce2e97a..736dcf3f9 100644 --- a/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/inventory/container/ContainerTerminalScripting.java @@ -40,6 +40,7 @@ import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; import org.cyclops.integratedscripting.api.network.IScriptingData; import org.cyclops.integratedscripting.api.network.IScriptingNetwork; +import org.cyclops.integratedscripting.core.evaluate.ScriptEvaluationStatuses; import org.cyclops.integratedscripting.core.evaluate.ScriptVariableFacadeHandler; import org.cyclops.integratedscripting.core.item.ScriptVariableFacade; import org.cyclops.integratedscripting.core.language.LanguageHandlers; @@ -80,6 +81,7 @@ public class ContainerTerminalScripting extends InventoryContainer implements ID private final int activeDiskId; private final int activeScriptPathId; private final int selectionId; + private final int cardEvaluationId; @Nullable private BiConsumer remoteScriptChangeListener; private final Map, ScriptSyntaxError> syntaxErrors = Maps.newHashMap(); @@ -119,6 +121,7 @@ public ContainerTerminalScripting(int id, Inventory playerInventory, setActiveDisk(this.availableDisks.isEmpty() ? -1 : this.availableDisks.getInt(0)); this.activeScriptPathId = getNextValueId(); this.selectionId = getNextValueId(); + this.cardEvaluationId = getNextValueId(); } @@ -235,9 +238,21 @@ public void broadcastChanges() { }); this.checkActiveScriptSyntax(); + + // Send the last evaluation of the bound card + IScriptVariableFacade cardFacade = getCardFacade(); + ValueNotifierHelpers.setValue(this, cardEvaluationId, cardFacade == null ? Collections.emptyList() + : ScriptEvaluationStatuses.describe(cardFacade.getDisk(), cardFacade.getPath(), cardFacade.getMember())); } } + /** + * @return Lines describing the last evaluation of the bound card. + */ + public List getCardEvaluation() { + return ValueNotifierHelpers.getValueTextComponentList(this, cardEvaluationId); + } + /** * Check the active script for syntax errors once it hasn't changed for a while, and send the result to the client. */ @@ -427,13 +442,19 @@ public boolean isReservedWord(String word) { */ @Nullable public String getCardMember() { + IScriptVariableFacade facade = getCardFacade(); + return facade != null ? facade.getMember() : null; + } + + @Nullable + protected IScriptVariableFacade getCardFacade() { ItemStack itemStack = getContainerInventory().getItem(0); if (itemStack.isEmpty()) { return null; } IVariableFacadeHandlerRegistry registry = IntegratedDynamics._instance.getRegistryManager().getRegistry(IVariableFacadeHandlerRegistry.class); IVariableFacade variableFacade = registry.handle(ValueDeseralizationContext.of(getLevel()), itemStack); - return variableFacade instanceof IScriptVariableFacade scriptVariableFacade ? scriptVariableFacade.getMember() : null; + return variableFacade instanceof IScriptVariableFacade scriptVariableFacade ? scriptVariableFacade : null; } @Nullable diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 83af42152..15ae22924 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -50,6 +50,10 @@ "gui.integratedscripting.error.reserved_member": "\"%s\" is a keyword, so it can not be bound. Select a function name or variable name instead.", "gui.integratedscripting.binding.next": "Binds to: %s", "gui.integratedscripting.binding.card": "Bound to: %s", + "gui.integratedscripting.evaluation.none": "Not evaluated by the network since the script last changed", + "gui.integratedscripting.evaluation.value": "Last value: %s", + "gui.integratedscripting.evaluation.error": "Last error: %s", + "gui.integratedscripting.evaluation.call_error": "Last error when called: %s", "key.categories.integratedscripting": "Integrated Scripting", "key.integratedscripting.undo": "Undo", From 77d5d2bb4c944dbc505957a4824842c0a653ccd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:22:31 +0000 Subject: [PATCH 44/59] Show the signature of idContext.ops calls in the script editor While the cursor is inside the arguments of an idContext.ops call, its argument and output types are shown above the line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../api/language/ILanguageHandler.java | 10 +++++ .../gui/component/input/WidgetTextArea.java | 40 +++++++++++++++-- .../ContainerScreenTerminalScripting.java | 4 ++ .../core/language/JavaScriptCompletions.java | 43 +++++++++++++++++++ .../language/LanguageHandlerJavaScript.java | 6 +++ .../language/JavaScriptCompletionsTest.java | 18 ++++++++ 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index 551a0eb03..c3ac9eab1 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -76,6 +76,16 @@ public default ScriptCompletions getCompletions(String script, int cursor) { return null; } + /** + * @param script The script contents. + * @param cursor The cursor position. + * @return The signature of the function call the cursor is in, or null if unknown. + */ + @Nullable + public default String getSignatureHint(String script, int cursor) { + return null; + } + /** * Check a script for syntax errors, without executing it. * @param path The script path. diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 81045c7ae..dc63f2fbb 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -91,6 +91,8 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { @Nullable private BiFunction completionProvider; @Nullable + private BiFunction signatureProvider; + @Nullable private ScriptCompletions completions; private int completionIndex; private WidgetTextArea.DisplayCache emptyDisplayCache = createEmptyDisplayCache(); @@ -269,6 +271,10 @@ public void setCompletionProvider(@Nullable BiFunction signatureProvider) { + this.signatureProvider = signatureProvider; + } + /** * Only show completions while typing, not when moving the cursor. */ @@ -883,9 +889,17 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float if (displayCache.cursor != null) { this.renderCursor(guiGraphics, displayCache.cursor, displayCache.cursorAtEnd); - // Show completions while typing - if (this.isFocused() && this.completions != null) { - this.renderCompletions(guiGraphics, this.convertLocalToScreen(displayCache.cursor)); + // Show completions while typing, or else the signature of the call at the cursor + if (this.isFocused()) { + Pos2i cursor = this.convertLocalToScreen(displayCache.cursor); + if (this.completions != null) { + this.renderCompletions(guiGraphics, cursor); + } else if (this.signatureProvider != null) { + String signature = this.signatureProvider.apply(this.getValue(), this.getCursorPos()); + if (signature != null) { + this.renderSignature(guiGraphics, cursor, signature); + } + } } } @@ -992,6 +1006,26 @@ private Pair fitHorizontally(int width) { return Pair.of(x, width); } + private void renderSignature(GuiGraphics guiGraphics, Pos2i cursor, String signature) { + Pair xAndWidth = this.fitHorizontally(this.font.width(signature) + 2); + int x = xAndWidth.getLeft(); + int width = xAndWidth.getRight(); + if (this.font.width(signature) > width - 2) { + signature = this.font.plainSubstrByWidth(signature, width - 2 - this.font.width("\u2026")) + "\u2026"; + } + // Show above the line of the cursor if possible, otherwise below it + int y = cursor.y - ROW_HEIGHT - 1; + if (y < this.getY() - 1) { + y = cursor.y + ROW_HEIGHT + 1; + } + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate(0, 0, 200); + guiGraphics.fill(x - 1, y - 1, x + width + 1, y + ROW_HEIGHT + 1, Helpers.RGBAToInt(80, 80, 80, 255)); + guiGraphics.fill(x, y, x + width, y + ROW_HEIGHT, Helpers.RGBAToInt(255, 250, 220, 255)); + guiGraphics.drawString(this.font, signature, x + 1, y + 1, Helpers.RGBToInt(60, 60, 60), false); + guiGraphics.pose().popPose(); + } + private void renderCursor(GuiGraphics guiGraphics, Pos2i pos, boolean cursorAtEnd) { if (this.isFocused() && this.frameTick / 6 % 2 == 0) { pos = this.convertLocalToScreen(pos); diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 312a9167c..0f126149a 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -186,6 +186,10 @@ public int getTotalRows() { ILanguageHandler languageHandler = getActiveLanguageHandler(); return languageHandler != null ? languageHandler.getCompletions(text, cursor) : null; }); + textArea.setSignatureProvider((text, cursor) -> { + ILanguageHandler languageHandler = getActiveLanguageHandler(); + return languageHandler != null ? languageHandler.getSignatureHint(text, cursor) : null; + }); textArea.setMarkupProvider(text -> { Path path = getMenu().getActiveScriptPath(); ILanguageHandler languageHandler = path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java index dfb8d68f5..d81ae087c 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletions.java @@ -16,6 +16,8 @@ public class JavaScriptCompletions { public static final String OPS_PREFIX = "idContext.ops."; + // How far back to look for the call that the cursor is in + private static final int MAX_CALL_DISTANCE = 1000; /** * @param script A script. @@ -51,4 +53,45 @@ public static ScriptCompletions complete(String script, int cursor, Map signatures) { + int depth = 0; + for (int i = cursor - 1; i >= 0 && i >= cursor - MAX_CALL_DISTANCE; i--) { + char c = script.charAt(i); + if (c == ')' || c == ']' || c == '}') { + depth++; + } else if (c == '[' || c == '{') { + if (depth == 0) { + return null; + } + depth--; + } else if (c == '(') { + if (depth > 0) { + depth--; + } else { + int end = i; + int start = end; + while (start > 0 && Character.isJavaIdentifierPart(script.charAt(start - 1))) { + start--; + } + String name = script.substring(start, end); + String signature = signatures.get(name); + if (signature != null && script.startsWith(OPS_PREFIX, start - OPS_PREFIX.length())) { + return name + signature; + } + // Look further, for calls nested in arguments + } + } else if (c == ';' && depth == 0) { + return null; + } + } + return null; + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index 13b65039a..3fa51aecf 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -187,6 +187,12 @@ public ScriptCompletions getCompletions(String script, int cursor) { return JavaScriptCompletions.complete(script, cursor, getOperatorSignatures()); } + @Nullable + @Override + public String getSignatureHint(String script, int cursor) { + return JavaScriptCompletions.signatureHint(script, cursor, getOperatorSignatures()); + } + protected Map getOperatorSignatures() { // Operators are registered once, so this can be cached if (this.operatorSignatures == null) { diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java index bd0fda702..47fa596e9 100644 --- a/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java +++ b/src/test/java/org/cyclops/integratedscripting/core/language/JavaScriptCompletionsTest.java @@ -46,4 +46,22 @@ public void testCompleteNone() { assertThat(JavaScriptCompletions.complete("idContext.ops.itemstack", 18, SIGNATURES), nullValue()); } + @Test + public void testSignatureHint() { + String script = "const a = idContext.ops.integerMax(1, Math.max(2, 3), "; + assertThat(JavaScriptCompletions.signatureHint(script, script.length(), SIGNATURES), equalTo("integerMax(Integer, Integer): Integer")); + String nested = "idContext.ops.integerMax(idContext.ops.itemstackSize(x"; + assertThat(JavaScriptCompletions.signatureHint(nested, nested.length(), SIGNATURES), equalTo("itemstackSize(Item): Integer")); + } + + @Test + public void testSignatureHintNone() { + String closed = "idContext.ops.itemstackSize(x) + 1"; + assertThat(JavaScriptCompletions.signatureHint(closed, closed.length(), SIGNATURES), nullValue()); + String other = "foo(x"; + assertThat(JavaScriptCompletions.signatureHint(other, other.length(), SIGNATURES), nullValue()); + String statement = "idContext.ops.itemstackSize(x;\nfoo("; + assertThat(JavaScriptCompletions.signatureHint(statement, statement.length(), SIGNATURES), nullValue()); + } + } From b62d74e89f284bd873beb88c5014d6a04fb2eb06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:49:18 +0000 Subject: [PATCH 45/59] Fix broken JavaScript examples in the info book The string example used =+ instead of +=, resulting in NaN, the Fibonacci example referred to an undefined variable, and the NBT example read a Damage key that items no longer have since data components. It now reads the minecraft:damage component. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../data/integratedscripting/info/scripting_info.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/data/integratedscripting/info/scripting_info.xml b/src/main/resources/data/integratedscripting/info/scripting_info.xml index 7794437b5..9efed0155 100644 --- a/src/main/resources/data/integratedscripting/info/scripting_info.xml +++ b/src/main/resources/data/integratedscripting/info/scripting_info.xml @@ -85,7 +85,7 @@ let myInt = 123 + 456; let myString = ""; for (let i = 0; i < 50; i++) { - myString =+ "."; + myString += "."; } const myList = [ 1, 1, 2, 3, 5 ]; @@ -108,7 +108,7 @@ const isOdd = (value) => value % 2 !== 0; let n1 = 0; let n2 = 1; let nextTerm = 0; - for (let i = 1; i <= number; i++) { + for (let i = 1; i <= limit; i++) { values.push(n1); nextTerm = n1 + n2; n1 = n2; @@ -146,7 +146,7 @@ function filterItemCompact(item) { return item.strength(block) > 10; } function filterItemMethods3(item) { - return item.nbt().Damage === 0; + return !item.nbt()['minecraft:damage']; }
From 699853d5e81c85bedc655e7551318d4ee30284e5 Mon Sep 17 00:00:00 2001 From: Ruben Taelman Date: Mon, 19 Oct 2026 18:26:28 +0200 Subject: [PATCH 46/59] Fix display panels not recovering from script errors after external edits (#91) --- .../core/network/ScriptingData.java | 18 +++++++--- .../gametest/GameTestsScripts.java | 33 +++++++++++++++++++ .../core/network/ScriptingDataTest.java | 23 +++++++------ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java index eea57b951..7eb4fd8ac 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java +++ b/src/main/java/org/cyclops/integratedscripting/core/network/ScriptingData.java @@ -60,6 +60,9 @@ public class ScriptingData implements IScriptingData { private final Map pathWatchers = new ConcurrentHashMap<>(); private final Map pathWatchersReverse = new ConcurrentHashMap<>(); + // Disks changed on the file system, reloaded on the server thread + private final Set pendingDiskReloads = ConcurrentHashMap.newKeySet(); + private boolean initialized = false; private WatchService watchService; @@ -74,10 +77,16 @@ public Path getRootPath() { public void tick() { // Initialize once if (!this.initialized) { - this.initialized = true; this.initialize(); } + // Reload externally changed disks here, as change listeners may access the world + for (Iterator it = pendingDiskReloads.iterator(); it.hasNext(); ) { + int diskId = it.next(); + it.remove(); + this.initializeDiskScripts(diskId); + } + // Flush changes to disk if needed if (!dirtyPaths.isEmpty()) { for (Pair entry : dirtyPaths) { @@ -106,6 +115,8 @@ protected Path getDiskPath(int id) { } public void initialize() { + // Also when called directly, so a later tick doesn't start a second file watcher + this.initialized = true; Path disksPath = getDisksPath(); disksPath.toFile().mkdirs(); @@ -146,7 +157,6 @@ public void initialize() { watchThread.start(); } - // Synchronized, as this is also called from the file watcher thread protected synchronized void initializeDiskScripts(int diskId) { // Watch all directories before reading files, so files created during reading still trigger a reload this.registerDiskWatchers(diskId); @@ -207,8 +217,8 @@ protected void watchFiles() { if (changedPathAbsolute.startsWith(disksPath)) { try { int diskId = Integer.parseInt(changedPathAbsolute.getName(disksPath.getNameCount()).toString()); - // Re-initialize the full directory - this.initializeDiskScripts(diskId); + // Re-initialize the full directory on the next tick + this.pendingDiskReloads.add(diskId); } catch (NumberFormatException e) { // Ignore non-disk directories } diff --git a/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java b/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java index b7c2186e9..bb61adbdd 100644 --- a/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java +++ b/src/main/java/org/cyclops/integratedscripting/gametest/GameTestsScripts.java @@ -18,10 +18,14 @@ import org.cyclops.integrateddynamics.core.evaluate.variable.ValueTypeString; import org.cyclops.integrateddynamics.core.evaluate.variable.ValueTypes; import org.cyclops.integrateddynamics.part.PartTypePanelDisplay; +import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.network.IScriptingData; import org.cyclops.integratedscripting.core.network.ScriptingNetworkHelpers; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.cyclops.integrateddynamics.gametest.GameTestHelpersIntegratedDynamics.*; @@ -188,4 +192,33 @@ public void testScriptsDisplayScriptInfiniteLoop(GameTestHelper helper) { }); } + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testScriptsDisplayScriptErrorResolvedAfterExternalFix(GameTestHelper helper) { + GameTestHelpersIntegratedScripting.NetworkPositions positions = createBasicNetwork(helper, POS); + + // Write a script that fails when evaluated + ScriptingNetworkHelpers.getScriptingData().setScript(positions.diskId(), Path.of("script0.js"), "const abc = doesNotExist();", IScriptingData.ChangeLocation.MEMORY); + + // Create variable from script and place it in a display + ItemStack variableScript = createVariableForScript(helper.getLevel(), positions.diskId(), Path.of("script0.js"), "abc"); + Pair partAndState = placeVariableInDisplayPanel(helper.getLevel(), positions.displayPanel(), variableScript); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertFalse(partAndState.getRight().getGlobalErrors().isEmpty(), "Display panel must show the script error")) + // Let the network settle, so that only the script change can make the display update + .thenIdle(40) + // Fix the script externally, so the file watcher picks it up + .thenExecute(() -> { + Path scriptFile = IntegratedScripting._instance.scriptingData.getRootPath() + .resolve("scripting-disks").resolve(String.valueOf(positions.diskId())).resolve("script0.js"); + try { + Files.writeString(scriptFile, "const abc = 3;"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }) + .thenWaitUntil(() -> assertValueEqual(partAndState.getRight().getDisplayValue(), ValueTypeInteger.ValueInteger.of(3))) + .thenSucceed(); + } + } diff --git a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java index 5ab3cf714..0851c1f15 100644 --- a/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java +++ b/src/test/java/org/cyclops/integratedscripting/core/network/ScriptingDataTest.java @@ -136,7 +136,7 @@ public void testExternalUpdatesOnRuntimeCreatedDiskFolderSynced() throws IOExcep scriptingData.initialize(); Path diskPath = rootPath.resolve("scripting-disks").resolve("123"); Files.createDirectories(diskPath); - awaitCondition(() -> scriptingData.getDisks().contains(123)); + awaitCondition(scriptingData, () -> scriptingData.getDisks().contains(123)); Path scriptPath = Path.of("main.js"); int observedValue = writeScriptValueUntilObserved( @@ -291,7 +291,7 @@ public void testExternalFilesCreatedAfterDeletionsSynced() throws IOException, I Files.writeString(diskPath.resolve("new" + i + ".js"), "new"); } - awaitCondition(() -> scriptingData.getScripts(901).keySet().equals(Sets.newHashSet( + awaitCondition(scriptingData, () -> scriptingData.getScripts(901).keySet().equals(Sets.newHashSet( Path.of("new0.js"), Path.of("new1.js"), Path.of("new2.js"), Path.of("new3.js")))); } finally { scriptingData.close(); @@ -312,36 +312,39 @@ public void testExternalFilesInNewSubdirectoriesSynced() throws IOException, Int // A file in a new nested directory Files.createDirectories(diskPath.resolve("lib").resolve("helpers")); Files.writeString(diskPath.resolve("lib").resolve("helpers").resolve("a.js"), "a"); - awaitCondition(() -> "a".equals(scriptingData.getScripts(902).get(Path.of("lib/helpers/a.js")))); + awaitCondition(scriptingData, () -> "a".equals(scriptingData.getScripts(902).get(Path.of("lib/helpers/a.js")))); // A file in the intermediate directory, which contains no scripts yet Files.writeString(diskPath.resolve("lib").resolve("b.js"), "b"); - awaitCondition(() -> "b".equals(scriptingData.getScripts(902).get(Path.of("lib/b.js")))); + awaitCondition(scriptingData, () -> "b".equals(scriptingData.getScripts(902).get(Path.of("lib/b.js")))); // A file in an empty directory that already existed Files.createDirectories(diskPath.resolve("empty")); Files.writeString(diskPath.resolve("main.js"), "main2"); - awaitCondition(() -> "main2".equals(scriptingData.getScripts(902).get(Path.of("main.js")))); + awaitCondition(scriptingData, () -> "main2".equals(scriptingData.getScripts(902).get(Path.of("main.js")))); Files.writeString(diskPath.resolve("empty").resolve("c.js"), "c"); - awaitCondition(() -> "c".equals(scriptingData.getScripts(902).get(Path.of("empty/c.js")))); + awaitCondition(scriptingData, () -> "c".equals(scriptingData.getScripts(902).get(Path.of("empty/c.js")))); } finally { scriptingData.close(); FileUtils.deleteDirectory(rootPath.toFile()); } } - private static void awaitCondition(Condition condition) throws InterruptedException { - assertTrue("Timed out waiting for condition", awaitCondition(condition, AWAIT_TIMEOUT_MS)); + private static void awaitCondition(ScriptingData scriptingData, Condition condition) throws InterruptedException { + assertTrue("Timed out waiting for condition", awaitCondition(scriptingData, condition, AWAIT_TIMEOUT_MS)); } - private static boolean awaitCondition(Condition condition, int timeoutMs) throws InterruptedException { + private static boolean awaitCondition(ScriptingData scriptingData, Condition condition, int timeoutMs) throws InterruptedException { long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { + // External changes are applied on tick + scriptingData.tick(); if (condition.matches()) { return true; } Thread.sleep(POLLING_INTERVAL_MS); } + scriptingData.tick(); return condition.matches(); } @@ -352,7 +355,7 @@ private static int writeScriptValueUntilObserved(ScriptingData scriptingData, in while (System.currentTimeMillis() < deadline) { String expectedScriptValue = scriptValue(value); Files.writeString(scriptPathAbsolute, expectedScriptValue); - if (awaitCondition(() -> expectedScriptValue.equals(scriptingData.getScripts(disk).get(scriptPathRelative)), + if (awaitCondition(scriptingData, () -> expectedScriptValue.equals(scriptingData.getScripts(disk).get(scriptPathRelative)), ATTEMPT_TIMEOUT_MS)) { return value; } From 0c8cd80acdc75cb60fdc86bfaf11634b5b15b8ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:04:33 +0000 Subject: [PATCH 47/59] Show syntax error tooltips below their line, pointing at the error The tooltip of a syntax error was placed at the mouse, often covering the line it describes. Like in other editors, it is now placed below all rows of the line, or above them if there is no room, with an arrow pointing at the column of the error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index dc63f2fbb..e019a7f74 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -28,6 +28,7 @@ import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.StringUtil; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; @@ -35,6 +36,7 @@ import org.apache.commons.lang3.mutable.MutableBoolean; import org.apache.commons.lang3.mutable.MutableInt; import org.apache.commons.lang3.tuple.Pair; +import org.joml.Vector2i; import org.cyclops.cyclopscore.client.gui.component.WidgetScrollBar; import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; @@ -72,6 +74,13 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { public static final int ROW_HEIGHT = 9; private static final int COMPLETION_ROWS = 6; public static final int SCROLLBAR_WIDTH = 14; + // Height of the arrow between a syntax error tooltip and its error + private static final int TOOLTIP_ARROW = 4; + // How far vanilla tooltip backgrounds extend beyond their contents + private static final int TOOLTIP_BORDER = 4; + // Colors of vanilla tooltips + private static final int TOOLTIP_BACKGROUND = 0xF0100010; + private static final int TOOLTIP_BORDER_COLOR = 0x505000FF; private final TextFieldHelperExtended textFieldHelper; private final Font font; @@ -923,20 +932,62 @@ private void renderSyntaxError(GuiGraphics guiGraphics, DisplayCache displayCach int pos = Math.min(value.length(), TextEditing.lineOffset(value, this.syntaxError.line()) + Math.max(0, this.syntaxError.column() - 1)); int line = findLineFromPos(displayCache.lineStarts, pos); int row = line - this.firstRow; + int errorCenterX = -1; if (row >= 0 && row < this.getHeight() / ROW_HEIGHT) { int x = this.font.width(value.substring(displayCache.lineStarts[line], pos)); int width = pos < value.length() && value.charAt(pos) != '\n' ? this.font.width(value.substring(pos, pos + 1)) : 4; Pos2i screenPos = this.convertLocalToScreen(new Pos2i(x, row * ROW_HEIGHT)); guiGraphics.fill(screenPos.x, screenPos.y + ROW_HEIGHT - 2, screenPos.x + width, screenPos.y + ROW_HEIGHT - 1, Helpers.RGBAToInt(220, 0, 0, 255)); + errorCenterX = screenPos.x + width / 2; } - // Show the message when hovering the error line + // Show the message when hovering the error line, which may be wrapped over multiple rows + int top = Integer.MAX_VALUE; + int bottom = Integer.MIN_VALUE; + boolean hovering = false; for (LineInfo lineInfo : lines) { - if (lineInfo.lineNumber + 1 == this.syntaxError.line() && mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() - && mouseY >= lineInfo.y && mouseY < lineInfo.y + ROW_HEIGHT && Minecraft.getInstance().screen != null) { - Minecraft.getInstance().screen.setTooltipForNextRenderPass(this.font.split(Component.literal(this.syntaxError.message()), 200)); + if (lineInfo.lineNumber + 1 == this.syntaxError.line()) { + top = Math.min(top, lineInfo.y); + bottom = Math.max(bottom, lineInfo.y + ROW_HEIGHT); + hovering |= mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() + && mouseY >= lineInfo.y && mouseY < lineInfo.y + ROW_HEIGHT; } } + if (hovering && Minecraft.getInstance().screen != null) { + List message = this.font.split(Component.literal(this.syntaxError.message()), 200); + int screenHeight = Minecraft.getInstance().getWindow().getGuiScaledHeight(); + // Same height as vanilla tooltips of text lines + int tooltipHeight = message.size() * 10 - (message.size() == 1 ? 2 : 0); + + // Like in other editors, show the message below the line, or above it if there is no room + boolean below = bottom + TOOLTIP_ARROW + TOOLTIP_BORDER + tooltipHeight + TOOLTIP_BORDER <= screenHeight + || top - TOOLTIP_ARROW - TOOLTIP_BORDER - tooltipHeight - TOOLTIP_BORDER < 0; + int tooltipY = below ? bottom + TOOLTIP_ARROW + TOOLTIP_BORDER : top - TOOLTIP_ARROW - TOOLTIP_BORDER - tooltipHeight; + int anchorX = errorCenterX >= 0 ? errorCenterX - 12 : mouseX + 12; + Minecraft.getInstance().screen.setTooltipForNextRenderPass(message, (screenWidth, screenHeightIgnored, mouseXIgnored, mouseYIgnored, width, height) -> + new Vector2i(Math.max(TOOLTIP_BORDER, Math.min(anchorX, screenWidth - width - TOOLTIP_BORDER)), tooltipY), true); + + // Point at the column of the error + if (errorCenterX >= 0) { + this.renderTooltipArrow(guiGraphics, errorCenterX, below ? bottom : top - 1, below); + } + } + } + + /** + * Draw an arrow between a tooltip and the given position, in the style of vanilla tooltips. + */ + private void renderTooltipArrow(GuiGraphics guiGraphics, int x, int tipY, boolean pointUp) { + guiGraphics.pose().pushPose(); + // Same depth as tooltips + guiGraphics.pose().translate(0, 0, 400); + for (int i = 0; i < TOOLTIP_ARROW; i++) { + int y = pointUp ? tipY + i : tipY - i; + guiGraphics.fill(x - i, y, x + i + 1, y + 1, TOOLTIP_BACKGROUND); + guiGraphics.fill(x - i, y, x - i + 1, y + 1, TOOLTIP_BORDER_COLOR); + guiGraphics.fill(x + i, y, x + i + 1, y + 1, TOOLTIP_BORDER_COLOR); + } + guiGraphics.pose().popPose(); } private void renderCharBackground(GuiGraphics guiGraphics, DisplayCache displayCache, int pos, int color) { From 9508b99ef00c31b3c10c811cdcf5e733fb6e335f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:06:20 +0000 Subject: [PATCH 48/59] Make the script editor scrollbar narrower The scrollbar took as much width as an inventory scrollbar, leaving less room for text in the small editor. It is now about half as wide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/component/input/WidgetTextArea.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index e019a7f74..3b263056e 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -73,7 +73,9 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { public static final int ROW_HEIGHT = 9; private static final int COMPLETION_ROWS = 6; - public static final int SCROLLBAR_WIDTH = 14; + public static final int SCROLLBAR_WIDTH = 7; + // Same height as the vanilla scroller, which the scrollbar's drag handling assumes + private static final int SCROLLBAR_HANDLE_HEIGHT = 15; // Height of the arrow between a syntax error tooltip and its error private static final int TOOLTIP_ARROW = 4; // How far vanilla tooltip backgrounds extend beyond their contents @@ -156,9 +158,15 @@ public int getTotalRows() { @Override public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { - // Only show scrollbar if needed + // Only show scrollbar if needed, and draw a narrower handle than the vanilla scroller to leave more room for text if (this.needsScrollBars()) { - super.renderWidget(guiGraphics, mouseX, mouseY, partialTicks); + int x = this.getX(); + int y = this.getY() + (int) ((this.getHeight() - SCROLLBAR_HANDLE_HEIGHT - 2) * this.getCurrentScroll()); + int x2 = x + SCROLLBAR_WIDTH - 1; + int y2 = y + SCROLLBAR_HANDLE_HEIGHT; + guiGraphics.fill(x, y, x2, y2, Helpers.RGBAToInt(85, 85, 85, 255)); + guiGraphics.fill(x, y, x2 - 1, y2 - 1, Helpers.RGBAToInt(255, 255, 255, 255)); + guiGraphics.fill(x + 1, y + 1, x2 - 1, y2 - 1, Helpers.RGBAToInt(198, 198, 198, 255)); } } }; From 59bbc1e222dde04249ba75c076bf6e0b948f4249 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:06:20 +0000 Subject: [PATCH 49/59] Show syntax errors when hovering their scrollbar mark The red mark of a syntax error on the scrollbar was drawn over the scrollbar handle, and gave no information when hovered. It is now drawn below the handle, and hovering it shows the error message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 3b263056e..7ce778739 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -922,15 +922,21 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float // Render scrollbar if (this.scrollBar != null) { - this.scrollBar.render(guiGraphics, getX(), getY(), partialTicks); - - // Mark the position of the syntax error on the scrollbar, so it can be found when scrolled out of view + // Mark the position of the syntax error on the scrollbar, so it can be found when scrolled out of view. + // This is drawn below the handle, which only covers it when the error is roughly in view. if (this.syntaxError != null && displayCache.linesTotal > this.scrollBar.getVisibleRows()) { int errorRow = findLineFromPos(displayCache.lineStarts, TextEditing.lineOffset(this.getValue(), this.syntaxError.line())); int y = this.getY() + (int) ((long) errorRow * (this.getHeight() - 2) / displayCache.linesTotal); - int x = this.getX() + this.getWidth() - SCROLLBAR_WIDTH + 1; - guiGraphics.fill(x, y, x + SCROLLBAR_WIDTH - 2, y + 2, Helpers.RGBAToInt(220, 0, 0, 255)); + int x = this.getX() + this.getWidth() - SCROLLBAR_WIDTH; + guiGraphics.fill(x, y, x + SCROLLBAR_WIDTH - 1, y + 2, Helpers.RGBAToInt(220, 0, 0, 255)); + + // Show the message when hovering the mark + if (mouseX >= x && mouseX < x + SCROLLBAR_WIDTH - 1 && mouseY >= y - 1 && mouseY < y + 3 && Minecraft.getInstance().screen != null) { + Minecraft.getInstance().screen.setTooltipForNextRenderPass(this.font.split(Component.literal(this.syntaxError.message()), 200)); + } } + + this.scrollBar.render(guiGraphics, getX(), getY(), partialTicks); } } From accc4fefdf5eb10369299dfb21a43d81cbff8315 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:10:00 +0000 Subject: [PATCH 50/59] Hide JEI in the focus mode of the scripting terminal JEI drew its item list over the enlarged script editor. When JEI is installed, the focus mode now claims the whole screen, so JEI hides it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- build.gradle | 7 ++++ gradle.properties | 1 + .../ContainerScreenTerminalScripting.java | 4 ++ .../jei/JEIIntegratedScriptingPlugin.java | 38 +++++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 src/main/java/org/cyclops/integratedscripting/modcompat/jei/JEIIntegratedScriptingPlugin.java diff --git a/build.gradle b/build.gradle index 462b29cfd..6543bbef6 100644 --- a/build.gradle +++ b/build.gradle @@ -101,6 +101,10 @@ test { repositories { mavenCentral() mavenLocal() + maven { + name "JEI" + url "https://maven.blamejared.com" + } maven { name "Cyclops Repo" url "https://maven.pkg.github.com/CyclopsMC/packages" @@ -136,6 +140,9 @@ dependencies { } } + // Optional, only used to hide JEI in focus mode + compileOnly "mezz.jei:jei-${project.minecraft_version}-neoforge-api:${project.jei_version}" + modLib "com.google.re2j:re2j:1.1" modLib ("io.netty:netty-codec-http:4.1.77.Final") { transitive = false diff --git a/gradle.properties b/gradle.properties index 50806d5ce..ff9723dfd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -34,4 +34,5 @@ org.gradle.caching=true graal_version=25.2.4 cyclopscore_version=1.25.5-643 integrateddynamics_version=1.24.1-1004 +jei_version=19.16.0.152 commoncapabilities_version=2.9.3-147 diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 0f126149a..27a2bca32 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -741,6 +741,10 @@ public void setFirstRow(int firstRow) { this.firstRow = firstRow; } + public boolean isFocusMode() { + return focusMode; + } + @Nullable private ILanguageHandler getActiveLanguageHandler() { Path path = getMenu().getActiveScriptPath(); diff --git a/src/main/java/org/cyclops/integratedscripting/modcompat/jei/JEIIntegratedScriptingPlugin.java b/src/main/java/org/cyclops/integratedscripting/modcompat/jei/JEIIntegratedScriptingPlugin.java new file mode 100644 index 000000000..9a9ab7774 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/modcompat/jei/JEIIntegratedScriptingPlugin.java @@ -0,0 +1,38 @@ +package org.cyclops.integratedscripting.modcompat.jei; + +import mezz.jei.api.IModPlugin; +import mezz.jei.api.JeiPlugin; +import mezz.jei.api.gui.handlers.IGuiContainerHandler; +import mezz.jei.api.registration.IGuiHandlerRegistration; +import net.minecraft.client.renderer.Rect2i; +import net.minecraft.resources.ResourceLocation; +import org.cyclops.integratedscripting.Reference; +import org.cyclops.integratedscripting.client.gui.container.ContainerScreenTerminalScripting; + +import java.util.Collections; +import java.util.List; + +/** + * Only loaded by JEI if it is installed. + * @author rubensworks + */ +@JeiPlugin +public class JEIIntegratedScriptingPlugin implements IModPlugin { + + @Override + public ResourceLocation getPluginUid() { + return ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "main"); + } + + @Override + public void registerGuiHandlers(IGuiHandlerRegistration registration) { + registration.addGuiContainerHandler(ContainerScreenTerminalScripting.class, new IGuiContainerHandler<>() { + @Override + public List getGuiExtraAreas(ContainerScreenTerminalScripting screen) { + // Claim the whole screen in focus mode, so JEI hides its overlays instead of drawing them over the editor + return screen.isFocusMode() ? List.of(new Rect2i(0, 0, screen.width, screen.height)) : Collections.emptyList(); + } + }); + } + +} From 0a2a0f239780b4677bf77305651697a7f6b0e126 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:49:09 +0000 Subject: [PATCH 51/59] Keep JEI's buttons clear of the focus mode editor JEI keeps its bookmark and config buttons in the bottom corners of the screen, which overlapped the enlarged script editor. The focus mode now leaves a larger margin when JEI is installed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../ContainerScreenTerminalScripting.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 27a2bca32..8dd09fa51 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -24,6 +24,7 @@ import net.minecraft.util.StringUtil; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; +import net.neoforged.fml.ModList; import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.Level; import org.cyclops.cyclopscore.client.gui.component.WidgetScrollBar; @@ -87,6 +88,8 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended