Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7e4d4fb
Indent with tab in the script editor
claude Oct 17, 2026
96ba268
Keep indentation on enter in the script editor
claude Oct 17, 2026
80d4e11
Highlight matching brackets in the script editor
claude Oct 17, 2026
4e22fbf
Add find and go to line to the script editor
claude Oct 17, 2026
ee6cde5
Add toggle comment and move lines to the script editor
claude Oct 17, 2026
a403a44
Scroll the script editor while drag-selecting
claude Oct 17, 2026
eca8932
Merge branch 'terminal-ux/2-undo-and-sync' into terminal-ux/3-editing…
claude Oct 17, 2026
6b54d23
Merge branch 'terminal-ux/2-undo-and-sync' into terminal-ux/3-editing…
claude Oct 17, 2026
9bbbbc3
Close the find field when the scripting terminal is resized
claude Oct 17, 2026
ddd9201
Make the indent size of the script editor configurable
claude Oct 18, 2026
c261319
Merge branch 'terminal-ux/2-undo-and-sync' into terminal-ux/3-editing…
claude Oct 18, 2026
cde5145
Merge branch 'terminal-ux/2-undo-and-sync' into terminal-ux/3-editing…
claude Oct 18, 2026
4c3a007
Show find and go to line in a search bar below the editor
claude Oct 18, 2026
e2f1689
Add toolbar buttons for editor actions
claude Oct 18, 2026
76536ca
Don't let a long terminal title overlap the toolbar
claude Oct 18, 2026
e29ac0d
Remove the keyboard shortcuts button from the editor toolbar
claude Oct 18, 2026
36ddfdc
Make the script editor shortcuts rebindable
claude Oct 18, 2026
6e97fb4
Document the script editor shortcuts in the info book
claude Oct 18, 2026
1882232
Use an ellipsis character when truncating the terminal title
claude Oct 18, 2026
84661a9
Keep scrolling while a drag selection is held outside the editor
claude Oct 18, 2026
b664859
Merge branch 'terminal-ux/2-undo-and-sync' into terminal-ux/3-editing…
claude Oct 18, 2026
f32517d
Document word and line navigation keys in the info book
claude Oct 18, 2026
8a3472d
Merge branch 'terminal-ux/2-undo-and-sync' into terminal-ux/3-editing…
claude Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class GeneralConfig extends DummyConfig {
public static int terminalScriptingBaseConsumption = 1;
@ConfigurableProperty(category = "general", comment = "The minimum number of ticks inbetween sending a script change packet from client to server.", minimalValue = 0, configLocation = ModConfig.Type.SERVER)
public static int terminalScriptingClientSyncTickInterval = 20;
@ConfigurableProperty(category = "general", comment = "The number of spaces that tab inserts in the scripting terminal editor.", minimalValue = 1, maximalValue = 16, configLocation = ModConfig.Type.CLIENT)
public static int terminalScriptingIndentSize = 2;

@ConfigurableProperty(category = "general", comment = "If new processes can be created from guest languages. Only enable this on private servers and if you know what you are doing.", configLocation = ModConfig.Type.SERVER)
public static boolean graalAllowCreateProcess = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.cyclops.integratedscripting.client.gui.component.button;

import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import org.cyclops.cyclopscore.client.gui.component.button.ButtonExtended;
import org.cyclops.cyclopscore.client.gui.image.IImage;

/**
* A button with a centered icon, which is dimmed when the button is inactive.
* @author rubensworks
*/
public class ButtonIcon extends ButtonExtended {

private final IImage image;

public ButtonIcon(int x, int y, int width, int height, Component narrationMessage, OnPress onPress, IImage image) {
super(x, y, width, height, narrationMessage, onPress, true);
this.image = image;
}

@Override
protected void drawButtonInner(GuiGraphics guiGraphics, int mouseX, int mouseY) {
float color = this.active ? 1F : 0.4F;
this.image.drawWithColor(guiGraphics, this.getX() + (this.width - this.image.getWidth()) / 2,
this.getY() + (this.height - this.image.getHeight()) / 2, color, color, color, 1F);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package org.cyclops.integratedscripting.client.gui.component.input;

/**
* Code editing operations on the state of a text area.
* @author rubensworks
*/
public class TextEditing {


private static int start(TextHistory.State state) {
return Math.min(state.cursorPos(), state.selectionPos());
}

private static int end(TextHistory.State state) {
return Math.max(state.cursorPos(), state.selectionPos());
}

/**
* @return The end of the selection, excluding a line on which the selection ends at the start.
*/
private static int selectionEnd(TextHistory.State state) {
int end = end(state);
return end > start(state) && state.value().charAt(end - 1) == '\n' ? end - 1 : end;
}

public static int lineStart(String value, int pos) {
return value.lastIndexOf('\n', pos - 1) + 1;
}

public static int lineEnd(String value, int pos) {
int i = value.indexOf('\n', pos);
return i < 0 ? value.length() : i;
}

private static String leadingWhitespace(String value, int lineStart) {
int i = lineStart;
while (i < value.length() && (value.charAt(i) == ' ' || value.charAt(i) == '\t')) {
i++;
}
return value.substring(lineStart, i);
}

private static boolean isMultiLineSelection(TextHistory.State state) {
return state.value().substring(start(state), end(state)).indexOf('\n') >= 0;
}

/**
* Indent the selected lines, or insert an indent at the cursor if no lines are selected.
* @param indent The string of one indentation level.
*/
public static TextHistory.State indent(TextHistory.State state, String indent) {
String value = state.value();
if (!isMultiLineSelection(state)) {
int start = start(state);
String newValue = value.substring(0, start) + indent + value.substring(end(state));
return new TextHistory.State(newValue, start + indent.length(), start + indent.length());
}
return mapLines(state, line -> indent + line);
}

/**
* Remove one indent from the start of the lines touched by the cursor or selection.
* @param indent The string of one indentation level.
*/
public static TextHistory.State unindent(TextHistory.State state, String indent) {
return mapLines(state, line -> {
int remove = 0;
while (remove < indent.length() && remove < line.length() && line.charAt(remove) == ' ') {
remove++;
}
if (remove == 0 && !line.isEmpty() && line.charAt(0) == '\t') {
remove = 1;
}
return line.substring(remove);
});
}

/**
* Apply a function to each line touched by the cursor or selection,
* keeping the cursor and selection on the same characters.
*/
private static TextHistory.State mapLines(TextHistory.State state, java.util.function.UnaryOperator<String> 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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ public State redo(State current) {
return this.redoStack.pop();
}

public boolean canUndo() {
return !this.undoStack.isEmpty();
}

public boolean canRedo() {
return !this.redoStack.isEmpty();
}

public static record State(String value, int cursorPos, int selectionPos) {}

}
Loading
Loading