From fb034c8927a3d1b84fc4c922563147c5960dc6b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:16:30 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 bf7170f41e8f94ce1678bc62756c014b1855e002 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:43:32 +0000 Subject: [PATCH 10/10] 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