diff --git a/.gitignore b/.gitignore index bc07c6742..2b48ce3b8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ config # Ignore Minecraft runtime logs /logs + +# ClientDevBridge session state (golden images are intentionally kept) +.clientdevbridge/* +!.clientdevbridge/golden/ diff --git a/build.gradle b/build.gradle index 209552489..51a6fd2a1 100644 --- a/build.gradle +++ b/build.gradle @@ -115,6 +115,10 @@ test { repositories { mavenCentral() mavenLocal() + maven { + name "JEI" + url "https://maven.blamejared.com" + } maven { name "Cyclops Repo" url "https://maven.pkg.github.com/CyclopsMC/packages" @@ -156,6 +160,9 @@ dependencies { } } + // Optional, only used to hide JEI in focus mode + compileOnly "mezz.jei:jei-${project.minecraft_version}-neoforge-api:${project.jei_version}" + modLib "com.google.re2j:re2j:1.1" modLib ("io.netty:netty-codec-http:4.1.77.Final") { transitive = false diff --git a/gradle.properties b/gradle.properties index 0d554fbf7..30911db06 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,3 +35,4 @@ graal_version=25.2.4 cyclopscore_version=1.30.1-1051 integrateddynamics_version=1.32.11-1809 commoncapabilities_version=2.11.4-344 +jei_version=29.5.0.28 diff --git a/src/main/java/org/cyclops/integratedscripting/GeneralConfig.java b/src/main/java/org/cyclops/integratedscripting/GeneralConfig.java index a0ffc93e1..7c81c1d74 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 DummyConfigCommon { public static int terminalScriptingBaseConsumption = 1; @ConfigurablePropertyCommon(category = "general", comment = "The minimum number of ticks inbetween sending a script change packet from client to server.", minimalValue = 0, configLocation = ModConfigLocation.SERVER) public static int terminalScriptingClientSyncTickInterval = 20; + @ConfigurablePropertyCommon(category = "general", comment = "The number of spaces that tab inserts in the scripting terminal editor.", minimalValue = 1, maximalValue = 16, configLocation = ModConfigLocation.CLIENT) + public static int terminalScriptingIndentSize = 2; @ConfigurablePropertyCommon(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 = ModConfigLocation.SERVER) public static boolean graalAllowCreateProcess = false; diff --git a/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java b/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java index aa8bdc318..753f82d74 100644 --- a/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/IntegratedScripting.java @@ -34,6 +34,7 @@ import org.cyclops.integratedscripting.command.CommandTestScript; import org.cyclops.integratedscripting.component.DataComponentDiskIdConfig; import org.cyclops.integratedscripting.core.client.model.ScriptingVariableModelProviders; +import org.cyclops.integratedscripting.core.evaluate.ScriptEvaluationStatuses; import org.cyclops.integratedscripting.core.evaluate.ScriptVariableFacadeHandler; import org.cyclops.integratedscripting.core.packageddependencies.PackagedDependenciesLoader; import org.cyclops.integratedscripting.core.language.LanguageHandlerRegistry; @@ -43,7 +44,9 @@ import org.cyclops.integratedscripting.evaluate.translation.ValueTranslatorRegistry; import org.cyclops.integratedscripting.evaluate.translation.ValueTranslators; import org.cyclops.integratedscripting.gametest.GameTestsAdvancements; +import org.cyclops.integratedscripting.gametest.GameTestsScriptingDrive; import org.cyclops.integratedscripting.gametest.GameTestsScripts; +import org.cyclops.integratedscripting.gametest.GameTestsTerminalScripting; import org.cyclops.integratedscripting.inventory.container.ContainerScriptingDriveConfig; import org.cyclops.integratedscripting.inventory.container.ContainerTerminalScriptingConfig; import org.cyclops.integratedscripting.item.ItemScriptingDiskConfig; @@ -127,6 +130,7 @@ protected void onServerStopping(ServerStoppingEvent event) { } this.scriptingData = null; EvaluationExceptionResolutionHelpers.reset(); + ScriptEvaluationStatuses.reset(); } @SubscribeEvent @@ -187,7 +191,9 @@ protected ICommonProxy constructCommonProxy() { public Class[] getGameTestClasses() { return new Class[]{ GameTestsAdvancements.class, - GameTestsScripts.class + GameTestsScripts.class, + GameTestsScriptingDrive.class, + GameTestsTerminalScripting.class }; } diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java index c46835a87..dee5a847a 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -1,12 +1,18 @@ package org.cyclops.integratedscripting.api.language; +import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import org.apache.commons.lang3.tuple.Pair; import org.cyclops.cyclopscore.client.gui.image.IImage; import org.cyclops.integrateddynamics.api.evaluate.EvaluationException; import org.cyclops.integratedscripting.api.network.IScriptFactory; +import javax.annotation.Nullable; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Set; /** * Logic for handling a specific programming language. @@ -22,8 +28,73 @@ public interface ILanguageHandler { public List> markupLine(String line); + /** + * Mark up a full script, which allows constructs such as block comments to span multiple lines. + * @param script The script contents. + * @return Styled segments that together form the full script. + */ + public default List> markupScript(String script) { + List> segments = new ArrayList<>(); + String[] lines = script.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + segments.add(Pair.of(Style.EMPTY, "\n")); + } + segments.addAll(markupLine(lines[i])); + } + return segments; + } + public IScriptFactory getScriptFactory() throws EvaluationException; + /** + * @return Words of the language that can not be the name of a script member, such as keywords. + */ + public default Set getReservedWords() { + return Collections.emptySet(); + } + + /** + * @param script The script contents. + * @return The names of the members declared at the top level of the script, + * or null if they can not be determined. + */ + @Nullable + public default Set getTopLevelMembers(String script) { + return null; + } + + /** + * @param script The script contents. + * @param cursor The cursor position. + * @return Completion candidates at the cursor, or null if there are none. + */ + @Nullable + public default ScriptCompletions getCompletions(String script, int cursor) { + return null; + } + + /** + * @param script The script contents. + * @param cursor The cursor position. + * @return The signature of the function call the cursor is in, or null if unknown. + */ + @Nullable + public default Component getSignatureHint(String script, int cursor) { + return null; + } + + /** + * Check a script for syntax errors, without executing it. + * @param path The script path. + * @param script The script contents. + * @return The first syntax error, or null if there is none or if checking is not supported. + */ + @Nullable + public default ScriptSyntaxError checkSyntax(Path path, String script) { + return null; + } + public void onServerStarted(); } diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java new file mode 100644 index 000000000..71fcd4fff --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptCompletions.java @@ -0,0 +1,22 @@ +package org.cyclops.integratedscripting.api.language; + +import net.minecraft.network.chat.Component; + +import java.util.List; + +/** + * Completion candidates at a position in a script. + * @param start The position from which the text before the cursor is replaced when accepting a candidate. + * @param items The candidates. + * @author rubensworks + */ +public record ScriptCompletions(int start, List items) { + + /** + * @param name The text to insert. + * @param detail A short description, such as a signature. + */ + public record Item(String name, Component detail) { + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java new file mode 100644 index 000000000..b41bb28a3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ScriptSyntaxError.java @@ -0,0 +1,11 @@ +package org.cyclops.integratedscripting.api.language; + +/** + * A syntax error in a script. + * @param message The error message. + * @param line The 1-based line of the error. + * @param column The 1-based column of the error. + * @author rubensworks + */ +public record ScriptSyntaxError(String message, int line, int column) { +} diff --git a/src/main/java/org/cyclops/integratedscripting/blockentity/BlockEntityScriptingDrive.java b/src/main/java/org/cyclops/integratedscripting/blockentity/BlockEntityScriptingDrive.java index 4dde48f8f..c9fce0981 100644 --- a/src/main/java/org/cyclops/integratedscripting/blockentity/BlockEntityScriptingDrive.java +++ b/src/main/java/org/cyclops/integratedscripting/blockentity/BlockEntityScriptingDrive.java @@ -103,9 +103,11 @@ public void setExposedDiskId(int exposedDiskId) { this.onDirty(); ScriptingNetworkHelpers.getScriptingNetwork(getNetwork()) .ifPresent(scriptingNetwork -> { - if (exposedDiskId == -1) { + // Also remove the old disk when it is directly replaced by another one + if (oldExposedDiskId >= 0) { scriptingNetwork.removeDisk(oldExposedDiskId); - } else { + } + if (exposedDiskId >= 0) { scriptingNetwork.addDisk(exposedDiskId); } }); 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..964e6139d --- /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.GuiGraphicsExtractor; +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(GuiGraphicsExtractor 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/EditorKeys.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeys.java new file mode 100644 index 000000000..c45b40a6b --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeys.java @@ -0,0 +1,28 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.lwjgl.glfw.GLFW; + +/** + * Platform conventions for moving the cursor in text editors. + * @author rubensworks + */ +public final class EditorKeys { + + private EditorKeys() { + } + + /** + * @return If the cursor should move or delete by word: Option on macOS, Ctrl elsewhere. + */ + public static boolean isWordModifier(int modifiers, boolean osx) { + return (modifiers & (osx ? GLFW.GLFW_MOD_ALT : GLFW.GLFW_MOD_CONTROL)) != 0; + } + + /** + * @return If arrow keys should jump to the line or document start or end, which is Cmd on macOS. + */ + public static boolean isLineModifier(int modifiers, boolean osx) { + return osx && (modifiers & GLFW.GLFW_MOD_SUPER) != 0; + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java new file mode 100644 index 000000000..5c67e4fdd --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextEditing.java @@ -0,0 +1,375 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntList; + +import java.util.function.IntToDoubleFunction; + +/** + * Code editing operations on the state of a text area. + * @author rubensworks + */ +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); + } + } + + + private static final String BREAK_AFTER = ",;([{"; + private static final String OPERATORS = "+-*/%=&|<>!?:"; + + private static boolean canBreakBefore(String value, int pos) { + char prev = value.charAt(pos - 1); + char cur = value.charAt(pos); + if (cur == ' ') { + return false; + } + if (prev == ' ' || BREAK_AFTER.indexOf(prev) >= 0) { + return true; + } + // Don't split multi-character operators such as && and >= + if ((OPERATORS.indexOf(prev) >= 0) != (OPERATORS.indexOf(cur) >= 0)) { + return true; + } + // Keep member access chains readable, but don't split numbers + return cur == '.' && !Character.isDigit(prev); + } + + /** + * Wrap lines to the given width, preferably at spaces and punctuation, so identifiers are not split. + * Trailing spaces may exceed the width. + * @param value A text. + * @param maxWidth The maximum line width. + * @param charWidth The width of a character. + * @return The start positions of all wrapped lines. + */ + public static int[] wrapLines(String value, float maxWidth, IntToDoubleFunction charWidth) { + IntList starts = new IntArrayList(); + int start = 0; + while (true) { + starts.add(start); + int end = value.indexOf('\n', start); + if (end < 0) { + end = value.length(); + } + float width = 0; + int lastBreak = -1; + for (int i = start; i < end; i++) { + if (i > start && canBreakBefore(value, i)) { + lastBreak = i; + } + char c = value.charAt(i); + width += (float) charWidth.applyAsDouble(c); + if (width > maxWidth && i > start && c != ' ') { + start = lastBreak > start ? lastBreak : i; + starts.add(start); + width = 0; + lastBreak = -1; + i = start - 1; + } + } + if (end >= value.length() - 1) { + break; + } + start = end + 1; + } + return starts.toIntArray(); + } + +} diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java index ff114d38f..d75222700 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextFieldHelperExtended.java @@ -29,8 +29,9 @@ public TextFieldHelperExtended(Supplier getMessageFn, Consumer s public String getSelected() { String value = this.getMessageFn.get(); - int i = Math.min(this.getCursorPos(), this.getSelectionPos()); - int j = Math.max(this.getCursorPos(), this.getSelectionPos()); + // Positions may still refer to a previous, longer value + int i = Math.min(Math.min(this.getCursorPos(), this.getSelectionPos()), value.length()); + int j = Math.min(Math.max(this.getCursorPos(), this.getSelectionPos()), value.length()); return value.substring(i, j); } diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java new file mode 100644 index 000000000..92774f2c0 --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/TextHistory.java @@ -0,0 +1,83 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import javax.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Undo and redo history of a text area. + * @author rubensworks + */ +public class TextHistory { + + public static final int MAX_SIZE = 500; + public static final long TYPING_GROUP_MILLIS = 1000; + + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + private boolean lastWasTyping; + private long lastRecordTime; + private int lastTypingEnd = -1; + + /** + * Record the state before an edit. + * Consecutive typing at the same position within a short time is grouped into a single undo step. + * @param before The state before the edit. + * @param typing If the edit was caused by typing a single character. + * @param time The current time in milliseconds. + */ + public void record(State before, boolean typing, long time) { + boolean group = typing && this.lastWasTyping && !this.undoStack.isEmpty() + && time - this.lastRecordTime < TYPING_GROUP_MILLIS + && Math.min(before.cursorPos(), before.selectionPos()) == this.lastTypingEnd; + if (!group) { + this.undoStack.push(before); + if (this.undoStack.size() > MAX_SIZE) { + this.undoStack.removeLast(); + } + } + this.redoStack.clear(); + this.lastWasTyping = typing; + this.lastRecordTime = time; + this.lastTypingEnd = typing ? Math.min(before.cursorPos(), before.selectionPos()) + 1 : -1; + } + + /** + * @param current The current state, which will become redoable. + * @return The state to restore, or null if there is nothing to undo. + */ + @Nullable + public State undo(State current) { + if (this.undoStack.isEmpty()) { + return null; + } + this.redoStack.push(current); + this.lastWasTyping = false; + return this.undoStack.pop(); + } + + /** + * @param current The current state, which will become undoable. + * @return The state to restore, or null if there is nothing to redo. + */ + @Nullable + public State redo(State current) { + if (this.redoStack.isEmpty()) { + return null; + } + this.undoStack.push(current); + this.lastWasTyping = false; + return this.redoStack.pop(); + } + + public 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/WidgetDialog.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java index 36b097214..ede01cd1a 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetDialog.java @@ -4,10 +4,12 @@ import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.narration.NarratedElementType; import net.minecraft.client.gui.narration.NarrationElementOutput; import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.InputWithModifiers; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; @@ -18,6 +20,8 @@ import org.cyclops.cyclopscore.helper.IModHelpers; import org.cyclops.integratedscripting.Reference; +import javax.annotation.Nullable; + /** * Shows a confirm/cancel dialog * @author rubensworks @@ -34,10 +38,23 @@ public class WidgetDialog extends AbstractWidget implements GuiEventListener { private final Component message; private final ButtonText buttonConfirm; private final ButtonText buttonCancel; + @Nullable + private final EditBox input; + @Nullable + private Component warning; public WidgetDialog(Font font, int x, int y, Screen parent, Component title, Component message, Component confirm, Component cancel, Button.OnPress confirmCallback, Button.OnPress cancelCallback) { + this(font, x, y, parent, title, message, confirm, cancel, confirmCallback, cancelCallback, null); + } + + /** + * @param inputValue If not null, a text input is shown with this initial value. + */ + public WidgetDialog(Font font, int x, int y, Screen parent, + Component title, Component message, Component confirm, Component cancel, + Button.OnPress confirmCallback, Button.OnPress cancelCallback, @Nullable String inputValue) { super(x, y, WIDTH, HEIGHT, message); this.font = font; this.parent = parent; @@ -46,6 +63,21 @@ public WidgetDialog(Font font, int x, int y, Screen parent, this.buttonConfirm = new ButtonText(x + 50, y + HEIGHT - 15 - 5, 50, 15, confirm, confirm, confirmCallback, true); this.buttonCancel = new ButtonText(x + WIDTH - 50 - 50, y + HEIGHT - 15 - 5, 50, 15, cancel, cancel, cancelCallback, true); + if (inputValue != null) { + this.input = new EditBox(font, x + 10, y + 31, WIDTH - 20, 12, message); + this.input.setMaxLength(256); + this.input.setValue(inputValue); + this.input.setFocused(true); + } else { + this.input = null; + } + } + + /** + * @param warning A short warning to show below the input, or null. + */ + public void setWarning(@Nullable Component warning) { + this.warning = warning; } protected void drawBackground(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, float partialTicks) { @@ -65,16 +97,40 @@ public void extractWidgetRenderState(GuiGraphicsExtractor guiGraphics, int mouse guiGraphics.centeredText(font, title, this.getX() + this.width / 2, this.getY() + 4, ARGB.opaque(16777215)); FormattedCharSequence formattedcharsequence = message.getVisualOrderText(); - guiGraphics.text(font, message, (this.getX() + this.width / 2 - font.width(formattedcharsequence) / 2), (this.getY() + 25), ARGB.opaque(4210752), false); + guiGraphics.text(font, message, (this.getX() + this.width / 2 - font.width(formattedcharsequence) / 2), (this.getY() + (this.input != null ? 19 : 25)), ARGB.opaque(4210752), false); + if (this.input != null) { + this.input.extractRenderState(guiGraphics, mouseX, mouseY, partialTicks); + } + if (this.warning != null) { + // Scaled down to fit between the input and the buttons, and further for long translations + String warning = this.warning.getString(); + float scale = Math.min(0.5F, (float) (WIDTH - 20) / font.width(warning)); + IModHelpers.get().getRenderHelpers().drawScaledString(guiGraphics, font, warning, this.getX() + this.width / 2 - (int) (font.width(warning) * scale / 2), + this.getY() + 45, scale, IModHelpers.get().getBaseHelpers().RGBAToInt(140, 30, 30, 255), false, Font.DisplayMode.NORMAL); + } } @Override public boolean mouseClicked(MouseButtonEvent mouse, boolean isDoubleClick) { return this.buttonConfirm.mouseClicked(mouse, isDoubleClick) || this.buttonCancel.mouseClicked(mouse, isDoubleClick) + || (this.input != null && this.input.mouseClicked(mouse, isDoubleClick)) || super.mouseClicked(mouse, isDoubleClick); } + @Nullable + public EditBox getInput() { + return input; + } + + public void confirm(InputWithModifiers input) { + this.buttonConfirm.onPress(input); + } + + public void cancel(InputWithModifiers input) { + this.buttonCancel.onPress(input); + } + @Override protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { narrationElementOutput.add(NarratedElementType.TITLE, message); 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..7d0c58e8b --- /dev/null +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetSearchBar.java @@ -0,0 +1,171 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +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.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import org.cyclops.cyclopscore.client.gui.component.button.ButtonText; +import org.cyclops.cyclopscore.helper.IModHelpers; +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 = IModHelpers.get().getBaseHelpers().RGBAToInt(224, 224, 224, 255); + private static final int COLOR_ERROR = IModHelpers.get().getBaseHelpers().RGBAToInt(255, 85, 85, 255); + + 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 + public void extractWidgetRenderState(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, float partialTicks) { + guiGraphics.fill(this.getX(), this.getY(), this.getX() + this.width, this.getY() + this.height, IModHelpers.get().getBaseHelpers().RGBAToInt(60, 60, 60, 255)); + guiGraphics.text(this.font, this.label, this.getX() + 3, this.getY() + 2, COLOR_TEXT, false); + this.input.extractRenderState(guiGraphics, mouseX, mouseY, partialTicks); + if (!this.lineMode) { + int statusRight = this.buttonPrevious.getX() - 2; + guiGraphics.text(this.font, this.status, statusRight - this.font.width(this.status), this.getY() + 2, + this.error ? COLOR_ERROR : COLOR_TEXT, false); + this.buttonPrevious.extractRenderState(guiGraphics, mouseX, mouseY, partialTicks); + this.buttonNext.extractRenderState(guiGraphics, mouseX, mouseY, partialTicks); + } + this.buttonClose.extractRenderState(guiGraphics, mouseX, mouseY, partialTicks); + } + + @Override + public boolean mouseClicked(MouseButtonEvent mouse, boolean isDoubleClick) { + if (!this.isMouseOver(mouse.x(), mouse.y())) { + return false; + } + if (this.buttonClose.mouseClicked(mouse, isDoubleClick) + || (!this.lineMode && (this.buttonPrevious.mouseClicked(mouse, isDoubleClick) + || this.buttonNext.mouseClicked(mouse, isDoubleClick)))) { + return true; + } + this.input.mouseClicked(mouse, isDoubleClick); + return true; + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (event.key() == GLFW.GLFW_KEY_ESCAPE) { + this.closeCallback.run(); + } else if (event.key() == GLFW.GLFW_KEY_ENTER || event.key() == GLFW.GLFW_KEY_KP_ENTER) { + this.navigateCallback.accept(event.hasShiftDown() ? -1 : 1); + } else { + this.input.keyPressed(event); + } + // Don't let typing trigger screen actions, such as closing the screen + return true; + } + + @Override + public boolean charTyped(CharacterEvent event) { + return this.input.charTyped(event); + } + + @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 0ec62dc01..5b3fc9cbb 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 @@ -9,32 +9,43 @@ import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.components.MultilineTextField; import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.gui.font.TextFieldHelper; import net.minecraft.client.gui.narration.NarratedElementType; import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.components.MultilineTextField; import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.InputQuirks; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.client.renderer.Rect2i; import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.Rect2i; +import net.minecraft.locale.Language; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Mth; import net.minecraft.util.ARGB; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.mutable.MutableBoolean; import org.apache.commons.lang3.mutable.MutableInt; import org.apache.commons.lang3.tuple.Pair; +import org.joml.Vector2i; import org.cyclops.cyclopscore.client.gui.component.WidgetScrollBar; import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.IModHelpers; +import org.cyclops.integratedscripting.api.language.ScriptCompletions; +import org.cyclops.integratedscripting.api.language.ScriptSyntaxError; +import org.cyclops.integratedscripting.client.key.Keys; +import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -55,6 +66,24 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { public static final int ROW_HEIGHT = 9; + private static final int COMPLETION_ROWS = 6; + // Colors of the completion and signature popups + private static final int POPUP_BACKGROUND = 0xFF202020; + private static final int POPUP_BORDER = 0xFFA0A0A0; + // Neutral, so the colors of value types stay readable + private static final int POPUP_SELECTED = 0xFF505050; + private static final int POPUP_TEXT = 0xFFE0E0E0; + private static final int POPUP_DETAIL = 0xFF909090; + public static final int SCROLLBAR_WIDTH = 7; + // Same height as the vanilla scroller, which the scrollbar's drag handling assumes + private static final int SCROLLBAR_HANDLE_HEIGHT = 15; + // Height of the arrow between a syntax error tooltip and its error + private static final int TOOLTIP_ARROW = 4; + // How far vanilla tooltip backgrounds extend beyond their contents + private static final int TOOLTIP_BORDER = 4; + // Colors of vanilla tooltips + private static final int TOOLTIP_BACKGROUND = 0xF0100010; + private static final int TOOLTIP_BORDER_COLOR = 0x505000FF; private final TextFieldHelperExtended textFieldHelper; private final Font font; @@ -71,6 +100,19 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { private IInputListener listenerCursor; @Nullable private IMarkupProvider markupProvider; + @Nullable + private BiFunction completionProvider; + @Nullable + private BiFunction signatureProvider; + @Nullable + private ScriptCompletions completions; + private int completionIndex; + // Where the completions were last drawn, to handle clicks and scrolling on them + private int completionsX; + private int completionsY; + private int completionsWidth; + private int completionsFirstItem; + private int completionsRows; private WidgetTextArea.DisplayCache emptyDisplayCache = createEmptyDisplayCache(); @Nullable private WidgetTextArea.DisplayCache displayCache = emptyDisplayCache; @@ -79,6 +121,25 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { @Nullable private WidgetScrollBar scrollBar; private int firstRow; + @Nullable + private TextLayout layout; + private TextHistory history = new TextHistory(); + private boolean draggingSelection; + private double dragMouseX; + 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; + @Nullable + private ScriptSyntaxError syntaxError; public WidgetTextArea(Font font, int x, int y, int width, int height, Component narrationMessage, boolean scrollBar, boolean showLineNumbers) { super(font, x, y, width, height, narrationMessage); @@ -86,27 +147,56 @@ public WidgetTextArea(Font font, int x, int y, int width, int height, Component this.showLineNumbers = showLineNumbers; this.textFieldHelper = new TextFieldHelperExtended(this::getValue, this::setValuePassive, this::getClipboard, this::setClipboard, s -> true, this::setSelected, this::onCursorPosChanged); if (scrollBar) { - this.scrollBar = new WidgetScrollBar(x + width - 14, y, height, - 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 extractWidgetRenderState(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, float partialTicks) { - // Only show scrollbar if needed - if (this.needsScrollBars()) { - super.extractWidgetRenderState(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 extractWidgetRenderState(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, float partialTicks) { + // Only show scrollbar if needed, and draw a narrower handle than the vanilla scroller to leave more room for text + if (this.needsScrollBars()) { + int x = this.getX(); + int y = this.getY() + (int) ((this.getHeight() - SCROLLBAR_HANDLE_HEIGHT - 2) * this.getCurrentScroll()); + int x2 = x + SCROLLBAR_WIDTH - 1; + int y2 = y + SCROLLBAR_HANDLE_HEIGHT; + guiGraphics.fill(x, y, x2, y2, IModHelpers.get().getBaseHelpers().RGBAToInt(85, 85, 85, 255)); + guiGraphics.fill(x, y, x2 - 1, y2 - 1, IModHelpers.get().getBaseHelpers().RGBAToInt(255, 255, 255, 255)); + guiGraphics.fill(x + 1, y + 1, x2 - 1, y2 - 1, IModHelpers.get().getBaseHelpers().RGBAToInt(198, 198, 198, 255)); } - }; + } + }; + } + + /** + * 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() { Pos2i pos = convertLocalToScreen(new Pos2i(0, 0)); - return new DisplayCache("", pos, true, new int[]{0}, new LineInfo[]{new LineInfo(Collections.emptyList(), pos.x, pos.y, 0, false)}, new LineInfo[]{new LineInfo(Collections.emptyList(), pos.x, pos.y, 0, false)}, new Rect2i[0], 0, 0); + TextLayout layout = new TextLayout("", new int[]{0}, new String[]{""}, new int[]{0}, new Markup(Collections.emptyList())); + return new DisplayCache("", pos, true, new int[]{0}, new LineInfo[]{new LineInfo(layout, 0, pos.x, pos.y, false)}, new LineInfo[]{new LineInfo(layout, 0, pos.x, pos.y, false)}, new Rect2i[0], 0, 0); } public void setFirstRow(int firstRow, boolean propagateToScrollbar) { @@ -140,11 +230,151 @@ 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(); + } + + public void setSyntaxError(@Nullable ScriptSyntaxError syntaxError) { + this.syntaxError = syntaxError; + } + + /** + * @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; + } + public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { this.markupProvider = markupProvider; + this.layout = null; + } + + public void setCompletionProvider(@Nullable BiFunction completionProvider) { + this.completionProvider = completionProvider; + } + + public void setSignatureProvider(@Nullable BiFunction signatureProvider) { + this.signatureProvider = signatureProvider; + } + + /** + * Only show completions while typing, not when moving the cursor. + */ + private void updateCompletions(boolean textChanged) { + if (textChanged && this.completionProvider != null) { + ScriptCompletions completionsNew = this.completionProvider.apply(this.getValue(), this.getCursorPos()); + if (completionsNew == null || this.completions == null || !completionsNew.items().equals(this.completions.items())) { + this.completionIndex = 0; + } + this.completions = completionsNew; + } else { + this.completions = null; + } + } + + /** + * @return If completions were shown. + */ + public boolean dismissCompletions() { + boolean shown = this.completions != null; + this.completions = null; + return shown; + } + + private boolean completionKeyPressed(int key) { + int count = this.completions.items().size(); + switch (key) { + case GLFW.GLFW_KEY_DOWN: + this.completionIndex = (this.completionIndex + 1) % count; + return true; + case GLFW.GLFW_KEY_UP: + this.completionIndex = (this.completionIndex + count - 1) % count; + return true; + case GLFW.GLFW_KEY_TAB: + case GLFW.GLFW_KEY_ENTER: + case GLFW.GLFW_KEY_KP_ENTER: + this.acceptCompletion(); + return true; + case GLFW.GLFW_KEY_ESCAPE: + this.completions = null; + return true; + default: + return false; + } + } + + /** + * @return The row of the shown completions at the given position, or -1 if there is none. + */ + private int getCompletionRowAt(double mouseX, double mouseY) { + if (this.completions == null || this.completionsRows == 0 || mouseX < this.completionsX || mouseX >= this.completionsX + this.completionsWidth + || mouseY < this.completionsY + 1 || mouseY >= this.completionsY + 1 + this.completionsRows * ROW_HEIGHT) { + return -1; + } + return Math.min((int) (mouseY - this.completionsY - 1) / ROW_HEIGHT, this.completionsRows - 1); + } + + private void acceptCompletion() { + TextHistory.State before = this.getState(); + // Replace the text directly instead of selecting it, as a selection would change the bound member + String insert = this.completions.items().get(this.completionIndex).name() + "("; + String value = this.getValue(); + int cursorPos = this.getCursorPos(); + int start = this.completions.start(); + int newCursorPos = start + insert.length(); + this.completions = null; + this.setState(new TextHistory.State(value.substring(0, start) + insert + value.substring(cursorPos), newCursorPos, newCursorPos)); + this.recordHistory(before, false); + this.clearDisplayCache(); + this.ensureCursorVisible(); } public void setValue(String value) { + this.firstRow = 0; + // Another script may have the same contents but another language + this.layout = null; this.setValuePassive(value); textFieldHelper.setCursorToStart(); @@ -155,7 +385,6 @@ public void setValue(String value) { } public void setValuePassive(String value) { - this.firstRow = 0; this.value = value; this.clearDisplayCache(); @@ -218,23 +447,53 @@ 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 public boolean mouseClicked(MouseButtonEvent mouse, boolean isDoubleClick) { - if (mouse.button() == 1 && mouse.x() >= this.getX() && mouse.x() < this.getX() + this.width - && mouse.y() >= this.getY() && mouse.y() < this.getY() + this.height) { - // Select everything + double mouseX = mouse.x(); + double mouseY = mouse.y(); + int mouseButton = mouse.button(); + // Insert a clicked completion + int completionRow = this.getCompletionRowAt(mouseX, mouseY); + if (completionRow >= 0 && mouseButton == 0) { + this.completionIndex = this.completionsFirstItem + completionRow; + this.acceptCompletion(); + return true; + } + this.completions = null; + if (mouseButton == 1 && mouseX >= this.getX() && mouseX < this.getX() + this.width + && mouseY >= this.getY() && mouseY < this.getY() + this.height) { + // Don't select everything on right-click, as typing would then replace the whole script this.setFocused(true); - textFieldHelper.selectAll(); return true; } else { - if (mouse.button() == 0 && mouse.x() >= this.getX() && mouse.x() < this.getX() + this.width - && mouse.y() >= this.getY() && mouse.y() < this.getY() + this.height) { + 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(); - int j = displayCache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouse.x(), (int)mouse.y()))); + int j = displayCache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, this.clampToVisibleRows(mouseY)))); if (j >= 0) { if (j == this.lastIndex && i - this.lastClickTime < 250L) { if (!this.textFieldHelper.isSelecting()) { @@ -243,7 +502,7 @@ public boolean mouseClicked(MouseButtonEvent mouse, boolean isDoubleClick) { this.textFieldHelper.selectAll(); } } else { - this.textFieldHelper.setCursorPos(j, Minecraft.getInstance().hasShiftDown()); + this.textFieldHelper.setCursorPos(j, mouse.hasShiftDown()); } this.clearDisplayCache(); @@ -270,6 +529,11 @@ private void selectWord(int p_98142_) { @Override public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double scroll) { + // Move through the completions when scrolling on them + if (scroll != 0 && this.getCompletionRowAt(mouseX, mouseY) >= 0) { + this.completionIndex = Mth.clamp(this.completionIndex - (int) Math.signum(scroll), 0, this.completions.items().size() - 1); + return true; + } if (this.scrollBar != null && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height && this.scrollBar.mouseScrolled(mouseX, mouseY, mouseZ, scroll)) { @@ -280,53 +544,191 @@ public boolean mouseScrolled(double mouseX, double mouseY, double mouseZ, double @Override public boolean mouseDragged(MouseButtonEvent mouse, double offsetX, double offsetY) { - if (this.scrollBar != null && mouse.x() >= this.getX() + this.width - 12 && mouse.x() < this.getX() + this.width - && mouse.y() >= this.getY() && mouse.y() < this.getY() + this.height) { + double mouseX = mouse.x(); + double mouseY = mouse.y(); + int mouseButton = mouse.button(); + 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(mouse, offsetX, offsetY); } - if (mouse.button() == 0 && mouse.x() >= this.getX() && mouse.x() < this.getX() + this.width - && mouse.y() >= this.getY() && mouse.y() < this.getY() + this.height) { - DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); - int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouse.x(), (int)mouse.y()))); - 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; } - return true; + // Let other drags through, such as spreading items over inventory slots + return false; + } + + @Override + public boolean mouseReleased(MouseButtonEvent mouse) { + if (mouse.button() == 0) { + this.draggingSelection = false; + this.dragScrollProgress = 0; + } + return super.mouseReleased(mouse); + } + + 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(KeyEvent event) { - boolean flag = this.textFieldKeyPressed(event); + int typedChar = event.key(); + int keyCode = event.scancode(); + int modifiers = event.modifiers(); + if (this.completions != null && this.completionKeyPressed(typedChar)) { + return true; + } + if (this.shortcutListener != null && this.shortcutListener.keyPressed(typedChar, keyCode, modifiers)) { + return true; + } + TextHistory.State before = this.getState(); + boolean history = this.historyKeyPressed(typedChar, keyCode, modifiers); + boolean flag = history || this.textFieldKeyPressed(typedChar, keyCode, modifiers); if (flag) { + if (!history) { + this.recordHistory(before, false); + } + this.updateCompletions(!history && !before.value().equals(this.getValue())); this.clearDisplayCache(); this.setFocused(true); + this.ensureCursorVisible(); return true; - } else { + } else if (typedChar == GLFW.GLFW_KEY_ESCAPE) { this.setFocused(false); return false; + } else { + // Consume all other keys, so they don't trigger screen actions (such as closing the screen) while typing + return true; } } @Override public boolean charTyped(CharacterEvent event) { - if (super.charTyped(event)) { - this.setFocused(true); - return true; - } else if (event.isAllowedChatCharacter()) { + // Don't delegate to EditBox, as that would consume the character into its own (unused) value + if (event.isAllowedChatCharacter()) { + TextHistory.State before = this.getState(); this.textFieldHelper.insertText(event.codepointAsString()); + this.recordHistory(before, true); + this.updateCompletions(true); this.clearDisplayCache(); this.setFocused(true); + this.ensureCursorVisible(); return true; } else { - this.setFocused(false); return false; } } - private boolean textFieldKeyPressed(KeyEvent event) { - if (event.isSelectAll()) { + private TextHistory.State getState() { + return new TextHistory.State(this.getValue(), this.textFieldHelper.getCursorPos(), this.textFieldHelper.getSelectionPos()); + } + + private void setState(TextHistory.State state) { + this.setValuePassive(state.value()); + this.textFieldHelper.setSelectionRange(state.cursorPos(), state.selectionPos()); + } + + private void recordHistory(TextHistory.State before, boolean typing) { + if (!before.value().equals(this.getValue())) { + this.history.record(before, typing, Util.getMillis()); + } + } + + /** + * Replace the value as an undoable edit, keeping the cursor position where possible. + * @param value The new value. + */ + public void replaceValue(String value) { + TextHistory.State before = this.getState(); + this.setState(new TextHistory.State(value, before.cursorPos(), before.selectionPos())); + this.recordHistory(before, false); + this.clearDisplayCache(); + } + + // Modifiers are read from the key event instead of the global keyboard state + private static boolean hasControlDown(int modifiers) { + return (modifiers & (InputQuirks.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(); + } + } + + // 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 (Keys.matches(Keys.UNDO, key, scanCode, modifiers)) { + state = this.history.undo(this.getState()); + } else if (Keys.matches(Keys.REDO, key, scanCode, modifiers) || isRedoAlternative(key, modifiers)) { + state = this.history.redo(this.getState()); + } else { + return false; + } + if (state != null) { + this.setState(state); + } + return true; + } + + private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { + KeyEvent event = new KeyEvent(p_98153_, p_98154_, p_98155_); + boolean select = event.hasShiftDown(); + 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 (event.isSelectAll()) { this.textFieldHelper.selectAll(); return true; } else if (event.isCopy()) { @@ -339,11 +741,15 @@ private boolean textFieldKeyPressed(KeyEvent event) { this.textFieldHelper.cut(); return true; } else { - TextFieldHelper.CursorStep cursorStep = event.hasControlDown() ? TextFieldHelper.CursorStep.WORD : TextFieldHelper.CursorStep.CHARACTER; - switch (event.key()) { + TextFieldHelper.CursorStep cursorStep = EditorKeys.isWordModifier(p_98155_, InputQuirks.ON_OSX) ? TextFieldHelper.CursorStep.WORD : TextFieldHelper.CursorStep.CHARACTER; + boolean lineModifier = EditorKeys.isLineModifier(p_98155_, InputQuirks.ON_OSX); + 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); @@ -352,28 +758,44 @@ private boolean textFieldKeyPressed(KeyEvent event) { this.textFieldHelper.removeFromCursor(1, cursorStep); return true; case 262: - this.textFieldHelper.moveBy(1, event.hasShiftDown(), cursorStep); + if (lineModifier) { + this.keyEnd(false, select); + } else { + this.textFieldHelper.moveBy(1, select, cursorStep); + } return true; case 263: - this.textFieldHelper.moveBy(-1, event.hasShiftDown(), cursorStep); + if (lineModifier) { + this.keyHome(false, select); + } else { + this.textFieldHelper.moveBy(-1, select, cursorStep); + } return true; case 264: - this.keyDown(event); + if (lineModifier) { + this.keyEnd(true, select); + } else { + this.changeLine(1, select); + } return true; case 265: - this.keyUp(event); + if (lineModifier) { + this.keyHome(true, select); + } else { + this.changeLine(-1, select); + } return true; case 266: -// this.backButton.onPress(); + this.keyPage(-1, select); return true; case 267: -// this.forwardButton.onPress(); + this.keyPage(1, select); return true; case 268: - this.keyHome(event); + this.keyHome(event.hasControlDownWithQuirk(), select); return true; case 269: - this.keyEnd(event); + this.keyEnd(event.hasControlDownWithQuirk(), select); return true; default: return false; @@ -381,52 +803,61 @@ private boolean textFieldKeyPressed(KeyEvent event) { } } - private void keyUp(KeyEvent event) { - this.changeLine(-1, event); + private void changeLine(int p_98098_, boolean select) { + int cursorPos = this.textFieldHelper.getCursorPos(); + DisplayCache displayCache = this.getDisplayCache(); + int j = displayCache.changeLine(cursorPos, p_98098_); + this.textFieldHelper.setCursorPos(j, select); } - private void keyDown(KeyEvent event) { - this.changeLine(1, event); + /** + * @return The y position, moved up to the last fully visible row if it is below it. + */ + private int clampToVisibleRows(double mouseY) { + int rows = this.scrollBar != null ? this.scrollBar.getVisibleRows() : this.getHeight() / ROW_HEIGHT; + return (int) Math.min(mouseY, this.getY() + rows * ROW_HEIGHT - 1); } - private void changeLine(int p_98098_, KeyEvent event) { - int cursorPos = this.textFieldHelper.getCursorPos(); - DisplayCache displayCache = this.getDisplayCache(); - int j = displayCache.changeLine(cursorPos, p_98098_); - this.textFieldHelper.setCursorPos(j, event.hasShiftDown()); + private void keyPage(int direction, boolean select) { + int rows = this.scrollBar != null ? this.scrollBar.getVisibleRows() : this.getHeight() / ROW_HEIGHT; + this.changeLine(direction * rows, select); + this.setFirstRow(this.firstRow + direction * rows, true); + } - // Modify scroll position when cursor goes out of screen + /** + * Scroll the viewport so that the cursor is visible. + */ + public void ensureCursorVisible() { if (this.scrollBar != null) { - int cursorLine = findLineFromPos(Arrays.stream(displayCache.lineStarts).toArray(), cursorPos); - if (cursorLine < this.firstRow + 2) { - // Cursor is above viewport - this.setFirstRow(cursorLine - 2, true); - } else if (cursorLine > this.firstRow + this.scrollBar.getVisibleRows() - 3) { - // Cursor is below viewport - this.setFirstRow(cursorLine - (this.scrollBar.getVisibleRows() - 3), true); + int cursorLine = findLineFromPos(this.getDisplayCache().lineStarts, this.textFieldHelper.getCursorPos()); + int visibleRows = this.scrollBar.getVisibleRows(); + if (cursorLine < this.firstRow) { + this.setFirstRow(cursorLine, true); + } else if (cursorLine >= this.firstRow + visibleRows) { + this.setFirstRow(cursorLine - visibleRows + 1, true); } } } - private void keyHome(KeyEvent event) { - if (event.hasControlDown()) { - this.textFieldHelper.setCursorToStart(event.hasShiftDown()); + private void keyHome(boolean document, boolean select) { + if (document) { + this.textFieldHelper.setCursorToStart(select); } else { int i = this.textFieldHelper.getCursorPos(); int j = this.getDisplayCache().findLineStart(i); - this.textFieldHelper.setCursorPos(j, event.hasShiftDown()); + this.textFieldHelper.setCursorPos(j, select); } } - private void keyEnd(KeyEvent event) { - if (event.hasControlDown()) { - this.textFieldHelper.setCursorToEnd(event.hasShiftDown()); + private void keyEnd(boolean document, boolean select) { + if (document) { + this.textFieldHelper.setCursorToEnd(select); } else { DisplayCache displayCache = this.getDisplayCache(); int i = this.textFieldHelper.getCursorPos(); int j = displayCache.findLineEnd(i); - this.textFieldHelper.setCursorPos(j, event.hasShiftDown()); + this.textFieldHelper.setCursorPos(j, select); } } @@ -437,39 +868,262 @@ public void extractWidgetRenderState(GuiGraphicsExtractor guiGraphics, int mouse 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, IModHelpers.get().getBaseHelpers().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, IModHelpers.get().getBaseHelpers().RGBAToInt(160, 200, 255, 255)); + } + } + } + + // Mark the gutter of the line with a syntax error + if (this.syntaxError != null && this.showLineNumbers) { + for (LineInfo line : lines) { + if (line.lineNumber + 1 == this.syntaxError.line()) { + // Fill the space between the editor border and the gutter separator + guiGraphics.fill(this.getX() - 1, line.y - 1, this.getX() + getLinesXOffset() - 2, line.y + ROW_HEIGHT - 1, IModHelpers.get().getBaseHelpers().RGBAToInt(255, 160, 160, 255)); + } + } + } + // Draw lines int lastLineNumber = -1; for(LineInfo line : lines) { - guiGraphics.text(this.font, line.asComponent, line.x, line.y - offsetY, ARGB.opaque(-16777216), false); + guiGraphics.text(this.font, line.getComponent(), line.x, line.y - offsetY, -16777216, false); // Draw line number if (this.showLineNumbers && lastLineNumber != line.lineNumber) { IModHelpers.get().getRenderHelpers().drawScaledString( guiGraphics, font, - String.valueOf(line.lineNumber), + String.valueOf(line.lineNumber + 1), // Line numbers start at 1, like in error messages this.getX(), line.y - offsetY + 2, 0.5f, - line.hasCursor ? 0 : IModHelpers.get().getBaseHelpers().RGBAToInt(120, 120, 120, 255), + this.syntaxError != null && this.syntaxError.line() == line.lineNumber + 1 ? IModHelpers.get().getBaseHelpers().RGBAToInt(170, 0, 0, 255) : (line.hasCursor ? ARGB.opaque(0) : IModHelpers.get().getBaseHelpers().RGBAToInt(120, 120, 120, 255)), false, Font.DisplayMode.NORMAL ); + } else if (this.showLineNumbers) { + // Mark wrapped continuation lines + IModHelpers.get().getRenderHelpers().drawScaledString(guiGraphics, font, "\u21B3", this.getX() + 4, line.y - offsetY + 2, 0.5f, + IModHelpers.get().getBaseHelpers().RGBAToInt(160, 160, 160, 255), false, Font.DisplayMode.NORMAL); } lastLineNumber = line.lineNumber; } + if (this.syntaxError != null) { + this.renderSyntaxError(guiGraphics, displayCache, lines, mouseX, mouseY); + } + // Show highlighting and cursor this.renderHighlight(guiGraphics, displayCache.selection); if (displayCache.cursor != null) { this.renderCursor(guiGraphics, displayCache.cursor, displayCache.cursorAtEnd); + + // Show completions while typing, or else the signature of the call at the cursor + if (this.isFocused()) { + Pos2i cursor = this.convertLocalToScreen(displayCache.cursor); + if (this.completions != null) { + this.renderCompletions(guiGraphics, cursor); + } else if (this.signatureProvider != null) { + Component signature = this.signatureProvider.apply(this.getValue(), this.getCursorPos()); + if (signature != null) { + this.renderSignature(guiGraphics, cursor, signature); + } + } + } } // Render scrollbar if (this.scrollBar != null) { + // Mark the position of the syntax error on the scrollbar, so it can be found when scrolled out of view. + // This is drawn below the handle, which only covers it when the error is roughly in view. + if (this.syntaxError != null && displayCache.linesTotal > this.scrollBar.getVisibleRows()) { + int errorRow = findLineFromPos(displayCache.lineStarts, TextEditing.lineOffset(this.getValue(), this.syntaxError.line())); + int y = this.getY() + (int) ((long) errorRow * (this.getHeight() - 2) / displayCache.linesTotal); + int x = this.getX() + this.getWidth() - SCROLLBAR_WIDTH; + guiGraphics.fill(x, y, x + SCROLLBAR_WIDTH - 1, y + 2, IModHelpers.get().getBaseHelpers().RGBAToInt(220, 0, 0, 255)); + + // Show the message when hovering the mark + if (mouseX >= x && mouseX < x + SCROLLBAR_WIDTH - 1 && mouseY >= y - 1 && mouseY < y + 3) { + guiGraphics.setTooltipForNextFrame(this.font, this.font.split(Component.literal(this.syntaxError.message()), 200), mouseX, mouseY); + } + } + this.scrollBar.extractRenderState(guiGraphics, getX(), getY(), partialTicks); } } + private void renderSyntaxError(GuiGraphicsExtractor guiGraphics, DisplayCache displayCache, List lines, int mouseX, int mouseY) { + String value = this.getValue(); + // Underline the character at the error position + int pos = Math.min(value.length(), TextEditing.lineOffset(value, this.syntaxError.line()) + Math.max(0, this.syntaxError.column() - 1)); + int line = findLineFromPos(displayCache.lineStarts, pos); + int row = line - this.firstRow; + int errorCenterX = -1; + if (row >= 0 && row < this.getHeight() / ROW_HEIGHT) { + int x = this.font.width(value.substring(displayCache.lineStarts[line], pos)); + int width = pos < value.length() && value.charAt(pos) != '\n' ? this.font.width(value.substring(pos, pos + 1)) : 4; + Pos2i screenPos = this.convertLocalToScreen(new Pos2i(x, row * ROW_HEIGHT)); + guiGraphics.fill(screenPos.x, screenPos.y + ROW_HEIGHT - 2, screenPos.x + width, screenPos.y + ROW_HEIGHT - 1, IModHelpers.get().getBaseHelpers().RGBAToInt(220, 0, 0, 255)); + errorCenterX = screenPos.x + width / 2; + } + + // Show the message when hovering the error line, which may be wrapped over multiple rows + int top = Integer.MAX_VALUE; + int bottom = Integer.MIN_VALUE; + boolean hovering = false; + for (LineInfo lineInfo : lines) { + if (lineInfo.lineNumber + 1 == this.syntaxError.line()) { + top = Math.min(top, lineInfo.y); + bottom = Math.max(bottom, lineInfo.y + ROW_HEIGHT); + hovering |= mouseX >= this.getX() && mouseX < this.getX() + this.getWidth() + && mouseY >= lineInfo.y && mouseY < lineInfo.y + ROW_HEIGHT; + } + } + if (hovering) { + List message = this.font.split(Component.literal(this.syntaxError.message()), 200); + int screenHeight = Minecraft.getInstance().getWindow().getGuiScaledHeight(); + // Same height as vanilla tooltips of text lines + int tooltipHeight = message.size() * 10 - (message.size() == 1 ? 2 : 0); + + // Like in other editors, show the message below the line, or above it if there is no room + boolean below = bottom + TOOLTIP_ARROW + TOOLTIP_BORDER + tooltipHeight + TOOLTIP_BORDER <= screenHeight + || top - TOOLTIP_ARROW - TOOLTIP_BORDER - tooltipHeight - TOOLTIP_BORDER < 0; + int tooltipY = below ? bottom + TOOLTIP_ARROW + TOOLTIP_BORDER : top - TOOLTIP_ARROW - TOOLTIP_BORDER - tooltipHeight; + int anchorX = errorCenterX >= 0 ? errorCenterX - 12 : mouseX + 12; + guiGraphics.setTooltipForNextFrame(this.font, message, (screenWidth, screenHeightIgnored, mouseXIgnored, mouseYIgnored, width, height) -> + new Vector2i(Math.max(TOOLTIP_BORDER, Math.min(anchorX, screenWidth - width - TOOLTIP_BORDER)), tooltipY), mouseX, mouseY, true); + + // Point at the column of the error + if (errorCenterX >= 0) { + this.renderTooltipArrow(guiGraphics, errorCenterX, below ? bottom : top - 1, below); + } + } + } + + /** + * Draw an arrow between a tooltip and the given position, in the style of vanilla tooltips. + */ + private void renderTooltipArrow(GuiGraphicsExtractor guiGraphics, int x, int tipY, boolean pointUp) { + for (int i = 0; i < TOOLTIP_ARROW; i++) { + int y = pointUp ? tipY + i : tipY - i; + guiGraphics.fill(x - i, y, x + i + 1, y + 1, TOOLTIP_BACKGROUND); + guiGraphics.fill(x - i, y, x - i + 1, y + 1, TOOLTIP_BORDER_COLOR); + guiGraphics.fill(x + i, y, x + i + 1, y + 1, TOOLTIP_BORDER_COLOR); + } + } + + private void renderCharBackground(GuiGraphicsExtractor 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 renderCompletions(GuiGraphicsExtractor guiGraphics, Pos2i cursor) { + List items = this.completions.items(); + int rows = Math.min(items.size(), COMPLETION_ROWS); + int firstItem = Math.max(0, Math.min(this.completionIndex - rows / 2, items.size() - rows)); + // Grow beyond the editor if needed, as signatures can be long + int width = getTextWidth(); + for (int i = 0; i < rows; i++) { + ScriptCompletions.Item item = items.get(firstItem + i); + width = Math.max(width, this.font.width(item.name()) + 4 + this.font.width(item.detail()) + 2); + } + Pair xAndWidth = this.fitHorizontally(width); + int x = xAndWidth.getLeft(); + width = xAndWidth.getRight(); + int height = rows * ROW_HEIGHT + 2; + // Show below the line of the cursor if possible, otherwise above it + int y = cursor.y + ROW_HEIGHT; + if (y + height > this.getY() + this.getHeight() && cursor.y - height - 1 >= this.getY()) { + y = cursor.y - height - 1; + } + this.completionsX = x; + this.completionsY = y; + this.completionsWidth = width; + this.completionsFirstItem = firstItem; + this.completionsRows = rows; + // Dark, like tooltips, as the colors of value types are made for those + guiGraphics.fill(x - 1, y - 1, x + width + 1, y + height + 1, POPUP_BORDER); + guiGraphics.fill(x, y, x + width, y + height, POPUP_BACKGROUND); + for (int i = 0; i < rows; i++) { + ScriptCompletions.Item item = items.get(firstItem + i); + int rowY = y + 1 + i * ROW_HEIGHT; + if (firstItem + i == this.completionIndex) { + guiGraphics.fill(x, rowY, x + width, rowY + ROW_HEIGHT, POPUP_SELECTED); + } + String name = this.font.plainSubstrByWidth(item.name(), width - 2); + guiGraphics.text(this.font, name, x + 1, rowY + 1, POPUP_TEXT, false); + int detailX = x + 1 + this.font.width(name) + 4; + int detailWidth = x + width - 1 - detailX; + if (detailWidth > 10) { + guiGraphics.text(this.font, this.truncate(item.detail(), detailWidth), detailX, rowY + 1, POPUP_DETAIL, false); + } + } + } + + /** + * @return The x position and width of a popup at the start of the text, moved left if it would leave the screen. + */ + private Pair fitHorizontally(int width) { + int screenWidth = Minecraft.getInstance().getWindow().getGuiScaledWidth(); + width = Math.min(width, screenWidth - 4); + int x = Math.min(this.getX() + getLinesXOffset(), screenWidth - 2 - width); + return Pair.of(x, width); + } + + private void renderSignature(GuiGraphicsExtractor guiGraphics, Pos2i cursor, Component signature) { + Pair xAndWidth = this.fitHorizontally(this.font.width(signature) + 2); + int x = xAndWidth.getLeft(); + int width = xAndWidth.getRight(); + // Show above the line of the cursor if possible, otherwise below it + int y = cursor.y - ROW_HEIGHT - 1; + if (y < this.getY() - 1) { + y = cursor.y + ROW_HEIGHT + 1; + } + guiGraphics.fill(x - 1, y - 1, x + width + 1, y + ROW_HEIGHT + 1, POPUP_BORDER); + guiGraphics.fill(x, y, x + width, y + ROW_HEIGHT, POPUP_BACKGROUND); + guiGraphics.text(this.font, this.truncate(signature, width - 2), x + 1, y + 1, POPUP_TEXT, false); + } + + /** + * @return The text, cut off with an ellipsis if it is wider than the given width. + */ + private FormattedCharSequence truncate(Component text, int width) { + if (this.font.width(text) <= width) { + return text.getVisualOrderText(); + } + FormattedText truncated = FormattedText.composite(this.font.substrByWidth(text, Math.max(0, width - this.font.width("\u2026"))), FormattedText.of("\u2026")); + return Language.getInstance().getVisualOrder(truncated); + } + private void renderCursor(GuiGraphicsExtractor guiGraphics, Pos2i pos, boolean cursorAtEnd) { if (this.isFocused() && this.frameTick / 6 % 2 == 0) { pos = this.convertLocalToScreen(pos); @@ -488,10 +1142,16 @@ private void renderHighlight(GuiGraphicsExtractor guiGraphics, Rect2i[] highligh int j = rect2i.getY(); int k = i + rect2i.getWidth(); int l = j + rect2i.getHeight(); - guiGraphics.fill(RenderPipelines.GUI_TEXT_HIGHLIGHT, i, j, k, l, -16776961); + // Dimmer when not focused + guiGraphics.fill(RenderPipelines.GUI_TEXT_HIGHLIGHT, i, j, k, l, this.isFocused() ? 0xFF0000FF : 0xFF000064); } } + private int getTextWidth() { + // Don't render text below the scrollbar + return this.getWidth() - getLinesXOffset() - (this.scrollBar != null ? SCROLLBAR_WIDTH : 0); + } + private int getLinesXOffset() { return this.showLineNumbers ? 14 : 0; } @@ -523,26 +1183,25 @@ private DisplayCache rebuildDisplayCache() { } else { int cursorPos = this.textFieldHelper.getCursorPos(); int selectionPos = this.textFieldHelper.getSelectionPos(); - IntList linePositionsOld = new IntArrayList(); + TextLayout layout = this.getLayout(s); + + // Keep scroll position within bounds, as the text may have shrunk + if (this.scrollBar != null) { + int maxFirstRow = Math.max(0, layout.lineStarts.length - this.scrollBar.getVisibleRows()); + if (this.firstRow > maxFirstRow) { + this.firstRow = maxFirstRow; + this.scrollBar.setFirstRow(this.firstRow, false); + } + } + + int cursorLineNumber = layout.lineNumbers[findLineFromPos(layout.lineStarts, cursorPos)]; List linesAll = Lists.newArrayList(); - MutableInt mutableint = new MutableInt(); - MutableBoolean mutableboolean = new MutableBoolean(); + for (int k3 = 0; k3 < layout.lineStarts.length; k3++) { + Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, (k3 - this.firstRow) * 9)); // Offset firstRow! + linesAll.add(new LineInfo(layout, k3, bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, layout.lineNumbers[k3] == cursorLineNumber)); + } + IntList linePositionsOld = IntArrayList.wrap(layout.lineStarts); StringSplitter stringsplitter = this.font.getSplitter(); - MutableInt lineNumber = new MutableInt(); - stringsplitter.splitLines(s, this.getWidth() - getLinesXOffset() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { - int k3 = mutableint.getAndIncrement(); - String stringPart = s.substring(startPos, endPos); - boolean hasNewLine = stringPart.endsWith("\n"); - mutableboolean.setValue(hasNewLine); - String s3 = StringUtils.stripEnd(stringPart, " \n"); - int l3 = (k3 - this.firstRow) * 9; // Offset firstRow! - Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, l3)); - linePositionsOld.add(startPos); - linesAll.add(new LineInfo(this.markupLine(style, s3), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumber.getValue(), cursorPos >= startPos && cursorPos < (hasNewLine ? endPos : s.indexOf("\n", endPos)))); - if (hasNewLine) { - lineNumber.increment(); - } - }); // --- Changed --- // Slice lines based on scroll position @@ -555,16 +1214,11 @@ private DisplayCache rebuildDisplayCache() { int[] lineStartsArr = lineStarts.toIntArray(); boolean flag = cursorPos == s.length(); - Pos2i cursor; - if (flag && mutableboolean.isTrue()) { - cursor = new Pos2i(0, lines.size() * 9); - } else { - int cursorLine = findLineFromPos(lineStartsArr, cursorPos); - int cursorX = this.font.width(s.substring(lineStartsArr[cursorLine], cursorPos)); - cursor = new Pos2i(cursorX, (cursorLine - this.firstRow) * 9); // We have to offset the firstRow here! - } - // Hide cursor if out of view - if (cursor.y < 0 || cursor.y > getHeight()) { + int cursorLine = findLineFromPos(lineStartsArr, cursorPos); + int cursorX = this.font.width(s.substring(lineStartsArr[cursorLine], cursorPos)); + Pos2i cursor = new Pos2i(cursorX, (cursorLine - this.firstRow) * 9); // We have to offset the firstRow here! + // Hide cursor if out of view, including the partially visible row below the last line + if (cursor.y < 0 || cursor.y + ROW_HEIGHT > getHeight()) { cursor = null; } @@ -592,11 +1246,11 @@ private DisplayCache rebuildDisplayCache() { selection.add(this.createPartialLineSelection(s, stringsplitter, lineStartsArr[lineEnd], i1, lineEnd * 9, lineStartsArr[lineEnd])); } } - // Hide out-of-view selections and offset them + // Hide out-of-view selections and offset them, including the partially visible row below the last line List selectionNew = Lists.newArrayList(); for (Rect2i rect2i : selection) { Rect2i rect2iNew = new Rect2i(rect2i.getX(), rect2i.getY() - this.firstRow * ROW_HEIGHT, rect2i.getWidth(), rect2i.getHeight()); - if (rect2iNew.getY() - this.getY() >= 0 && rect2iNew.getY() - this.getY() < getHeight()) { + if (rect2iNew.getY() - this.getY() >= 0 && rect2iNew.getY() + rect2iNew.getHeight() - this.getY() <= getHeight()) { selectionNew.add(rect2iNew); } } @@ -605,11 +1259,87 @@ private DisplayCache rebuildDisplayCache() { } } - private List> markupLine(Style style, String line) { + /** + * @return The wrapped and marked up lines of a text, which is only recomputed when the text changes. + */ + private TextLayout getLayout(String s) { + if (this.layout == null || !this.layout.text.equals(s)) { + IntList lineStarts = new IntArrayList(); + List contents = Lists.newArrayList(); + IntList lineNumbers = new IntArrayList(); + MutableBoolean trailingNewline = new MutableBoolean(); + MutableInt lineNumber = new MutableInt(); + StringSplitter splitter = this.font.getSplitter(); + int[] starts = TextEditing.wrapLines(s, getTextWidth(), c -> splitter.stringWidth(String.valueOf((char) c))); + for (int i = 0; i < starts.length; i++) { + String stringPart = s.substring(starts[i], i + 1 < starts.length ? starts[i + 1] : s.length()); + boolean hasNewLine = stringPart.endsWith("\n"); + trailingNewline.setValue(hasNewLine); + lineStarts.add(starts[i]); + contents.add(StringUtils.stripEnd(stringPart, " \n")); + lineNumbers.add(lineNumber.intValue()); + if (hasNewLine) { + lineNumber.increment(); + } + } + // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there + if (trailingNewline.isTrue()) { + lineStarts.add(s.length()); + contents.add(""); + lineNumbers.add(lineNumber.intValue()); + } + // Mark up the full text at once, so that constructs such as block comments can span multiple lines + this.layout = new TextLayout(s, lineStarts.toIntArray(), contents.toArray(new String[0]), lineNumbers.toIntArray(), new Markup(this.markup(s))); + } + return this.layout; + } + + private List> markup(String text) { if (this.markupProvider != null) { - return this.markupProvider.markupLine(style, line); + List> segments = this.markupProvider.markup(text); + // Ignore markup that doesn't match the text + if (segments.stream().mapToInt(segment -> segment.getRight().length()).sum() == text.length()) { + return segments; + } + } + return Stream.of(Pair.of(Style.EMPTY, text)).collect(Collectors.toList()); + } + + /** + * Styled segments of a text, which can be sliced by position. + */ + static class Markup { + private final int[] starts; + private final Style[] styles; + + Markup(List> segments) { + this.starts = new int[segments.size()]; + this.styles = new Style[segments.size()]; + int pos = 0; + for (int i = 0; i < segments.size(); i++) { + this.starts[i] = pos; + this.styles[i] = segments.get(i).getLeft(); + pos += segments.get(i).getRight().length(); + } + } + + List> slice(String text, int from, int to) { + List> result = Lists.newArrayList(); + if (from >= to) { + return result; + } + int i = Math.max(0, findLineFromPos(this.starts, from)); + while (i < this.starts.length && this.starts[i] < to) { + int segmentEnd = i + 1 < this.starts.length ? this.starts[i + 1] : text.length(); + int start = Math.max(from, this.starts[i]); + int end = Math.min(to, segmentEnd); + if (start < end) { + result.add(Pair.of(this.styles[i], text.substring(start, end))); + } + i++; + } + return result; } - return Stream.of(Pair.of(style, line)).collect(Collectors.toList()); } static int findLineFromPos(int[] p_98150_, int p_98151_) { @@ -673,17 +1403,10 @@ public int getIndexAtPosition(Font p_98214_, Pos2i p_98215_) { public int changeLine(int p_98211_, int p_98212_) { int i = WidgetTextArea.findLineFromPos(this.lineStarts, p_98211_); - int j = i + p_98212_; - int k; - if (0 <= j && j < this.lineStarts.length) { - int l = p_98211_ - this.lineStarts[i]; - int i1 = this.linesAll[j].contents.length(); - k = this.lineStarts[j] + Math.min(l, i1); - } else { - k = p_98211_; - } - - return k; + int j = Math.max(0, Math.min(this.lineStarts.length - 1, i + p_98212_)); + int l = p_98211_ - this.lineStarts[i]; + int i1 = this.linesAll[j].contents.length(); + return this.lineStarts[j] + Math.min(l, i1); } public int findLineStart(int p_98209_) { @@ -697,28 +1420,62 @@ public int findLineEnd(int p_98219_) { } } + /** + * The wrapped lines of a text, independent of the scroll and cursor position. + */ + static class TextLayout { + final String text; + final int[] lineStarts; + final String[] contents; + final int[] lineNumbers; + final Markup markup; + final Component[] components; + + TextLayout(String text, int[] lineStarts, String[] contents, int[] lineNumbers, Markup markup) { + this.text = text; + this.lineStarts = lineStarts; + this.contents = contents; + this.lineNumbers = lineNumbers; + this.markup = markup; + this.components = new Component[lineStarts.length]; + } + + /** + * Lazily create the styled component of a line, as only visible lines are rendered. + */ + Component getComponent(int line) { + if (this.components[line] == null) { + MutableComponent component = Component.literal(""); + for (Pair value : this.markup.slice(this.text, this.lineStarts[line], this.lineStarts[line] + this.contents[line].length())) { + component = component.append(Component.literal(value.getRight()).setStyle(value.getLeft())); + } + this.components[line] = component; + } + return this.components[line]; + } + } + static class LineInfo { - final List> contentsStyled; + private final TextLayout layout; + private final int line; final String contents; - final Component asComponent; final int x; final int y; final int lineNumber; final boolean hasCursor; - public LineInfo(List> contentsStyled, int x, int y, int lineNumber, boolean hasCursor) { - this.contentsStyled = contentsStyled; - this.contents = contentsStyled.stream().map(Pair::getRight).collect(Collectors.joining()); + public LineInfo(TextLayout layout, int line, int x, int y, boolean hasCursor) { + this.layout = layout; + this.line = line; + this.contents = layout.contents[line]; this.x = x; this.y = y; - this.lineNumber = lineNumber; + this.lineNumber = layout.lineNumbers[line]; this.hasCursor = hasCursor; + } - MutableComponent component = Component.literal(""); - for (Pair value : contentsStyled) { - component = component.append(Component.literal(value.getRight()).setStyle(value.getLeft())); - } - this.asComponent = component; + public Component getComponent() { + return this.layout.getComponent(this.line); } } @@ -733,6 +1490,15 @@ static class Pos2i { } public static interface IMarkupProvider { - public List> markupLine(Style style, String line); + /** + * @param text The full text. + * @return Styled segments that together form the full text. + */ + public List> markup(String text); + } + + public static interface ShortcutListener { + 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 fdf8acc7f..31fd7a371 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,25 +1,36 @@ 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 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.GuiGraphicsExtractor; -import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.components.Renderable; +import net.minecraft.client.gui.components.events.GuiEventListener; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; import net.minecraft.resources.Identifier; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.ARGB; +import net.minecraft.util.FormattedCharSequence; import net.minecraft.util.StringUtil; +import net.minecraft.util.Util; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; +import net.neoforged.fml.ModList; import org.apache.commons.lang3.tuple.Pair; +import org.apache.logging.log4j.Level; import org.cyclops.cyclopscore.client.gui.component.WidgetScrollBar; import org.cyclops.cyclopscore.client.gui.component.button.ButtonText; import org.cyclops.cyclopscore.client.gui.component.input.WidgetArrowedListField; @@ -32,20 +43,30 @@ 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; import org.cyclops.integratedscripting.network.packet.TerminalScriptingModifiedScriptPacket; +import org.cyclops.integratedscripting.network.packet.TerminalScriptingRenameScriptPacket; import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; +import java.nio.file.InvalidPathException; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -62,11 +83,17 @@ public class ContainerScreenTerminalScripting extends ContainerScreenExtended