diff --git a/.gitignore b/.gitignore index ed5313ce9..e6ff7e5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ changelog.txt # Ignore Minecraft runtime logs /logs + +# ClientDevBridge session state (golden images are intentionally kept) +.clientdevbridge/* +!.clientdevbridge/golden/ 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/WidgetTextArea.java b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java index 5359a1b0f..6cf30e0d7 100644 --- a/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java +++ b/src/main/java/org/cyclops/integratedscripting/client/gui/component/input/WidgetTextArea.java @@ -39,6 +39,7 @@ import org.cyclops.cyclopscore.client.gui.component.input.IInputListener; import org.cyclops.cyclopscore.helper.Helpers; import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.lwjgl.glfw.GLFW; import javax.annotation.Nullable; import java.util.Arrays; @@ -65,6 +66,7 @@ public class WidgetTextArea extends EditBox implements GuiEventListener { public static final int ROW_HEIGHT = 9; + public static final int SCROLLBAR_WIDTH = 14; private final TextFieldHelperExtended textFieldHelper; private final Font font; @@ -96,7 +98,7 @@ 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, + this.scrollBar = new WidgetScrollBar(x + width - SCROLLBAR_WIDTH, y, height, Component.translatable("gui.cyclopscore.scrollbar"), firstRow -> setFirstRow(firstRow, false), height / ROW_HEIGHT) { @Override public int getTotalRows() { @@ -155,6 +157,7 @@ public void setMarkupProvider(@Nullable IMarkupProvider markupProvider) { } public void setValue(String value) { + this.firstRow = 0; this.setValuePassive(value); textFieldHelper.setCursorToStart(); @@ -165,7 +168,6 @@ public void setValue(String value) { } public void setValuePassive(String value) { - this.firstRow = 0; this.value = value; this.clearDisplayCache(); @@ -244,7 +246,7 @@ public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { this.setFocused(true); long i = Util.getMillis(); DisplayCache displayCache = this.getDisplayCache(); - int j = displayCache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, (int)mouseY))); + 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()) { @@ -298,12 +300,14 @@ public boolean mouseDragged(double mouseX, double mouseY, int mouseButton, doubl if (mouseButton == 0 && mouseX >= this.getX() && mouseX < this.getX() + this.width && mouseY >= this.getY() && mouseY < this.getY() + this.height) { DisplayCache bookeditscreen$displaycache = this.getDisplayCache(); - int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, (int)mouseY))); + int i = bookeditscreen$displaycache.getIndexAtPosition(this.font, this.convertScreenToLocal(new Pos2i((int)mouseX, this.clampToVisibleRows(mouseY)))); this.textFieldHelper.setCursorPos(i, true); this.clearDisplayCache(); this.setFocused(true); + return true; } - return true; + // Let other drags through, such as spreading items over inventory slots + return false; } @Override @@ -312,25 +316,27 @@ public boolean keyPressed(int typedChar, int keyCode, int modifiers) { if (flag) { 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(char typedChar, int keyCode) { - if (super.charTyped(typedChar, keyCode)) { - this.setFocused(true); - return true; - } else if (StringUtil.isAllowedChatCharacter(typedChar)) { + // Don't delegate to EditBox, as that would consume the character into its own (unused) value + if (StringUtil.isAllowedChatCharacter(typedChar)) { this.textFieldHelper.insertText(Character.toString(typedChar)); this.clearDisplayCache(); this.setFocused(true); + this.ensureCursorVisible(); return true; } else { - this.setFocused(false); return false; } } @@ -349,7 +355,8 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { this.textFieldHelper.cut(); return true; } else { - TextFieldHelper.CursorStep cursorStep = Screen.hasControlDown() ? TextFieldHelper.CursorStep.WORD : TextFieldHelper.CursorStep.CHARACTER; + TextFieldHelper.CursorStep cursorStep = EditorKeys.isWordModifier(p_98155_, Minecraft.ON_OSX) ? TextFieldHelper.CursorStep.WORD : TextFieldHelper.CursorStep.CHARACTER; + boolean lineModifier = EditorKeys.isLineModifier(p_98155_, Minecraft.ON_OSX); switch (p_98153_) { case 257: case 335: @@ -362,28 +369,44 @@ private boolean textFieldKeyPressed(int p_98153_, int p_98154_, int p_98155_) { this.textFieldHelper.removeFromCursor(1, cursorStep); return true; case 262: - this.textFieldHelper.moveBy(1, Screen.hasShiftDown(), cursorStep); + if (lineModifier) { + this.keyEnd(false); + } else { + this.textFieldHelper.moveBy(1, Screen.hasShiftDown(), cursorStep); + } return true; case 263: - this.textFieldHelper.moveBy(-1, Screen.hasShiftDown(), cursorStep); + if (lineModifier) { + this.keyHome(false); + } else { + this.textFieldHelper.moveBy(-1, Screen.hasShiftDown(), cursorStep); + } return true; case 264: - this.keyDown(); + if (lineModifier) { + this.keyEnd(true); + } else { + this.keyDown(); + } return true; case 265: - this.keyUp(); + if (lineModifier) { + this.keyHome(true); + } else { + this.keyUp(); + } return true; case 266: -// this.backButton.onPress(); + this.keyPage(-1); return true; case 267: -// this.forwardButton.onPress(); + this.keyPage(1); return true; case 268: - this.keyHome(); + this.keyHome(Screen.hasControlDown()); return true; case 269: - this.keyEnd(); + this.keyEnd(Screen.hasControlDown()); return true; default: return false; @@ -404,22 +427,39 @@ private void changeLine(int p_98098_) { DisplayCache displayCache = this.getDisplayCache(); int j = displayCache.changeLine(cursorPos, p_98098_); this.textFieldHelper.setCursorPos(j, Screen.hasShiftDown()); + } - // Modify scroll position when cursor goes out of screen + /** + * @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 keyPage(int direction) { + int rows = this.scrollBar != null ? this.scrollBar.getVisibleRows() : this.getHeight() / ROW_HEIGHT; + this.changeLine(direction * rows); + this.setFirstRow(this.firstRow + direction * rows, true); + } + + /** + * 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() { - if (Screen.hasControlDown()) { + private void keyHome(boolean document) { + if (document) { this.textFieldHelper.setCursorToStart(Screen.hasShiftDown()); } else { int i = this.textFieldHelper.getCursorPos(); @@ -429,8 +469,8 @@ private void keyHome() { } - private void keyEnd() { - if (Screen.hasControlDown()) { + private void keyEnd(boolean document) { + if (document) { this.textFieldHelper.setCursorToEnd(Screen.hasShiftDown()); } else { DisplayCache displayCache = this.getDisplayCache(); @@ -456,7 +496,7 @@ public void renderWidget(GuiGraphics guiGraphics, int mouseX, int mouseY, float RenderHelpers.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, @@ -520,6 +560,11 @@ private void renderHighlight(Rect2i[] p_98139_) { RenderSystem.setShaderColor(1, 1, 1, 1); } + 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; } @@ -552,25 +597,46 @@ private DisplayCache rebuildDisplayCache() { int cursorPos = this.textFieldHelper.getCursorPos(); int selectionPos = this.textFieldHelper.getSelectionPos(); IntList linePositionsOld = new IntArrayList(); - List linesAll = Lists.newArrayList(); - MutableInt mutableint = new MutableInt(); + 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, this.getWidth() - getLinesXOffset() /* changed */, Style.EMPTY, true, (style, startPos, endPos) -> { - int k3 = mutableint.getAndIncrement(); + 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); - 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)))); + 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()); + } + + // 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()); + if (this.firstRow > maxFirstRow) { + this.firstRow = maxFirstRow; + this.scrollBar.setFirstRow(this.firstRow, false); + } + } + + 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))); + } // --- Changed --- // Slice lines based on scroll position @@ -583,16 +649,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; } @@ -620,11 +681,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); } } @@ -702,17 +763,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_) { 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 c13eeca3d..21f0c76d8 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 @@ -96,6 +96,12 @@ public ContainerScreenTerminalScripting(ContainerTerminalScripting container, In @Override public void init() { + // This is called again when the screen is resized, which recreates all widgets + if (textArea != null) { + this.saveCursorPos(); + } + pendingScriptRemovalDialog = null; + super.init(); fieldDisk = new WidgetArrowedListField<>(Minecraft.getInstance().font, leftPos + 30, @@ -132,6 +138,13 @@ public int getTotalRows() { buttonCreateFile = new ButtonText(this.leftPos + 19, this.topPos + 222, 56, 10, Component.translatable("gui.integratedscripting.create_file"), Component.literal("+"), (button) -> getMenu().createNewFile(), true); addRenderableWidget(buttonCreateFile); + + // Restore the open script in the new editor + if (getMenu().getActiveScriptPath() != null) { + this.onActiveScriptSelected(); + this.restoreCursorPos(); + textArea.ensureCursorVisible(); + } } @Override diff --git a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java index 631133abf..ae34d68f2 100644 --- a/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java +++ b/src/main/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacket.java @@ -42,7 +42,7 @@ public TerminalScriptingModifiedScriptPacket(int disk, Path path, String script) super(ID); this.disk = disk; this.path = path.toString(); - this.script = Arrays.stream(script.split("\n")).toList(); + this.script = Arrays.stream(script.split("\n", -1)).toList(); } public String getScript() { diff --git a/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java new file mode 100644 index 000000000..8b10b7590 --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/client/gui/component/input/EditorKeysTest.java @@ -0,0 +1,40 @@ +package org.cyclops.integratedscripting.client.gui.component.input; + +import org.junit.Test; +import org.lwjgl.glfw.GLFW; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author rubensworks + */ +public class EditorKeysTest { + + @Test + public void testWordModifierMac() { + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_ALT, true)); + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_ALT | GLFW.GLFW_MOD_SHIFT, true)); + assertFalse(EditorKeys.isWordModifier(GLFW.GLFW_MOD_SUPER, true)); + assertFalse(EditorKeys.isWordModifier(GLFW.GLFW_MOD_CONTROL, true)); + assertFalse(EditorKeys.isWordModifier(0, true)); + } + + @Test + public void testWordModifierOthers() { + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_CONTROL, false)); + assertTrue(EditorKeys.isWordModifier(GLFW.GLFW_MOD_CONTROL | GLFW.GLFW_MOD_SHIFT, false)); + assertFalse(EditorKeys.isWordModifier(GLFW.GLFW_MOD_ALT, false)); + assertFalse(EditorKeys.isWordModifier(0, false)); + } + + @Test + public void testLineModifier() { + assertTrue(EditorKeys.isLineModifier(GLFW.GLFW_MOD_SUPER, true)); + assertTrue(EditorKeys.isLineModifier(GLFW.GLFW_MOD_SUPER | GLFW.GLFW_MOD_SHIFT, true)); + assertFalse(EditorKeys.isLineModifier(GLFW.GLFW_MOD_ALT, true)); + assertFalse(EditorKeys.isLineModifier(GLFW.GLFW_MOD_SUPER, false)); + assertFalse(EditorKeys.isLineModifier(GLFW.GLFW_MOD_CONTROL, false)); + } + +} diff --git a/src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java b/src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java new file mode 100644 index 000000000..0f78a812e --- /dev/null +++ b/src/test/java/org/cyclops/integratedscripting/network/packet/TerminalScriptingModifiedScriptPacketTest.java @@ -0,0 +1,41 @@ +package org.cyclops.integratedscripting.network.packet; + +import org.junit.Test; + +import java.nio.file.Path; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +public class TerminalScriptingModifiedScriptPacketTest { + + private static String roundTrip(String script) { + return new TerminalScriptingModifiedScriptPacket(0, Path.of("script0.js"), script).getScript(); + } + + @Test + public void testSingleLine() { + assertThat(roundTrip("const a = 1;"), equalTo("const a = 1;")); + } + + @Test + public void testEmpty() { + assertThat(roundTrip(""), equalTo("")); + } + + @Test + public void testTrailingNewlines() { + assertThat(roundTrip("const a = 1;\n\n"), equalTo("const a = 1;\n\n")); + } + + @Test + public void testOnlyNewlines() { + assertThat(roundTrip("\n\n\n"), equalTo("\n\n\n")); + } + + @Test + public void testLeadingAndInnerNewlines() { + assertThat(roundTrip("\na\n\nb"), equalTo("\na\n\nb")); + } + +}