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/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/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java new file mode 100644 index 000000000..8f7c0e221 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -0,0 +1,308 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +/** + * Code editing operations on the state of a text area. + * @author rubensworks + */ +public class TextEditing { + + + 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()); + } + + /** + * @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; + } + + public static int lineEnd(String value, int pos) { + int i = value.indexOf('\n', 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; + } + + /** + * 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, 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()); + } + 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, String indent) { + 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)); + 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++) { + 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; + } + + /** + * 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, String indentUnit) { + 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 ? 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)) { + 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; + }; + } + + 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; + } + + /** + * Find the next case-insensitive occurrence of a query, wrapping around at the end. + * @return The start position of the occurrence, or -1 if there is none. + */ + public static int findNext(String value, String query, int from) { + if (query.isEmpty()) { + return -1; + } + String haystack = value.toLowerCase(java.util.Locale.ROOT); + String needle = query.toLowerCase(java.util.Locale.ROOT); + int i = haystack.indexOf(needle, Math.max(0, Math.min(from, haystack.length()))); + return i >= 0 ? i : haystack.indexOf(needle); + } + + /** + * Find all non-overlapping case-insensitive occurrences of a query. + * @return The start positions of all occurrences. + */ + public static int[] findAll(String value, String query) { + if (query.isEmpty()) { + return new int[0]; + } + String haystack = value.toLowerCase(java.util.Locale.ROOT); + String needle = query.toLowerCase(java.util.Locale.ROOT); + it.unimi.dsi.fastutil.ints.IntList positions = new it.unimi.dsi.fastutil.ints.IntArrayList(); + int i = haystack.indexOf(needle); + while (i >= 0) { + positions.add(i); + i = haystack.indexOf(needle, i + needle.length()); + } + return positions.toIntArray(); + } + + /** + * Find the last case-insensitive occurrence of a query that starts before a position, wrapping around at the start. + * @return The start position of the occurrence, or -1 if there is none. + */ + public static int findPrevious(String value, String query, int before) { + int[] positions = findAll(value, query); + if (positions.length == 0) { + return -1; + } + for (int i = positions.length - 1; i >= 0; i--) { + if (positions[i] < before) { + return positions[i]; + } + } + return positions[positions.length - 1]; + } + + /** + * @param line A 1-based line number, which is clamped to the existing lines. + * @return The position of the start of the line. + */ + public static int lineOffset(String value, int line) { + int pos = 0; + for (int i = 1; i < line; i++) { + int next = value.indexOf('\n', pos); + if (next < 0) { + break; + } + pos = next + 1; + } + return pos; + } + + /** + * 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/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/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 86da7a15c..152c70c06 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,6 +39,7 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedscripting.client.key.Keys; import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; @@ -92,6 +93,20 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private WidgetScrollBar scrollBar; private int firstRow; private TextHistory history = new TextHistory(); + private boolean draggingSelection; + private double dragMouseX; + private double dragMouseY; + private float dragScrollProgress; + @Nullable + private String searchQuery; + @Nullable + private String searchMatchesValue; + @Nullable + private String searchMatchesQuery; + private int[] searchMatches = new int[0]; + private String indent = " "; + @Nullable + 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); @@ -99,22 +114,44 @@ public WidgetTextArea(Font font, int x, int y, int width, int height, Component this.showLineNumbers = showLineNumbers; this.textFieldHelper = new TextFieldHelperExtended(this::getValue, this::setValuePassive, this::getClipboard, this::setClipboard, s -> true, this::setSelected, this::onCursorPosChanged); if (scrollBar) { - this.scrollBar = new WidgetScrollBar(x + width - SCROLLBAR_WIDTH, y, height, - Component.translatable("gui.cyclopscore.scrollbar"), firstRow -> setFirstRow(firstRow, false), height / ROW_HEIGHT) { - @Override - public int getTotalRows() { - return getDisplayCache().linesTotal; - } + this.scrollBar = this.createScrollBar(); + } + } - @Override - public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { - // Only show scrollbar if needed - if (this.needsScrollBars()) { - super.renderWidget(guiGraphics, mouseX, mouseY, partialTicks); - } + private WidgetScrollBar createScrollBar() { + int x = this.getX(); + int y = this.getY(); + int width = this.getWidth(); + int height = this.getHeight(); + return new WidgetScrollBar(x + width - SCROLLBAR_WIDTH, y, height, + Component.translatable("gui.cyclopscore.scrollbar"), firstRow -> setFirstRow(firstRow, false), height / ROW_HEIGHT) { + @Override + public int getTotalRows() { + return getDisplayCache().linesTotal; + } + + @Override + public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTicks) { + // Only show scrollbar if needed + if (this.needsScrollBars()) { + super.renderWidget(guiGraphics, mouseX, mouseY, partialTicks); } - }; + } + }; + } + + /** + * Change the height, for example to make room for other widgets, while keeping the scroll and cursor position. + */ + @Override + public void setHeight(int height) { + super.setHeight(height); + if (this.scrollBar != null) { + this.scrollBar = this.createScrollBar(); + this.setFirstRow(this.firstRow, true); } + this.clearDisplayCache(); + this.ensureCursorVisible(); } private DisplayCache createEmptyDisplayCache() { @@ -153,6 +190,54 @@ public void setListenerCursor(@Nullable IInputListener listenerCursor) { this.listenerCursor = listenerCursor; } + /** + * @param shortcutListener Called with each key press before the editor handles it, returns true if it handled the key. + */ + public void setShortcutListener(@Nullable ShortcutListener shortcutListener) { + this.shortcutListener = shortcutListener; + } + + /** + * Select a range and scroll to it. + * @param start The start position, where the selection is anchored. + * @param end The end position, where the cursor is placed. + */ + public void selectRange(int start, int end) { + this.textFieldHelper.setSelectionRange(end, start); + this.clearDisplayCache(); + this.ensureCursorVisible(); + } + + /** + * @param indent The string that is inserted for one indentation level. + */ + public void setIndent(String indent) { + this.indent = indent; + } + + /** + * @param searchQuery A query of which all occurrences are highlighted, or null. + */ + 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; } @@ -235,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.clampToVisibleRows(this.dragMouseY); + if (distance > 0) { + this.dragScrollProgress += (float) Math.min(2.5, 0.15 + distance / 24); + int rows = (int) this.dragScrollProgress; + this.dragScrollProgress -= rows; + if (rows > 0) { + this.setFirstRow(this.firstRow + (this.dragMouseY < this.getY() ? -rows : rows), true); + this.updateDragSelection(); + } + } + } } @Override @@ -245,8 +345,13 @@ public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { this.setFocused(true); return true; } else { + this.draggingSelection = false; if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { + this.draggingSelection = this.scrollBar == null || mouseX < this.getX() + this.width - SCROLLBAR_WIDTH; + this.dragMouseX = mouseX; + this.dragMouseY = mouseY; + this.dragScrollProgress = 0; this.setFocused(true); long i = Util.getMillis(); DisplayCache displayCache = this.getDisplayCache(); @@ -296,28 +401,48 @@ public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double @Override public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, double offsetX, double offsetY) { - if (this.scrollBar != null && mouseX >= this.getX() + this.width - 12 && mouseX < this.getX() + this.width + if (!this.draggingSelection && this.scrollBar != null && mouseX >= this.getX() + this.width - 12 && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { return this.scrollBar.mouseDragged(mouseX, mouseY, mouseButton, offsetX, offsetY); } - if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width - && mouseY >= this.getY() && mouseY < this.getY() + this.height) { - DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); - int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, this.clampToVisibleRows(mouseY)))); - this.textFieldHelper.setCursorPos(i, true); - this.clearDisplayCache(); - this.setFocused(true); + if (mouseButton == 0 && this.draggingSelection) { + // Scrolling beyond the top or bottom happens in tick, so it continues while the mouse is held still + this.dragMouseX = mouseX; + this.dragMouseY = mouseY; + this.updateDragSelection(); return true; } // Let other drags through, such as spreading items over inventory slots return false; } + @Override + public boolean mouseReleased(double mouseX, double mouseY, int mouseButton) { + if (mouseButton == 0) { + this.draggingSelection = false; + this.dragScrollProgress = 0; + } + return super.mouseReleased(mouseX, mouseY, mouseButton); + } + + private void updateDragSelection() { + int x = (int) Math.max(this.getX(), Math.min(this.getX() + this.width - 1, this.dragMouseX)); + int y = this.clampToVisibleRows(Math.max(this.getY(), this.dragMouseY)); + DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); + int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i(x, y))); + this.textFieldHelper.setCursorPos(i, true); + this.clearDisplayCache(); + this.setFocused(true); + } + @Override public boolean keyPressed(int typedChar, int keyCode, int modifiers) { + if (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) { @@ -383,15 +508,50 @@ private static boolean hasControlDown(int modifiers) { return (modifiers & (Minecraft.ON_OSX ? GLFW.GLFW_MOD_SUPER : GLFW.GLFW_MOD_CONTROL)) != 0; } - private boolean historyKeyPressed(int key, int modifiers) { - if (!hasControlDown(modifiers)) { - return false; + 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(); } - 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; @@ -403,7 +563,16 @@ 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 (Keys.matches(Keys.COMMENT, p_98153_, p_98154_, p_98155_)) { + this.setState(TextEditing.toggleComment(this.getState())); + return true; + } 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(); return true; } else if (Screen.isCopy(p_98153_)) { @@ -421,7 +590,10 @@ 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(), this.indent)); + return true; + case GLFW.GLFW_KEY_TAB: + 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); @@ -548,6 +720,33 @@ 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()); + 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) { @@ -581,6 +780,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); @@ -881,4 +1093,9 @@ static class Pos2i { public static interface IMarkupProvider { public List> markupLine(Style style, String line); } + + public static interface ShortcutListener { + public boolean keyPressed(int keyCode, int scanCode, int modifiers); + } + } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index f9ddffc3f..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 @@ -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.client.KeyMapping; 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,10 +33,14 @@ 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; +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; @@ -74,7 +81,13 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended toolbarEditButtons = Lists.newArrayList(); + private ButtonIcon buttonUndo; + private ButtonIcon buttonRedo; + private int toolbarLeft; private WidgetDialog pendingScriptRemovalDialog; + @Nullable + private WidgetSearchBar searchBar; private int lastClientSyncTick; private final DisplayErrorsComponent displayErrors = new DisplayErrorsComponent(); @@ -103,6 +116,7 @@ public void init() { this.saveCursorPos(); } pendingScriptRemovalDialog = null; + searchBar = null; super.init(); @@ -127,7 +141,16 @@ 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, 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; + }); getMenu().setRemoteScriptChangeListener(this::onRemoteScriptChanged); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); @@ -142,6 +165,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(); @@ -150,10 +175,55 @@ public int getTotalRows() { } } + /** + * Add buttons for editor actions, which also show their keyboard shortcut in their tooltip. + * @param right The right side of the toolbar. + * @param y The top of the toolbar. + */ + private void addToolbar(int right, int y) { + this.toolbarLeft = right - 50; + this.toolbarEditButtons.clear(); + 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, 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(() -> { + // 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(); if (this.lastClientSyncTick == 0) { // Send modified scripts from client to server @@ -322,7 +392,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("\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); // Draw disk label guiGraphics.drawString(font, L10NHelpers.localize("gui.integratedscripting.disk") + ":", 8, 6, 16777215); @@ -534,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 @@ -541,6 +625,15 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return false; } + if (this.searchBar != null && this.searchBar.isFocused()) { + return this.searchBar.keyPressed(typedChar, keyCode, modifiers); + } + // Close the search bar before closing the screen + if (this.searchBar != null && typedChar == GLFW.GLFW_KEY_ESCAPE) { + closeNavigate(); + return true; + } + if (textArea.isFocused()) { boolean ret = textArea.keyPressed(typedChar, keyCode, modifiers); if (typedChar != GLFW.GLFW_KEY_ESCAPE) { @@ -550,12 +643,119 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { return super.keyPressed(typedChar, keyCode, modifiers); } + /** + * Show a bar below the editor to find text, or to go to a line number. + */ + private void openNavigate(boolean toLine) { + String selected = this.textArea.getSelected(); + String initialValue = !toLine && !selected.isEmpty() && selected.indexOf('\n') < 0 ? selected : ""; + if (this.searchBar != null && this.searchBar.isLineMode() == toLine) { + this.searchBar.focusAndSelect(initialValue); + } else { + closeNavigate(); + // The editor gives up the space for the bar + this.textArea.setHeight(this.textArea.getHeight() - WidgetSearchBar.HEIGHT); + this.searchBar = new WidgetSearchBar(font, this.textArea.getX(), this.textArea.getY() + this.textArea.getHeight(), this.textArea.getWidth(), + toLine, initialValue, this::onSearchChanged, this::navigate, this::closeNavigate); + addRenderableWidget(this.searchBar); + } + this.textArea.setFocused(false); + this.setFocused(this.searchBar); + this.onSearchChanged(this.searchBar.getValue()); + } + + private void closeNavigate() { + if (this.searchBar != null) { + removeWidget(this.searchBar); + this.searchBar = null; + this.textArea.setHeight(this.textArea.getHeight() + WidgetSearchBar.HEIGHT); + this.textArea.setSearchQuery(null); + this.setFocused(this.textArea); + this.textArea.setFocused(true); + } + } + + private void onSearchChanged(String query) { + if (this.searchBar.isLineMode()) { + this.searchBar.setStatus("", !query.isBlank() && parseLine(query) < 0); + return; + } + this.textArea.setSearchQuery(query); + if (!query.isEmpty()) { + // Stay on the current match while typing, or move to the next one + int pos = TextEditing.findNext(this.textArea.getValue(), query, Math.min(this.textArea.getCursorPos(), this.textArea.getSelectionPos())); + if (pos >= 0) { + this.textArea.selectRange(pos, pos + query.length()); + } + } + this.updateSearchStatus(); + } + + private void updateSearchStatus() { + if (this.searchBar == null || this.searchBar.isLineMode()) { + return; + } + String query = this.searchBar.getValue(); + if (query.isEmpty()) { + this.searchBar.setStatus("", false); + return; + } + int[] matches = this.textArea.getSearchMatches(); + int start = Math.min(this.textArea.getCursorPos(), this.textArea.getSelectionPos()); + int end = Math.max(this.textArea.getCursorPos(), this.textArea.getSelectionPos()); + int current = 0; + for (int i = 0; i < matches.length; i++) { + if (matches[i] == start && end - start == query.length()) { + current = i + 1; + } + } + this.searchBar.setStatus(current + "/" + matches.length, matches.length == 0); + } + + private static int parseLine(String value) { + try { + int line = Integer.parseInt(value.trim()); + return line > 0 ? line : -1; + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * @param direction 1 for the next match, -1 for the previous match. + */ + private void navigate(int direction) { + String query = this.searchBar.getValue(); + String value = this.textArea.getValue(); + if (this.searchBar.isLineMode()) { + int line = parseLine(query); + if (line < 0) { + this.searchBar.setStatus("", true); + return; + } + int pos = TextEditing.lineOffset(value, line); + closeNavigate(); + this.textArea.selectRange(pos, pos); + return; + } + int pos = direction > 0 + ? TextEditing.findNext(value, query, Math.max(this.textArea.getCursorPos(), this.textArea.getSelectionPos())) + : TextEditing.findPrevious(value, query, Math.min(this.textArea.getCursorPos(), this.textArea.getSelectionPos())); + if (pos >= 0) { + this.textArea.selectRange(pos, pos + query.length()); + } + this.updateSearchStatus(); + } + @Override public boolean charTyped(char p_94683_, int p_94684_) { // Make active dialog consume all input if (pendingScriptRemovalDialog != null) { return false; } + if (this.searchBar != null && this.searchBar.isFocused()) { + return this.searchBar.charTyped(p_94683_, p_94684_); + } return super.charTyped(p_94683_, p_94684_); } } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java index 77cb4ece5..9d6678d8a 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/image/ScriptImages.java @@ -16,4 +16,10 @@ public class ScriptImages { public static final Image FILE_OTHER = new Image(ICONS, 0, 0, 12, 12); public static final Image FILE_JS = new Image(ICONS, 12, 0, 12, 12); + public static final Image ACTION_UNDO = new Image(ICONS, 0, 16, 8, 8); + public static final Image ACTION_REDO = new Image(ICONS, 8, 16, 8, 8); + public static final Image ACTION_FIND = new Image(ICONS, 16, 16, 8, 8); + public static final Image ACTION_GOTO_LINE = new Image(ICONS, 24, 16, 8, 8); + public static final Image ACTION_COMMENT = new Image(ICONS, 32, 16, 8, 8); + } diff --git a/src/main/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 23728d28a..678959996 100644 --- a/src/main/resources/assets/integratedscripting/lang/en_us.json +++ b/src/main/resources/assets/integratedscripting/lang/en_us.json @@ -26,6 +26,13 @@ "gui.integratedscripting.disk": "Disk", "gui.integratedscripting.script": "Script", "gui.integratedscripting.create_file": "Create File", + "gui.integratedscripting.find": "Find", + "gui.integratedscripting.goto_line": "Go to line", + "gui.integratedscripting.find.label": "Find:", + "gui.integratedscripting.goto_line.label": "Line:", + "gui.integratedscripting.find.previous": "Previous match (Shift+Enter)", + "gui.integratedscripting.find.next": "Next match (Enter)", + "gui.integratedscripting.find.close": "Close (Escape)", "gui.integratedscripting.removal_dialog.title": "Confirm script deletion", "gui.integratedscripting.removal_dialog.message": "Do you want to delete \"%s\"?", "gui.integratedscripting.removal_dialog.delete": "Delete", @@ -33,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.", @@ -119,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, &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", diff --git a/src/main/resources/assets/integratedscripting/textures/gui/icons.png b/src/main/resources/assets/integratedscripting/textures/gui/icons.png index cf2519fee..c896e55b9 100644 Binary files a/src/main/resources/assets/integratedscripting/textures/gui/icons.png and b/src/main/resources/assets/integratedscripting/textures/gui/icons.png differ 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 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..d7b5e42f0 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditingTest.java @@ -0,0 +1,223 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +public class 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); + } + + 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), FOUR), equalTo(s("a b", 5))); + } + + @Test + public void testIndentReplacesSingleLineSelection() { + 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), FOUR), equalTo(s(" a\n b\nc", 4, 11))); + } + + @Test + public void testIndentMultipleLinesSelectionEndingAtLineStart() { + 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), FOUR), equalTo(s("a\n b", 5))); + } + + @Test + public void testUnindentCursorInsideIndent() { + assertThat(TextEditing.unindent(s(" b", 2), FOUR), equalTo(s("b", 0))); + } + + @Test + public void testUnindentPartialIndent() { + assertThat(TextEditing.unindent(s(" b", 3), FOUR), equalTo(s("b", 1))); + } + + @Test + public void testUnindentNoIndent() { + 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), FOUR), equalTo(s("a\nb\nc", 0, 5))); + } + + @Test + public void testNewlineKeepsIndent() { + assertThat(TextEditing.newline(s(" a;", 6), FOUR), equalTo(s(" a;\n ", 11))); + } + + @Test + public void testNewlineIndentsAfterOpeningBracket() { + assertThat(TextEditing.newline(s(" if (a) {", 10), FOUR), equalTo(s(" if (a) {\n ", 17))); + } + + @Test + public void testNewlineIndentsAfterOpeningBracketWithTrailingSpace() { + assertThat(TextEditing.newline(s("f(", 2), FOUR), equalTo(s("f(\n ", 7))); + } + + @Test + public void testNewlineSplitsBracketPair() { + assertThat(TextEditing.newline(s(" {}", 3), FOUR), equalTo(s(" {\n \n }", 10))); + } + + @Test + public void testNewlineReplacesSelection() { + assertThat(TextEditing.newline(s("abc", 1, 2), FOUR), equalTo(s("a\nc", 2))); + } + + @Test + public void testNewlineInMiddleOfIndent() { + assertThat(TextEditing.newline(s(" a", 2), FOUR), 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()); + } + + @Test + public void testFindNext() { + assertThat(TextEditing.findNext("abc abc", "abc", 1), equalTo(4)); + } + + @Test + public void testFindNextWraps() { + assertThat(TextEditing.findNext("abc abc", "abc", 5), equalTo(0)); + } + + @Test + public void testFindNextCaseInsensitive() { + assertThat(TextEditing.findNext("const Foo", "foo", 0), equalTo(6)); + } + + @Test + public void testFindNextMissing() { + assertThat(TextEditing.findNext("abc", "x", 0), equalTo(-1)); + assertThat(TextEditing.findNext("abc", "", 0), equalTo(-1)); + } + + @Test + public void testLineOffset() { + assertThat(TextEditing.lineOffset("a\nbc\nd", 1), equalTo(0)); + assertThat(TextEditing.lineOffset("a\nbc\nd", 3), equalTo(5)); + assertThat(TextEditing.lineOffset("a\nbc\nd", 10), equalTo(5)); + assertThat(TextEditing.lineOffset("a\nbc\nd", 0), equalTo(0)); + } + + @Test + public void 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))); + } + + @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))); + } + + @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)); + } + +} 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)); + } + +}