From 7e4d4fb21572d4c716762aa4a5b2d06d538d4e69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:16:30 +0000 Subject: [PATCH 01/17] 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 2d470f75b..a9e48680f 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 @@ -420,6 +420,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 96ba2683c2b88f06db63706ef78086a19b14cc88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:22:58 +0000 Subject: [PATCH 02/17] 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 a9e48680f..1bddc7379 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 @@ -418,7 +418,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 80d4e11fe5a0fc4503265a89742bfa0e844205c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:22:58 +0000 Subject: [PATCH 03/17] 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 1bddc7379..92349426b 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 @@ -524,6 +524,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) { @@ -557,6 +567,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 4e22fbf26f6401bbe7dd327e2f00bf8b320c88ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:33:11 +0000 Subject: [PATCH 04/17] Add find and go to line to the script editor Finding something in a script meant scrolling through it in the small editor. Add ctrl+f to find text (enter jumps to the next match) and ctrl+g to go to a line number, for example one from an error message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 30 ++++++++ .../gui/component/input/WidgetTextArea.java | 24 ++++++ .../ContainerScreenTerminalScripting.java | 73 +++++++++++++++++++ .../integratedscripting/lang/en_us.json | 2 + .../gui/component/input/TextEditingTest.java | 29 ++++++++ 5 files changed, 158 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 ebf251bd8..8cf27cd3d 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,34 @@ 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); + } + + /** + * @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/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 92349426b..e131d2189 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,8 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private WidgetScrollBar scrollBar; private int firstRow; private TextHistory history = new TextHistory(); + @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); @@ -153,6 +156,24 @@ 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(); + } + public void setHistory(TextHistory history) { this.history = history; } @@ -314,6 +335,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); 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 1ba75ff24..7311dbf2f 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 @@ -8,6 +8,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -30,6 +31,7 @@ import org.cyclops.integratedscripting.IntegratedScripting; import org.cyclops.integratedscripting.Reference; import org.cyclops.integratedscripting.api.language.ILanguageHandler; +import org.cyclops.integratedscripting.client.gui.component.input.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.WidgetTextArea; @@ -75,6 +77,9 @@ 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); @@ -528,6 +540,18 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return false; } + if (this.fieldNavigate != null && this.fieldNavigate.isFocused()) { + if (typedChar == GLFW.GLFW_KEY_ESCAPE) { + closeNavigate(); + } else if (typedChar == GLFW.GLFW_KEY_ENTER || typedChar == GLFW.GLFW_KEY_KP_ENTER) { + navigate(); + } else { + this.fieldNavigate.keyPressed(typedChar, keyCode, modifiers); + } + // Don't let typing trigger screen actions, such as closing the screen + return true; + } + if (textArea.isFocused()) { boolean ret = textArea.keyPressed(typedChar, keyCode, modifiers); if (typedChar != GLFW.GLFW_KEY_ESCAPE) { @@ -537,12 +561,61 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return super.keyPressed(typedChar, keyCode, modifiers); } + /** + * Show a field to find text, or to go to a line number. + */ + private void openNavigate(boolean toLine) { + closeNavigate(); + this.navigateToLine = toLine; + this.fieldNavigate = new EditBox(font, this.leftPos + SCRIPT_X + SCRIPT_WIDTH - 82, this.topPos + SCRIPT_Y + 2, 66, 10, + Component.translatable(toLine ? "gui.integratedscripting.goto_line" : "gui.integratedscripting.find")); + this.fieldNavigate.setHint(Component.translatable(toLine ? "gui.integratedscripting.goto_line" : "gui.integratedscripting.find")); + String selected = this.textArea.getSelected(); + if (!toLine && !selected.isEmpty() && selected.indexOf('\n') < 0) { + this.fieldNavigate.setValue(selected); + } + addRenderableWidget(this.fieldNavigate); + this.textArea.setFocused(false); + this.setFocused(this.fieldNavigate); + } + + private void closeNavigate() { + if (this.fieldNavigate != null) { + removeWidget(this.fieldNavigate); + this.fieldNavigate = null; + this.setFocused(this.textArea); + this.textArea.setFocused(true); + } + } + + private void navigate() { + String query = this.fieldNavigate.getValue(); + String value = this.textArea.getValue(); + if (this.navigateToLine) { + try { + int pos = TextEditing.lineOffset(value, Integer.parseInt(query.trim())); + closeNavigate(); + this.textArea.selectRange(pos, pos); + } catch (NumberFormatException e) { + // Keep the field open so the number can be fixed + } + } else { + int pos = TextEditing.findNext(value, query, Math.max(this.textArea.getCursorPos(), this.textArea.getSelectionPos())); + if (pos >= 0) { + this.textArea.selectRange(pos, pos + query.length()); + } + } + } + @Override public boolean charTyped(char p_94683_, int p_94684_) { // Make active dialog consume all input if (pendingScriptRemovalDialog != null) { return false; } + if (this.fieldNavigate != null && this.fieldNavigate.isFocused()) { + return this.fieldNavigate.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 23728d28a..2305d1397 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -26,6 +26,8 @@ "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.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..f61251248 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,33 @@ 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)); + } + } From ee6cde51038a8056c90846439ff62b2b4c1b1c3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:35:33 +0000 Subject: [PATCH 05/17] 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 8cf27cd3d..d941c3676 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++) { @@ -215,4 +221,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 e131d2189..2e78d33ac 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 @@ -425,7 +425,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 f61251248..b39f199da 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 @@ -145,4 +145,44 @@ public void testLineOffset() { assertThat(TextEditing.lineOffset("a\nbc\nd", 0), equalTo(0)); } + @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 a403a44e0b7dbc924b17166404375e225f027f48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:36:00 +0000 Subject: [PATCH 06/17] 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. Scroll the editor while dragging beyond its edges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 2e78d33ac..f2c3bd2f0 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,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private WidgetScrollBar scrollBar; private int firstRow; private TextHistory history = new TextHistory(); + private boolean draggingSelection; @Nullable private IntPredicate shortcutListener; @@ -266,8 +267,10 @@ 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.setFocused(true); long i = Util.getMillis(); DisplayCache displayCache = this.getDisplayCache(); @@ -317,15 +320,22 @@ 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) { + if (mouseButton == 0 && this.draggingSelection) { + // Scroll when dragging a selection beyond the top or bottom + if (mouseY < this.getY()) { + this.setFirstRow(this.firstRow - 1, true); + } else if (mouseY >= this.getY() + this.height) { + this.setFirstRow(this.firstRow + 1, true); + } + int x = (int) Math.max(this.getX(), Math.min(this.getX() + this.width - 1, mouseX)); + int y = (int) Math.max(this.getY(), Math.min(this.getY() + this.height - 1, mouseY)); DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); - int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, (int)mouseY))); + int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i(x, y))); this.textFieldHelper.setCursorPos(i, true); this.clearDisplayCache(); this.setFocused(true); From 9bbbbc369ed88db5014fa028c73c909f7075094b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:49:05 +0000 Subject: [PATCH 07/17] Close the find field when the scripting terminal is resized Resizing removes all widgets, so an open find field kept receiving key presses without being visible. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/container/ContainerScreenTerminalScripting.java | 1 + 1 file changed, 1 insertion(+) 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 6e18cee27..f927e99a7 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 @@ -108,6 +108,7 @@ public void init() { this.saveCursorPos(); } pendingScriptRemovalDialog = null; + fieldNavigate = null; super.init(); From ddd9201603355132ed5c9f375c41494ef0fbd6ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:55:30 +0000 Subject: [PATCH 08/17] 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 d941c3676..4a1c4df00 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 f2c3bd2f0..c94859c1a 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 @@ -94,6 +94,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private int firstRow; private TextHistory history = new TextHistory(); private boolean draggingSelection; + private String indent = " "; @Nullable private IntPredicate shortcutListener; @@ -175,6 +176,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; + } + public void setHistory(TextHistory history) { this.history = history; } @@ -458,10 +466,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 f927e99a7..50e038f95 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 @@ -133,6 +133,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 b39f199da..716117b70 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 @@ -185,4 +188,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 4c3a007f8054c7adc25e37e2a7525fc535b417f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:19:22 +0000 Subject: [PATCH 09/17] Show find and go to line in a search bar below the editor The find field appeared floating over the top right of the code, covering it, without a label, without showing how many matches there are, and without any feedback when nothing matched. Show a search bar below the editor instead, which takes the space of the bottom row. It shows a label, the current match and number of matches, buttons for the previous and next match (also shift+enter and enter), highlights all matches, and turns red when nothing is found or the line number is invalid. Escape closes it before closing the screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/TextEditing.java | 36 ++++ .../gui/component/input/WidgetSearchBar.java | 168 ++++++++++++++++++ .../gui/component/input/WidgetTextArea.java | 95 ++++++++-- .../ContainerScreenTerminalScripting.java | 138 +++++++++----- .../integratedscripting/lang/en_us.json | 5 + .../gui/component/input/TextEditingTest.java | 16 ++ 6 files changed, 404 insertions(+), 54 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 4a1c4df00..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 @@ -207,6 +207,42 @@ public static int findNext(String value, String query, int from) { 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. 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 c94859c1a..d7bc39668 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 @@ -94,6 +94,13 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private int firstRow; private TextHistory history = new TextHistory(); private boolean draggingSelection; + @Nullable + private String searchQuery; + @Nullable + private String searchMatchesValue; + @Nullable + private String searchMatchesQuery; + private int[] searchMatches = new int[0]; private String indent = " "; @Nullable private IntPredicate shortcutListener; @@ -104,22 +111,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() { @@ -183,6 +212,29 @@ public void setIndent(String indent) { this.indent = indent; } + /** + * @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; } @@ -572,6 +624,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 50e038f95..dfec30ee3 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 @@ -8,7 +8,6 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -34,6 +33,7 @@ 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; @@ -78,8 +78,7 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended= 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 @@ -628,8 +684,8 @@ public boolean charTyped(char p_94683_, int p_94684_) { if (pendingScriptRemovalDialog != null) { return false; } - if (this.fieldNavigate != null && this.fieldNavigate.isFocused()) { - return this.fieldNavigate.charTyped(p_94683_, p_94684_); + 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 2305d1397..ec7f1fac9 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -28,6 +28,11 @@ "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 716117b70..d7b5e42f0 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 @@ -204,4 +204,20 @@ public void testNewlineTwoSpaces() { assertThat(TextEditing.newline(s(" if (a) {", 10), TWO), equalTo(s(" if (a) {\n ", 15))); } + @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 e2f16898cc91745ddb85087ceb07382fcde1e363 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:48:44 +0000 Subject: [PATCH 10/17] 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, whose tooltips also show their shortcut, and a button whose tooltip lists all shortcuts. 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 | 58 ++++++++++++++++++ .../client/gui/image/ScriptImages.java | 7 +++ .../integratedscripting/lang/en_us.json | 7 +++ .../textures/gui/icons.png | Bin 423 -> 1308 bytes 7 files changed, 141 insertions(+) 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 d7bc39668..934fe2958 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 @@ -475,6 +475,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 dfec30ee3..eb4af480c 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,13 +1,16 @@ 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; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import net.minecraft.ChatFormatting; 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 +33,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; @@ -76,6 +80,9 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended toolbarEditButtons = Lists.newArrayList(); + private ButtonIcon buttonUndo; + private ButtonIcon buttonRedo; private WidgetDialog pendingScriptRemovalDialog; @Nullable private WidgetSearchBar searchBar; @@ -155,6 +162,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(); @@ -163,10 +172,59 @@ 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) { + String modifier = Minecraft.ON_OSX ? "Cmd" : "Ctrl"; + this.toolbarEditButtons.clear(); + this.buttonUndo = this.addToolbarButton(right - 60, y, ScriptImages.ACTION_UNDO, Component.translatable("gui.integratedscripting.action.undo", modifier), () -> this.textArea.undo()); + this.buttonRedo = this.addToolbarButton(right - 50, y, ScriptImages.ACTION_REDO, Component.translatable("gui.integratedscripting.action.redo", modifier), () -> this.textArea.redo()); + this.addToolbarButton(right - 40, y, ScriptImages.ACTION_FIND, Component.translatable("gui.integratedscripting.action.find", modifier), () -> this.openNavigate(false)); + this.addToolbarButton(right - 30, y, ScriptImages.ACTION_GOTO_LINE, Component.translatable("gui.integratedscripting.action.goto_line", modifier), () -> this.openNavigate(true)); + this.addToolbarButton(right - 20, y, ScriptImages.ACTION_COMMENT, Component.translatable("gui.integratedscripting.action.comment", modifier), () -> this.textArea.toggleComment()); + + // Only shows the shortcuts in its tooltip + ButtonIcon buttonHelp = new ButtonIcon(right - 10, y, 10, 10, Component.translatable("gui.integratedscripting.action.help"), (button) -> {}, ScriptImages.ACTION_HELP); + buttonHelp.setTooltip(Tooltip.create(Component.translatable("gui.integratedscripting.action.help").withStyle(ChatFormatting.YELLOW) + .append(Component.literal("\n")) + .append(Component.translatable("gui.integratedscripting.shortcuts", modifier).withStyle(ChatFormatting.GRAY)))); + addRenderableWidget(buttonHelp); + 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(); 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..d89dc99ac 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,11 @@ 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); + public static final Image ACTION_HELP = new Image(ICONS, 40, 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 ec7f1fac9..4497fbc8e 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -33,6 +33,13 @@ "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.action.help": "Keyboard shortcuts", + "gui.integratedscripting.shortcuts": "%1$s+Z, %1$s+Y: undo, redo\n%1$s+F, %1$s+G: find, go to line\n%1$s+/: toggle comment\nTab, Shift+Tab: indent, unindent\nAlt+Up, Alt+Down: move lines\nPage Up, Page Down: move a page", "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..1f651635da162406373400346dea3011b3f3f7fb 100644 GIT binary patch literal 1308 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEi$DS^ZAr*7p-Z>liDvP1v z;e!Z<-N9X&ja)g0-)bIQn>&X~qcQBxMaJ?%^%*BwU7nu1SUEqvZQ}g?mb2fVwLf@O zV8JuFQ0BheoQ=2NhRv7duhIX0d2XQ$zrMb{IJ>>At?c{$#}z+5|Gs|oWB>W}@qa1~ z{C}Azv2OOC;_E(j4}R}9{<-k=yZm~F2k(CtF-$xD6R4=~@5A;T_4Oyd%FT~wZ)g8d zeCLb6gY)m>xBdIjeCLl$1IK~$g+>gQzyCeGzxeXTe>KzpUnxHDz4VjJfiGJhe0qOp zrDxuw!{;|uRa8`bFS@tBK+~Wme)Z3M#s)EQCdU~J4nTCn*YCpH@4p{+wtTIsy=wIR z_uoXzvVV6zeJy@nz{hO&eh0h3{Dk*2`{$~?O4}@HHv8<+r#kW0U-@01o%j82zlrZb zRr%o$W`^s3^4m}TsUmay;|8GbFNfDXpSC$}efV>=_eMcw?mp`8#{r`KILH&J`e^=i9H`vpfQduu?Z=Jfu_qYd--#w3hdN!@N=HV5w%3H7U zw%<;a+JF1)vhVV34|B6^b<=-V?7k~k{@7o)e&@QKKeP1=nxt>N|K9#{$!vSO@3%fx z)Xq9Ez3lVyjnAj;kGa0|yamtyc`PNx#lP>otq}U4y!-0q8&CCqt?w0j4H6yWAJqKb6Mw<&;$VDO#XBL 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 06:57:47 +0000 Subject: [PATCH 11/17] Don't let a long terminal title overlap the toolbar Translations of the terminal title, such as "Terminal de Scripting", are wider than the space left of the new toolbar. Shorten the title with an ellipsis when it doesn't fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../container/ContainerScreenTerminalScripting.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index eb4af480c..2424ca0a4 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 @@ -83,6 +83,7 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended toolbarEditButtons = Lists.newArrayList(); private ButtonIcon buttonUndo; private ButtonIcon buttonRedo; + private int toolbarLeft; private WidgetDialog pendingScriptRemovalDialog; @Nullable private WidgetSearchBar searchBar; @@ -178,6 +179,7 @@ public int getTotalRows() { * @param y The top of the toolbar. */ private void addToolbar(int right, int y) { + this.toolbarLeft = right - 60; String modifier = Minecraft.ON_OSX ? "Cmd" : "Ctrl"; this.toolbarEditButtons.clear(); this.buttonUndo = this.addToolbarButton(right - 60, y, ScriptImages.ACTION_UNDO, Component.translatable("gui.integratedscripting.action.undo", modifier), () -> this.textArea.undo()); @@ -395,7 +397,14 @@ protected Path getHoveredScriptPath(double mouseX, double mouseY) { @Override protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { - super.renderLabels(guiGraphics, mouseX, 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("...")) + "..."); + } + guiGraphics.drawString(this.font, title, this.titleLabelX, this.titleLabelY, 4210752, false); + guiGraphics.drawString(this.font, this.playerInventoryTitle, this.inventoryLabelX, this.inventoryLabelY, 4210752, false); // Draw disk label guiGraphics.drawString(font, L10NHelpers.localize("gui.integratedscripting.disk") + ":", 8, 6, 16777215); From e29ac0dbd53427b0c03beda61313d44bb582185f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:42:56 +0000 Subject: [PATCH 12/17] Remove the keyboard shortcuts button from the editor toolbar Each toolbar button already shows its shortcut in its tooltip, so a separate button that only lists all shortcuts mostly repeats them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../ContainerScreenTerminalScripting.java | 20 ++++++------------ .../client/gui/image/ScriptImages.java | 1 - .../integratedscripting/lang/en_us.json | 2 -- .../textures/gui/icons.png | Bin 1308 -> 1286 bytes 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 2424ca0a4..bd8f96575 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,7 +6,6 @@ import com.mojang.blaze3d.systems.RenderSystem; import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; -import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; @@ -179,21 +178,14 @@ public int getTotalRows() { * @param y The top of the toolbar. */ private void addToolbar(int right, int y) { - this.toolbarLeft = right - 60; + this.toolbarLeft = right - 50; String modifier = Minecraft.ON_OSX ? "Cmd" : "Ctrl"; this.toolbarEditButtons.clear(); - this.buttonUndo = this.addToolbarButton(right - 60, y, ScriptImages.ACTION_UNDO, Component.translatable("gui.integratedscripting.action.undo", modifier), () -> this.textArea.undo()); - this.buttonRedo = this.addToolbarButton(right - 50, y, ScriptImages.ACTION_REDO, Component.translatable("gui.integratedscripting.action.redo", modifier), () -> this.textArea.redo()); - this.addToolbarButton(right - 40, y, ScriptImages.ACTION_FIND, Component.translatable("gui.integratedscripting.action.find", modifier), () -> this.openNavigate(false)); - this.addToolbarButton(right - 30, y, ScriptImages.ACTION_GOTO_LINE, Component.translatable("gui.integratedscripting.action.goto_line", modifier), () -> this.openNavigate(true)); - this.addToolbarButton(right - 20, y, ScriptImages.ACTION_COMMENT, Component.translatable("gui.integratedscripting.action.comment", modifier), () -> this.textArea.toggleComment()); - - // Only shows the shortcuts in its tooltip - ButtonIcon buttonHelp = new ButtonIcon(right - 10, y, 10, 10, Component.translatable("gui.integratedscripting.action.help"), (button) -> {}, ScriptImages.ACTION_HELP); - buttonHelp.setTooltip(Tooltip.create(Component.translatable("gui.integratedscripting.action.help").withStyle(ChatFormatting.YELLOW) - .append(Component.literal("\n")) - .append(Component.translatable("gui.integratedscripting.shortcuts", modifier).withStyle(ChatFormatting.GRAY)))); - addRenderableWidget(buttonHelp); + 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(); } 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 d89dc99ac..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 @@ -21,6 +21,5 @@ public class ScriptImages { public static final Image ACTION_FIND = new Image(ICONS, 16, 16, 8, 8); public static final Image ACTION_GOTO_LINE = new Image(ICONS, 24, 16, 8, 8); public static final Image ACTION_COMMENT = new Image(ICONS, 32, 16, 8, 8); - public static final Image ACTION_HELP = new Image(ICONS, 40, 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 4497fbc8e..7213aab7d 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -38,8 +38,6 @@ "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.action.help": "Keyboard shortcuts", - "gui.integratedscripting.shortcuts": "%1$s+Z, %1$s+Y: undo, redo\n%1$s+F, %1$s+G: find, go to line\n%1$s+/: toggle comment\nTab, Shift+Tab: indent, unindent\nAlt+Up, Alt+Down: move lines\nPage Up, Page Down: move a page", "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 1f651635da162406373400346dea3011b3f3f7fb..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 1308 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEi$DS^ZAr*7p-Z>liDvP1v z;e!Z<-N9X&ja)g0-)bIQn>&X~qcQBxMaJ?%^%*BwU7nu1SUEqvZQ}g?mb2fVwLf@O zV8JuFQ0BheoQ=2NhRv7duhIX0d2XQ$zrMb{IJ>>At?c{$#}z+5|Gs|oWB>W}@qa1~ z{C}Azv2OOC;_E(j4}R}9{<-k=yZm~F2k(CtF-$xD6R4=~@5A;T_4Oyd%FT~wZ)g8d zeCLb6gY)m>xBdIjeCLl$1IK~$g+>gQzyCeGzxeXTe>KzpUnxHDz4VjJfiGJhe0qOp zrDxuw!{;|uRa8`bFS@tBK+~Wme)Z3M#s)EQCdU~J4nTCn*YCpH@4p{+wtTIsy=wIR z_uoXzvVV6zeJy@nz{hO&eh0h3{Dk*2`{$~?O4}@HHv8<+r#kW0U-@01o%j82zlrZb zRr%o$W`^s3^4m}TsUmay;|8GbFNfDXpSC$}efV>=_eMcw?mp`8#{r`KILH&J`e^=i9H`vpfQduu?Z=Jfu_qYd--#w3hdN!@N=HV5w%3H7U zw%<;a+JF1)vhVV34|B6^b<=-V?7k~k{@7o)e&@QKKeP1=nxt>N|K9#{$!vSO@3%fx z)Xq9Ez3lVyjnAj;kGa0|yamtyc`PNx#lP>otq}U4y!-0q8&CCqt?w0j4H6yWAJqKb6Mw<&;$VDO#XBL From 36ddfdc3cedcb7087af3fd3230c4d271e5f0eb1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:43:32 +0000 Subject: [PATCH 13/17] 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 934fe2958..2021fee87 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; @@ -103,7 +103,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); @@ -188,9 +188,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; } @@ -405,11 +405,11 @@ 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)) { + 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) { @@ -509,15 +509,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; @@ -529,11 +530,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(); @@ -1031,4 +1035,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 bd8f96575..4ddeaf822 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; @@ -141,9 +143,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; @@ -179,17 +182,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 7213aab7d..2db07b787 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", @@ -45,6 +40,15 @@ "gui.integratedscripting.error.invalid_member": "Select a valid function name or variable name inside the script editor to bind with.", "gui.integratedscripting.error.invalid_member.current": "Current selection: \"%s\"", + "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 6e97fb46ab1a05afc01a64799ea6afe504c01e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:43:32 +0000 Subject: [PATCH 14/17] 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 2db07b787..4efc9a359 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -135,6 +135,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, and &oCtrl+Shift+Z&r (&oCmd+Shift+Z&r on Mac) also redoes.", + "info_book.integratedscripting.terminal_shortcuts.text3": "While finding text or going to a line, &oEnter&r and &oShift+Enter&r go to the next and previous match, and &oEscape&r closes the search bar.", "info_book.integratedscripting.writing": "Writing Scripts", diff --git a/src/main/resources/data/integratedscripting/info/scripting_info.xml b/src/main/resources/data/integratedscripting/info/scripting_info.xml index b1ce31129..84b7818ef 100644 --- a/src/main/resources/data/integratedscripting/info/scripting_info.xml +++ b/src/main/resources/data/integratedscripting/info/scripting_info.xml @@ -55,6 +55,19 @@ integratedscripting:crafting/part_terminal_scripting +
+ info_book.integratedscripting.terminal_shortcuts.text1 + info_book.integratedscripting.terminal_shortcuts.text2 + info_book.integratedscripting.terminal_shortcuts.text3 + key.integratedscripting.undo + key.integratedscripting.redo + key.integratedscripting.find + key.integratedscripting.goto_line + key.integratedscripting.comment + key.integratedscripting.move_lines_up + key.integratedscripting.move_lines_down +
+
info_book.integratedscripting.writing.js.text1 From 18822321d7426f1f6d9a5ed508f8efbc80cdc531 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:43:49 +0000 Subject: [PATCH 15/17] Use an ellipsis character when truncating the terminal title This is narrower than three dots, so more of the title fits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../client/gui/container/ContainerScreenTerminalScripting.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 4ddeaf822..7dfdca6c5 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 @@ -396,7 +396,7 @@ protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { 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("...")) + "..."); + title = Component.literal(this.font.plainSubstrByWidth(title.getString(), maxTitleWidth - this.font.width("\u2026")) + "\u2026"); } guiGraphics.drawString(this.font, title, this.titleLabelX, this.titleLabelY, 4210752, false); guiGraphics.drawString(this.font, this.playerInventoryTitle, this.inventoryLabelX, this.inventoryLabelY, 4210752, false); From 84661a9c329baaed2089ce7f14c3489cb31b8556 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:02:54 +0000 Subject: [PATCH 16/17] Keep scrolling while a drag selection is held outside the editor The editor only scrolled one line per mouse movement beyond its top or bottom, so holding the mouse still stopped the scrolling, and the speed depended on how fast the mouse moved. It now scrolls every tick while the selection is held outside the editor, 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, 51 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 2021fee87..89dd4954f 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 @@ -94,6 +94,9 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { 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 @@ -317,6 +320,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.getY() + this.height - 1); + 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 @@ -331,6 +349,9 @@ public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { 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(); @@ -386,23 +407,33 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl } if (mouseButton == 0 && this.draggingSelection) { - // Scroll when dragging a selection beyond the top or bottom - if (mouseY < this.getY()) { - this.setFirstRow(this.firstRow - 1, true); - } else if (mouseY >= this.getY() + this.height) { - this.setFirstRow(this.firstRow + 1, true); - } - int x = (int) Math.max(this.getX(), Math.min(this.getX() + this.width - 1, mouseX)); - int y = (int) Math.max(this.getY(), Math.min(this.getY() + this.height - 1, mouseY)); - 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); + // 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; } + @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 = (int) Math.max(this.getY(), Math.min(this.getY() + this.height - 1, 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 (this.shortcutListener != null && this.shortcutListener.keyPressed(typedChar, keyCode, 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 7dfdca6c5..d1ef3cc7e 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 @@ -611,6 +611,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 f32517d37df2c15bd9b3b92a32f26ba126d81d48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:18:57 +0000 Subject: [PATCH 17/17] Document word and line navigation keys in the info book Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- src/main/resources/assets/integratedscripting/lang/en_us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/assets/integratedscripting/lang/en_us.json b/src/main/resources/assets/integratedscripting/lang/en_us.json index 4efc9a359..678959996 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -137,7 +137,7 @@ "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, and &oCtrl+Shift+Z&r (&oCmd+Shift+Z&r on Mac) also redoes.", + "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, &oCmd+Left&r and &oCmd+Right&r move to the start or end of the line on Mac, 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",