From db8c8e65673283d3bcd20fbf724453c55d0f6fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:03:49 +0000 Subject: [PATCH 1/2] Improve syntax highlighting in the script editor Keywords were highlighted by substring, so they also matched inside other words (var in variable, return in returnValue), strings and comments, while strings, numbers and comments were not highlighted at all. Highlighting was also done per wrapped line, so it couldn't handle constructs spanning multiple lines. Tokenize the full script instead, with word boundaries, strings, numbers, and line and block comments, and add a markupScript method to language handlers for this. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../api/language/ILanguageHandler.java | 18 ++++ .../gui/component/input/WidgetTextArea.java | 58 ++++++++++- .../ContainerScreenTerminalScripting.java | 5 +- .../language/LanguageHandlerJavaScript.java | 96 ++++++++++++------ .../LanguageHandlerJavaScriptMarkupTest.java | 97 +++++++++++++++++++ 5 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 src/test/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScriptMarkupTest.java 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 8b47c9c7c..dad294ad5 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.Collections; import java.util.List; import java.util.Set; @@ -29,6 +30,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..91540415b 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 @@ -956,10 +956,13 @@ private DisplayCache rebuildDisplayCache() { } } + // Mark up the full text at once, so that constructs such as block comments can span multiple lines + Markup markup = new Markup(this.markup(s)); List linesAll = Lists.newArrayList(); for (int k3 = 0; k3 < linesRaw.size(); 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))); + int lineStartPos = linePositionsOld.getInt(k3); + linesAll.add(new LineInfo(markup.slice(s, lineStartPos, lineStartPos + linesRaw.get(k3).getLeft().length()), bookeditscreen$pos2i1.x, bookeditscreen$pos2i1.y, lineNumbers.getInt(k3), linesHasCursor.get(k3))); } // --- Changed --- @@ -1018,11 +1021,52 @@ private DisplayCache rebuildDisplayCache() { } } - private List> markupLine(Style style, String line) { + 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_) { @@ -1142,7 +1186,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 a5fcd0cdf..82603fa4c 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; @@ -158,10 +159,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 0d1e4e166..8c4c09c84 100644 --- a/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java +++ b/src/main/java/org/cyclops/integratedscripting/core/language/LanguageHandlerJavaScript.java @@ -43,6 +43,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 // Keywords and literals, which can never be the name of a member in a (non-strict) script public static final Set RESERVED_WORDS = Set.of( @@ -58,14 +59,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); @@ -97,36 +101,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 Set getReservedWords() { return RESERVED_WORDS; 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;"))); + } + +} From f105ac8b97d0a8a34845549f1caeedeee89ed137 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:08:12 +0000 Subject: [PATCH 2/2] Only re-wrap and re-highlight the editor text when it changes The full script was wrapped and highlighted again on every key press, including cursor movement, scrolling and selection changes, which took 9 ms for a 500-line script and 41 ms for a 2000-line script. Cache the text layout until the text changes, and only build the styled text of visible lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018MRyTdV3fSV5Q5NPGep4q9 --- .../gui/component/input/WidgetTextArea.java | 139 ++++++++++++------ 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 91540415b..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,50 +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); } } - // Mark up the full text at once, so that constructs such as block comments can span multiple lines - Markup markup = new Markup(this.markup(s)); + 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! - int lineStartPos = linePositionsOld.getInt(k3); - linesAll.add(new LineInfo(markup.slice(s, lineStartPos, lineStartPos + linesRaw.get(k3).getLeft().length()), 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 @@ -1021,6 +1002,39 @@ private DisplayCache rebuildDisplayCache() { } } + /** + * @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) { List> segments = this.markupProvider.markup(text); @@ -1148,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); } }