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 cef253c07..28be3900f 100644 --- a/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java +++ b/src/main/java/org/cyclops/integratedscripting/api/language/ILanguageHandler.java @@ -10,6 +10,7 @@ import javax.annotation.Nullable; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; /** @@ -27,6 +28,23 @@ public interface ILanguageHandler { public List> markupLine(String line); + /** + * Mark up a full script, which allows constructs such as block comments to span multiple lines. + * @param script The script contents. + * @return Styled segments that together form the full script. + */ + public default List> markupScript(String script) { + List> segments = new ArrayList<>(); + String[] lines = script.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + segments.add(Pair.of(Style.EMPTY, "\n")); + } + segments.addAll(markupLine(lines[i])); + } + return segments; + } + public IScriptFactory getScriptFactory() throws EvaluationException; /** diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index e664e42a1..3bc4177e0 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -93,6 +93,8 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { @Nullable private WidgetScrollBar scrollBar; private int firstRow; + @Nullable + private TextLayout layout; private TextHistory history = new TextHistory(); private boolean draggingSelection; private double dragMouseX; @@ -159,7 +161,8 @@ public void setHeight(int height) { private DisplayCache createEmptyDisplayCache() { Pos2i pos = convertLocalToScreen(new Pos2i(0, 0)); - return new DisplayCache("", pos, true, new int[]{0}, new LineInfo[]{new LineInfo(Collections.emptyList(), pos.x, pos.y, 0, false)}, new LineInfo[]{new LineInfo(Collections.emptyList(), pos.x, pos.y, 0, false)}, new Rect2i[0], 0, 0); + TextLayout layout = new TextLayout("", new int[]{0}, new String[]{""}, new int[]{0}, new Markup(Collections.emptyList())); + return new DisplayCache("", pos, true, new int[]{0}, new LineInfo[]{new LineInfo(layout, 0, pos.x, pos.y, false)}, new LineInfo[]{new LineInfo(layout, 0, pos.x, pos.y, false)}, new Rect2i[0], 0, 0); } public void setFirstRow(int firstRow, boolean propagateToScrollbar) { @@ -251,10 +254,13 @@ public void setHistory(TextHistory history) { public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { this.markupProvider = markupProvider; + this.layout = null; } public void setValue(String value) { this.firstRow = 0; + // Another script may have the same contents but another language + this.layout = null; this.setValuePassive(value); textFieldHelper.setCursorToStart(); @@ -767,7 +773,7 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float // Draw lines int lastLineNumber = -1; for(LineInfo line : lines) { - guiGraphics.drawString(this.font, line.asComponent, line.x, line.y - offsetY, -16777216, false); + guiGraphics.drawString(this.font, line.getComponent(), line.x, line.y - offsetY, -16777216, false); // Draw line number if (this.showLineNumbers && lastLineNumber != line.lineNumber) { RenderHelpers.drawScaledString( @@ -920,47 +926,25 @@ private DisplayCache rebuildDisplayCache() { } else { int cursorPos = this.textFieldHelper.getCursorPos(); int selectionPos = this.textFieldHelper.getSelectionPos(); - IntList linePositionsOld = new IntArrayList(); - List> linesRaw = Lists.newArrayList(); - IntList lineNumbers = new IntArrayList(); - List linesHasCursor = Lists.newArrayList(); - MutableBoolean mutableboolean = new MutableBoolean(); - StringSplitter stringsplitter = this.font.getSplitter(); - MutableInt lineNumber = new MutableInt(); - stringsplitter.splitLines(s, getTextWidth() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { - String stringPart = s.substring(startPos, endPos); - boolean hasNewLine = stringPart.endsWith("\n"); - mutableboolean.setValue(hasNewLine); - linePositionsOld.add(startPos); - linesRaw.add(Pair.of(StringUtils.stripEnd(stringPart, " \n"), style)); - lineNumbers.add(lineNumber.intValue()); - linesHasCursor.add(cursorPos >= startPos && cursorPos < (hasNewLine ? endPos : s.indexOf("\n", endPos))); - if (hasNewLine) { - lineNumber.increment(); - } - }); - // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there - if (mutableboolean.isTrue()) { - linePositionsOld.add(s.length()); - linesRaw.add(Pair.of("", Style.EMPTY)); - lineNumbers.add(lineNumber.intValue()); - linesHasCursor.add(cursorPos == s.length()); - } + TextLayout layout = this.getLayout(s); // Keep scroll position within bounds, as the text may have shrunk if (this.scrollBar != null) { - int maxFirstRow = Math.max(0, linesRaw.size() - this.scrollBar.getVisibleRows()); + int maxFirstRow = Math.max(0, layout.lineStarts.length - this.scrollBar.getVisibleRows()); if (this.firstRow > maxFirstRow) { this.firstRow = maxFirstRow; this.scrollBar.setFirstRow(this.firstRow, false); } } + int cursorLineNumber = layout.lineNumbers[findLineFromPos(layout.lineStarts, cursorPos)]; List linesAll = Lists.newArrayList(); - for (int k3 = 0; k3 < linesRaw.size(); k3++) { + for (int k3 = 0; k3 < layout.lineStarts.length; k3++) { Pos2i bookeditscreen$pos2i1 = this.convertLocalToScreen(new Pos2i(0, (k3 - this.firstRow) * 9)); // Offset firstRow! - linesAll.add(new LineInfo(this.markupLine(linesRaw.get(k3).getRight(), linesRaw.get(k3).getLeft()), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumbers.getInt(k3), linesHasCursor.get(k3))); + linesAll.add(new LineInfo(layout, k3, bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, layout.lineNumbers[k3] == cursorLineNumber)); } + IntList linePositionsOld = IntArrayList.wrap(layout.lineStarts); + StringSplitter stringsplitter = this.font.getSplitter(); // --- Changed --- // Slice lines based on scroll position @@ -1018,11 +1002,85 @@ 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(); + this.font.getSplitter().splitLines(s, getTextWidth() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { + String stringPart = s.substring(startPos, endPos); + boolean hasNewLine = stringPart.endsWith("\n"); + trailingNewline.setValue(hasNewLine); + lineStarts.add(startPos); + contents.add(StringUtils.stripEnd(stringPart, " \n")); + lineNumbers.add(lineNumber.intValue()); + if (hasNewLine) { + lineNumber.increment(); + } + }); + // Add an empty line after a trailing newline, so the cursor can be placed and scrolled there + if (trailingNewline.isTrue()) { + lineStarts.add(s.length()); + contents.add(""); + lineNumbers.add(lineNumber.intValue()); + } + // Mark up the full text at once, so that constructs such as block comments can span multiple lines + this.layout = new TextLayout(s, lineStarts.toIntArray(), contents.toArray(new String[0]), lineNumbers.toIntArray(), new Markup(this.markup(s))); + } + return this.layout; + } + + private List> markup(String text) { if (this.markupProvider != null) { - 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_) { @@ -1104,29 +1162,64 @@ public int findLineEnd(int p_98219_) { } } + /** + * The wrapped lines of a text, independent of the scroll and cursor position. + */ + @OnlyIn(Dist.CLIENT) + static class TextLayout { + final String text; + final int[] lineStarts; + final String[] contents; + final int[] lineNumbers; + final Markup markup; + final Component[] components; + + TextLayout(String text, int[] lineStarts, String[] contents, int[] lineNumbers, Markup markup) { + this.text = text; + this.lineStarts = lineStarts; + this.contents = contents; + this.lineNumbers = lineNumbers; + this.markup = markup; + this.components = new Component[lineStarts.length]; + } + + /** + * Lazily create the styled component of a line, as only visible lines are rendered. + */ + Component getComponent(int line) { + if (this.components[line] == null) { + MutableComponent component = Component.literal(""); + for (Pair value : this.markup.slice(this.text, this.lineStarts[line], this.lineStarts[line] + this.contents[line].length())) { + component = component.append(Component.literal(value.getRight()).setStyle(value.getLeft())); + } + this.components[line] = component; + } + return this.components[line]; + } + } + @OnlyIn(Dist.CLIENT) static class LineInfo { - final List> contentsStyled; + private final TextLayout layout; + private final int line; final String contents; - final Component asComponent; final int x; final int y; final int lineNumber; final boolean hasCursor; - public LineInfo(List> contentsStyled, int x, int y, int lineNumber, boolean hasCursor) { - this.contentsStyled = contentsStyled; - this.contents = contentsStyled.stream().map(Pair::getRight).collect(Collectors.joining()); + public LineInfo(TextLayout layout, int line, int x, int y, boolean hasCursor) { + this.layout = layout; + this.line = line; + this.contents = layout.contents[line]; this.x = x; this.y = y; - this.lineNumber = lineNumber; + this.lineNumber = layout.lineNumbers[line]; this.hasCursor = hasCursor; + } - MutableComponent component = Component.literal(""); - for (Pair value : contentsStyled) { - component = component.append(Component.literal(value.getRight()).setStyle(value.getLeft())); - } - this.asComponent = component; + public Component getComponent() { + return this.layout.getComponent(this.line); } } @@ -1142,7 +1235,11 @@ static class Pos2i { } public static interface IMarkupProvider { - public List> markupLine(Style style, String line); + /** + * @param text The full text. + * @return Styled segments that together form the full text. + */ + public List> markup(String text); } public static interface ShortcutListener { diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java index 37c568c44..a26f7264b 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/container/ContainerScreenTerminalScripting.java @@ -13,6 +13,7 @@ import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.resources.sounds.SimpleSoundInstance; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvents; import net.minecraft.util.StringUtil; @@ -159,10 +160,10 @@ public int getTotalRows() { }); textArea.setListenerSelection(this::onSelectionModified); textArea.setListenerCursor(this::onSelectionModified); - textArea.setMarkupProvider((style, line) -> { + textArea.setMarkupProvider(text -> { Path path = getMenu().getActiveScriptPath(); ILanguageHandler languageHandler = path != null ? LanguageHandlers.REGISTRY.getProvider(path) : null; - return languageHandler != null ? languageHandler.markupLine(line) : Stream.of(Pair.of(style, line)).collect(Collectors.toList()); + return languageHandler != null ? languageHandler.markupScript(text) : Stream.of(Pair.of(Style.EMPTY, text)).collect(Collectors.toList()); }); addRenderableWidget(textArea); diff --git a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java index 26777eb70..90ffcab31 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -42,6 +42,7 @@ public class LanguageHandlerJavaScript implements ILanguageHandler { public static final Style SYMBOL = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(99, 163, 92))); // Green public static final Style CONSTANT = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(0, 134, 179))); // Blue public static final Style KEYWORD = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(167, 29, 93))); // Red-purple + public static final Style STRING = Style.EMPTY.withColor(TextColor.fromRgb(Helpers.RGBToInt(24, 54, 145))); // Dark blue public final Map tokenStyles; private final IScriptFactory scriptFactory; @@ -49,14 +50,17 @@ public class LanguageHandlerJavaScript implements ILanguageHandler { public LanguageHandlerJavaScript() { this.tokenStyles = Maps.newHashMap(); - this.tokenStyles.put("const", KEYWORD); - this.tokenStyles.put("delete", KEYWORD); - this.tokenStyles.put("let", KEYWORD); - this.tokenStyles.put("var", KEYWORD); - this.tokenStyles.put("function", KEYWORD); - this.tokenStyles.put("return", KEYWORD); - this.tokenStyles.put("true", KEYWORD); - this.tokenStyles.put("false", KEYWORD); + for (String keyword : new String[]{ + "const", "delete", "let", "var", "function", "return", "if", "else", "for", "while", "do", "break", + "continue", "switch", "case", "default", "new", "this", "typeof", "instanceof", "in", "of", "class", + "extends", "super", "import", "export", "from", "try", "catch", "finally", "throw", "async", "await", + "yield", "void" + }) { + this.tokenStyles.put(keyword, KEYWORD); + } + for (String constant : new String[]{"true", "false", "null", "undefined"}) { + this.tokenStyles.put(constant, CONSTANT); + } this.tokenStyles.put("{", SYMBOL); this.tokenStyles.put("}", SYMBOL); @@ -88,36 +92,68 @@ public List getExtensions() { @Override public List> markupLine(String line) { - // TODO: in the future, write/use JS lexer/tokenizer + return this.markupScript(line); + } + @Override + public List> markupScript(String script) { List> segments = Lists.newArrayList(); - segments.add(Pair.of(Style.EMPTY, line)); - - // Split up string by tokens - for (Map.Entry entry : this.tokenStyles.entrySet()) { - String token = entry.getKey(); - Style tokenStyle = entry.getValue(); - List> segmentsNew = Lists.newArrayList(); - - for (Pair segment : segments) { - Style styleOriginal = segment.getLeft(); - String hayStack = segment.getRight(); - int pos; - while ((pos = hayStack.indexOf(token)) >= 0) { - segmentsNew.add(Pair.of(styleOriginal, hayStack.substring(0, pos))); - segmentsNew.add(Pair.of(tokenStyle, hayStack.substring(pos, pos + token.length()))); - hayStack = hayStack.substring(pos + token.length()); + int length = script.length(); + int i = 0; + while (i < length) { + char c = script.charAt(i); + int end; + Style style; + if (c == '/' && i + 1 < length && script.charAt(i + 1) == '/') { + end = script.indexOf('\n', i); + end = end < 0 ? length : end; + style = COMMENT; + } else if (c == '/' && i + 1 < length && script.charAt(i + 1) == '*') { + end = script.indexOf("*/", i + 2); + end = end < 0 ? length : end + 2; + style = COMMENT; + } else if (c == '"' || c == '\'' || c == '`') { + end = i + 1; + while (end < length && script.charAt(end) != c && (c == '`' || script.charAt(end) != '\n')) { + end += script.charAt(end) == '\\' ? 2 : 1; } - - segmentsNew.add(Pair.of(styleOriginal, hayStack)); + if (end < length && script.charAt(end) == c) { + end++; + } + end = Math.min(length, end); + style = STRING; + } else if (Character.isDigit(c)) { + end = i + 1; + while (end < length && (Character.isLetterOrDigit(script.charAt(end)) || script.charAt(end) == '.' || script.charAt(end) == '_')) { + end++; + } + style = CONSTANT; + } else if (Character.isJavaIdentifierStart(c)) { + end = i + 1; + while (end < length && Character.isJavaIdentifierPart(script.charAt(end))) { + end++; + } + style = this.tokenStyles.getOrDefault(script.substring(i, end), Style.EMPTY); + } else { + end = i + 1; + style = this.tokenStyles.getOrDefault(String.valueOf(c), Style.EMPTY); } - - segments = segmentsNew; + addSegment(segments, style, script.substring(i, end)); + i = end; } - return segments; } + private static void addSegment(List> segments, Style style, String text) { + // Merge with the previous segment if it has the same style + if (!segments.isEmpty() && segments.get(segments.size() - 1).getLeft().equals(style)) { + Pair last = segments.remove(segments.size() - 1); + segments.add(Pair.of(style, last.getRight() + text)); + } else { + segments.add(Pair.of(style, text)); + } + } + @Override public IScriptFactory getScriptFactory() throws EvaluationException { return this.scriptFactory; diff --git a/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java new file mode 100644 index 000000000..20e782f9b --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java @@ -0,0 +1,97 @@ +package org.cyclops.integratedscripting.core.language; + +import net.minecraft.network.chat.Style; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.Test; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +public class LanguageHandlerJavaScriptMarkupTest { + + private static final LanguageHandlerJavaScript HANDLER = new LanguageHandlerJavaScript(); + + private static List> markup(String script) { + List> segments = HANDLER.markupScript(script); + assertThat(segments.stream().map(Pair::getRight).collect(Collectors.joining()), equalTo(script)); + return segments; + } + + private static Style styleOf(String script, String token) { + int pos = script.indexOf(token); + int i = 0; + for (Pair segment : markup(script)) { + if (pos < i + segment.getRight().length()) { + return segment.getLeft(); + } + i += segment.getRight().length(); + } + throw new AssertionError("Token not found"); + } + + @Test + public void testKeyword() { + assertThat(styleOf("const a = 1;", "const"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testKeywordInsideIdentifier() { + assertThat(styleOf("const variable = 1;", "variable"), equalTo(Style.EMPTY)); + assertThat(styleOf("let outlet = 1;", "outlet"), equalTo(Style.EMPTY)); + assertThat(styleOf("returnValue();", "returnValue"), equalTo(Style.EMPTY)); + } + + @Test + public void testSymbol() { + assertThat(styleOf("f();", "("), equalTo(LanguageHandlerJavaScript.SYMBOL)); + } + + @Test + public void testNumber() { + assertThat(styleOf("x = 1.5;", "1.5"), equalTo(LanguageHandlerJavaScript.CONSTANT)); + assertThat(markup("x = 1.5;").stream().anyMatch(s -> s.getRight().equals("1.5")), equalTo(true)); + } + + @Test + public void testConstant() { + assertThat(styleOf("return true;", "true"), equalTo(LanguageHandlerJavaScript.CONSTANT)); + } + + @Test + public void testStrings() { + assertThat(styleOf("x = \"let it be\";", "let"), equalTo(LanguageHandlerJavaScript.STRING)); + assertThat(styleOf("x = 'delete';", "delete"), equalTo(LanguageHandlerJavaScript.STRING)); + assertThat(styleOf("x = `a ${b}`;", "b"), equalTo(LanguageHandlerJavaScript.STRING)); + } + + @Test + public void testEscapedQuote() { + assertThat(styleOf("x = 'it\\'s var';", "var"), equalTo(LanguageHandlerJavaScript.STRING)); + } + + @Test + public void testUnterminatedStringEndsAtLine() { + assertThat(styleOf("x = 'abc\nconst y;", "const"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testLineComment() { + assertThat(styleOf("// const a\nconst b;", "const a"), equalTo(LanguageHandlerJavaScript.COMMENT)); + assertThat(styleOf("// const a\nconst b;", "const b"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testBlockCommentSpansLines() { + assertThat(styleOf("/* a\nreturn b */ return", "return b"), equalTo(LanguageHandlerJavaScript.COMMENT)); + assertThat(styleOf("/* a\nreturn b */ let", "let"), equalTo(LanguageHandlerJavaScript.KEYWORD)); + } + + @Test + public void testMarkupLineCompatible() { + assertThat(HANDLER.markupLine("const a;"), equalTo(HANDLER.markupScript("const a;"))); + } + +}