From 9226c1cd8749a6ce130cb2790572d1f4d874a399 Mon Sep 17 00:00:00 2001 From: Aran Roig Date: Fri, 31 Jul 2026 14:42:27 +0200 Subject: [PATCH] Jkdsjk --- .../client/editor/CompletionItem.java | 38 ++ .../client/editor/CompletionProvider.java | 336 ++++++++++++++ .../client/editor/EditorFileTree.java | 20 +- .../client/editor/EditorFileView.java | 434 +++++++++++++++--- .../aranroig/client/editor/EditorManager.java | 40 +- .../client/editor/LuaHighlighter.java | 267 +++++++++++ src/main/java/com/aranroig/Codecraft.java | 51 +- .../java/com/aranroig/SavedProjectData.java | 65 +++ .../java/com/aranroig/editor/EditorFile.java | 11 +- .../java/com/aranroig/editor/Project.java | 9 + .../java/com/aranroig/editor/RunInfo.java | 30 ++ .../payloads/ClientboundSaveCodePayload.java | 2 +- .../payloads/ServerboundRunCodePayload.java | 24 + .../payloads/ServerboundSaveCodePayload.java | 2 +- 14 files changed, 1237 insertions(+), 92 deletions(-) create mode 100644 src/client/java/com/aranroig/client/editor/CompletionItem.java create mode 100644 src/client/java/com/aranroig/client/editor/CompletionProvider.java create mode 100644 src/client/java/com/aranroig/client/editor/LuaHighlighter.java create mode 100644 src/main/java/com/aranroig/SavedProjectData.java create mode 100644 src/main/java/com/aranroig/editor/Project.java create mode 100644 src/main/java/com/aranroig/editor/RunInfo.java create mode 100644 src/main/java/com/aranroig/payloads/ServerboundRunCodePayload.java diff --git a/src/client/java/com/aranroig/client/editor/CompletionItem.java b/src/client/java/com/aranroig/client/editor/CompletionItem.java new file mode 100644 index 0000000..6a05baf --- /dev/null +++ b/src/client/java/com/aranroig/client/editor/CompletionItem.java @@ -0,0 +1,38 @@ +package com.aranroig.client.editor; + +/** + * A single autocomplete suggestion. + * + * @param label The text shown in the dropdown (e.g. "System.out.println"). + * @param insert The text actually inserted when accepted. Usually equals label, + * but can differ (e.g. snippets with a trailing "(" etc.). + * @param detail Short right-aligned annotation shown in the dropdown (e.g. "method", "keyword"). + * @param kind Category used for the icon/colour badge. + */ +public record CompletionItem(String label, String insert, String detail, Kind kind) { + + public enum Kind { + KEYWORD, // language keywords + TYPE, // class / interface / enum names + METHOD, // method / function + FIELD, // field / variable + SNIPPET, // multi-character template + } + + /** Convenience constructor when label == insert and no detail is needed. */ + public static CompletionItem keyword(String word) { + return new CompletionItem(word, word, "keyword", Kind.KEYWORD); + } + + public static CompletionItem type(String name) { + return new CompletionItem(name, name, "type", Kind.TYPE); + } + + public static CompletionItem method(String label, String insert) { + return new CompletionItem(label, insert, "method", Kind.METHOD); + } + + public static CompletionItem snippet(String label, String insert, String detail) { + return new CompletionItem(label, insert, detail, Kind.SNIPPET); + } +} \ No newline at end of file diff --git a/src/client/java/com/aranroig/client/editor/CompletionProvider.java b/src/client/java/com/aranroig/client/editor/CompletionProvider.java new file mode 100644 index 0000000..36ca99f --- /dev/null +++ b/src/client/java/com/aranroig/client/editor/CompletionProvider.java @@ -0,0 +1,336 @@ +package com.aranroig.client.editor; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Generates autocomplete suggestions for Lua code given the current text + * buffer and cursor position. The provider combines: + *
    + *
  1. A static list of Lua keywords, built-in globals, and standard library members.
  2. + *
  3. Words already present in the buffer (user-defined functions/variables).
  4. + *
  5. Library member completion after a "." or ":" trigger.
  6. + *
+ * + * None of this requires a real LSP – it is "good-enough" IDE-lite completion + * that dramatically improves the editing experience without external dependencies. + */ +public class CompletionProvider { + + // ── Static vocabulary ───────────────────────────────────────────────── + + private static final List STATIC_ITEMS; + + // Maps a library name (after the dot/colon) to its known members + private static final Map> LIBRARY_MEMBERS; + + static { + List items = new ArrayList<>(); + + // ── Lua keywords ───────────────────────────────────────────────── + for (String kw : new String[]{ + "and", "break", "do", "else", "elseif", "end", + "false", "for", "function", "goto", "if", "in", + "local", "nil", "not", "or", "repeat", "return", + "then", "true", "until", "while" + }) items.add(CompletionItem.keyword(kw)); + + // ── Built-in global functions ──────────────────────────────────── + for (String fn : new String[]{ + "assert", "collectgarbage", "dofile", "error", "getmetatable", + "ipairs", "load", "loadfile", "next", "pairs", + "pcall", "print", "rawequal", "rawget", "rawlen", "rawset", + "require", "select", "setmetatable", "tonumber", "tostring", + "type", "unpack", "xpcall" + }) items.add(CompletionItem.method(fn + "(", fn + "(")); + + // ── Built-in global tables (as types) ──────────────────────────── + for (String lib : new String[]{ + "string", "table", "math", "io", "os", "coroutine", + "package", "utf8", "debug", "_G", "_VERSION" + }) items.add(CompletionItem.type(lib)); + + // ── Common snippets ─────────────────────────────────────────────── + items.add(CompletionItem.snippet("fun", + "function ()\n \nend", + "anonymous function")); + items.add(CompletionItem.snippet("function", + "function name()\n \nend", + "named function")); + items.add(CompletionItem.snippet("lfun", + "local function name()\n \nend", + "local function")); + items.add(CompletionItem.snippet("if", + "if then\n \nend", + "if block")); + items.add(CompletionItem.snippet("ifel", + "if then\n \nelse\n \nend", + "if / else")); + items.add(CompletionItem.snippet("ifelseif", + "if then\n \nelseif then\n \nelse\n \nend", + "if / elseif / else")); + items.add(CompletionItem.snippet("for", + "for i = 1, do\n \nend", + "numeric for")); + items.add(CompletionItem.snippet("forin", + "for k, v in pairs() do\n \nend", + "generic for (pairs)")); + items.add(CompletionItem.snippet("fori", + "for i, v in ipairs() do\n \nend", + "generic for (ipairs)")); + items.add(CompletionItem.snippet("while", + "while do\n \nend", + "while loop")); + items.add(CompletionItem.snippet("repeat", + "repeat\n \nuntil ", + "repeat / until")); + items.add(CompletionItem.snippet("pcall", + "local ok, err = pcall(function()\n \nend)", + "protected call")); + items.add(CompletionItem.snippet("class", + "local ClassName = {}\nClassName.__index = ClassName\n\nfunction ClassName.new()\n local self = setmetatable({}, ClassName)\n return self\nend\n\nreturn ClassName", + "OOP class template")); + items.add(CompletionItem.snippet("require", + "local = require(\"\")", + "require module")); + items.add(CompletionItem.snippet("print", + "print()", + "print(…)")); + items.add(CompletionItem.snippet("local", + "local = ", + "local variable")); + items.add(CompletionItem.snippet("return", + "return ", + "return statement")); + + STATIC_ITEMS = Collections.unmodifiableList(items); + + // ── Library member tables ───────────────────────────────────────── + Map> libs = new LinkedHashMap<>(); + + // string.* + List stringLib = new ArrayList<>(); + for (String[] m : new String[][]{ + {"byte", "byte(", "string.byte(s [,i [,j]])"}, + {"char", "char(", "string.char(…)"}, + {"dump", "dump(", "string.dump(func)"}, + {"find", "find(", "string.find(s, pattern [,init])"}, + {"format", "format(", "string.format(fmt, …)"}, + {"gmatch", "gmatch(", "string.gmatch(s, pattern)"}, + {"gsub", "gsub(", "string.gsub(s, pattern, repl)"}, + {"len", "len(", "string.len(s)"}, + {"lower", "lower(", "string.lower(s)"}, + {"match", "match(", "string.match(s, pattern [,init])"}, + {"rep", "rep(", "string.rep(s, n [,sep])"}, + {"reverse", "reverse(", "string.reverse(s)"}, + {"sub", "sub(", "string.sub(s, i [,j])"}, + {"upper", "upper(", "string.upper(s)"}, + }) stringLib.add(CompletionItem.method(m[0] + "(", m[1])); + libs.put("string", stringLib); + + // table.* + List tableLib = new ArrayList<>(); + for (String[] m : new String[][]{ + {"concat", "concat(", "table.concat(t [,sep [,i [,j]]])"}, + {"insert", "insert(", "table.insert(t [,pos], value)"}, + {"move", "move(", "table.move(a1, f, e, t [,a2])"}, + {"pack", "pack(", "table.pack(…)"}, + {"remove", "remove(", "table.remove(t [,pos])"}, + {"sort", "sort(", "table.sort(t [,comp])"}, + {"unpack", "unpack(", "table.unpack(t [,i [,j]])"}, + }) tableLib.add(CompletionItem.method(m[0] + "(", m[1])); + libs.put("table", tableLib); + + // math.* + List mathLib = new ArrayList<>(); + for (String[] m : new String[][]{ + {"abs", "abs(", "math.abs(x)"}, + {"ceil", "ceil(", "math.ceil(x)"}, + {"cos", "cos(", "math.cos(x)"}, + {"exp", "exp(", "math.exp(x)"}, + {"floor", "floor(", "math.floor(x)"}, + {"fmod", "fmod(", "math.fmod(x, y)"}, + {"log", "log(", "math.log(x [,base])"}, + {"max", "max(", "math.max(x, …)"}, + {"min", "min(", "math.min(x, …)"}, + {"modf", "modf(", "math.modf(x)"}, + {"pow", "pow(", "math.pow(x, y)"}, + {"random", "random(", "math.random([m [,n]])"}, + {"randomseed", "randomseed(", "math.randomseed(x)"}, + {"sin", "sin(", "math.sin(x)"}, + {"sqrt", "sqrt(", "math.sqrt(x)"}, + {"tan", "tan(", "math.tan(x)"}, + {"tointeger", "tointeger(", "math.tointeger(x)"}, + {"type", "type(", "math.type(x)"}, + {"huge", "huge", "math.huge (∞)"}, + {"pi", "pi", "math.pi"}, + {"maxinteger", "maxinteger", "math.maxinteger"}, + {"mininteger", "mininteger", "math.mininteger"}, + }) mathLib.add(CompletionItem.method(m[0], m[1])); + libs.put("math", mathLib); + + // io.* + List ioLib = new ArrayList<>(); + for (String[] m : new String[][]{ + {"close", "close(", "io.close([file])"}, + {"flush", "flush()", "io.flush()"}, + {"input", "input(", "io.input([file])"}, + {"lines", "lines(", "io.lines([filename])"}, + {"open", "open(", "io.open(filename [,mode])"}, + {"output", "output(", "io.output([file])"}, + {"popen", "popen(", "io.popen(prog [,mode])"}, + {"read", "read(", "io.read(…)"}, + {"tmpfile", "tmpfile()", "io.tmpfile()"}, + {"type", "type(", "io.type(obj)"}, + {"write", "write(", "io.write(…)"}, + }) ioLib.add(CompletionItem.method(m[0], m[1])); + libs.put("io", ioLib); + + // os.* + List osLib = new ArrayList<>(); + for (String[] m : new String[][]{ + {"clock", "clock()", "os.clock()"}, + {"date", "date(", "os.date([format [,time]])"}, + {"difftime", "difftime(", "os.difftime(t2, t1)"}, + {"execute", "execute(", "os.execute([command])"}, + {"exit", "exit(", "os.exit([code])"}, + {"getenv", "getenv(", "os.getenv(varname)"}, + {"remove", "remove(", "os.remove(filename)"}, + {"rename", "rename(", "os.rename(oldname, newname)"}, + {"setlocale","setlocale(","os.setlocale(locale [,category])"}, + {"time", "time(", "os.time([table])"}, + {"tmpname", "tmpname()", "os.tmpname()"}, + }) osLib.add(CompletionItem.method(m[0], m[1])); + libs.put("os", osLib); + + // coroutine.* + List coLib = new ArrayList<>(); + for (String[] m : new String[][]{ + {"create", "create(", "coroutine.create(f)"}, + {"isyieldable", "isyieldable()", "coroutine.isyieldable()"}, + {"resume", "resume(", "coroutine.resume(co [,…])"}, + {"running", "running()", "coroutine.running()"}, + {"status", "status(", "coroutine.status(co)"}, + {"wrap", "wrap(", "coroutine.wrap(f)"}, + {"yield", "yield(", "coroutine.yield(…)"}, + }) coLib.add(CompletionItem.method(m[0], m[1])); + libs.put("coroutine", coLib); + + LIBRARY_MEMBERS = Collections.unmodifiableMap(libs); + } + + // Lua identifier chars (no $ unlike Java) + private static final Pattern WORD_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + + // ── Public API ───────────────────────────────────────────────────────── + + /** + * Returns up to {@code maxResults} completion items for the word currently + * being typed at {@code cursorPos} in {@code buffer}. + * + * @return empty list when no prefix can be extracted or nothing matches. + */ + public List getCompletions(String buffer, int cursorPos, int maxResults) { + String prefix = extractPrefix(buffer, cursorPos); + if (prefix.isEmpty()) return Collections.emptyList(); + + int prefixStart = cursorPos - prefix.length(); + String libraryName = resolveLibraryName(buffer, prefixStart); + boolean afterDotOrColon = (libraryName != null); + + List results = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + + if (afterDotOrColon && libraryName != null) { + // Member completion: only show items from the known library (if recognised) + List members = LIBRARY_MEMBERS.get(libraryName); + if (members != null) { + for (CompletionItem item : members) { + if (item.label().toLowerCase().startsWith(prefix.toLowerCase())) { + if (seen.add(item.label())) results.add(item); + } + } + } + // Also fall through to buffer words so unknown table members appear + Matcher m = WORD_PATTERN.matcher(buffer); + while (m.find()) { + String word = m.group(); + if (word.length() > 1 + && word.toLowerCase().startsWith(prefix.toLowerCase()) + && !word.equals(prefix) + && seen.add(word)) { + results.add(new CompletionItem(word, word, "identifier", + CompletionItem.Kind.FIELD)); + } + } + } else { + // Top-level completion: keywords + globals + snippets + for (CompletionItem item : STATIC_ITEMS) { + if (item.label().toLowerCase().startsWith(prefix.toLowerCase())) { + if (seen.add(item.label())) results.add(item); + } + } + + // Words from the buffer (user-defined names) + Matcher m = WORD_PATTERN.matcher(buffer); + while (m.find()) { + String word = m.group(); + if (word.length() > 2 + && word.toLowerCase().startsWith(prefix.toLowerCase()) + && !word.equals(prefix) + && seen.add(word)) { + results.add(new CompletionItem(word, word, "identifier", + CompletionItem.Kind.FIELD)); + } + } + } + + // Sort: exact case match first, then by kind, then alphabetical + results.sort(Comparator + .comparingInt((CompletionItem c) -> c.label().startsWith(prefix) ? 0 : 1) + .thenComparingInt(c -> c.kind().ordinal()) + .thenComparing(CompletionItem::label)); + + return results.subList(0, Math.min(results.size(), maxResults)); + } + + /** + * Extracts the Lua identifier fragment immediately to the left of the cursor. + * Only the segment after the last dot or colon is returned. + * E.g. "math.sq|rt" → "sq" + */ + public String extractPrefix(String buffer, int cursorPos) { + int end = Math.min(cursorPos, buffer.length()); + int start = end; + while (start > 0) { + char c = buffer.charAt(start - 1); + if (Character.isLetterOrDigit(c) || c == '_') start--; + else break; + } + return buffer.substring(start, end); + } + + /** + * If the token immediately before the prefix (skipping whitespace) is a '.' + * or ':', returns the identifier that precedes that separator — this is the + * library/table name used for member completion. + * Returns {@code null} when the cursor is not after a dot or colon. + * + * E.g. "math.sq" → "math", "str:fi" → "str", "print" → null + */ + private String resolveLibraryName(String buffer, int prefixStart) { + if (prefixStart <= 0) return null; + int i = prefixStart - 1; + // skip spaces + while (i > 0 && buffer.charAt(i) == ' ') i--; + char sep = buffer.charAt(i); + if (sep != '.' && sep != ':') return null; + // walk back over the library name + int nameEnd = i; + i--; + while (i >= 0 && (Character.isLetterOrDigit(buffer.charAt(i)) || buffer.charAt(i) == '_')) i--; + if (i + 1 >= nameEnd) return null; // nothing before the separator + return buffer.substring(i + 1, nameEnd); + } +} \ No newline at end of file diff --git a/src/client/java/com/aranroig/client/editor/EditorFileTree.java b/src/client/java/com/aranroig/client/editor/EditorFileTree.java index 76d5285..3ba9250 100644 --- a/src/client/java/com/aranroig/client/editor/EditorFileTree.java +++ b/src/client/java/com/aranroig/client/editor/EditorFileTree.java @@ -1,5 +1,6 @@ package com.aranroig.client.editor; +import com.aranroig.editor.EditorFile; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.input.CharacterEvent; @@ -21,25 +22,10 @@ public class EditorFileTree extends Widget { public void draw(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, double delta) { graphics.fill(getStartX(), getStartY(), getEndX(), getEndY(), COLOR_BACKGROUND); - // Line numbers + text - /* - for (int i = firstLine; i <= lastLine; i++) { - int screenY = start_y + i * lineH - scrollY; - - // Line number — right-aligned, not scrolled horizontally - String lineNum = String.valueOf(i + 1); - int numW = font.width(lineNum); - graphics.text(font, lineNum, - gutterDivX() - 4 - numW, - screenY, COLOR_GUTTER_TEXT, false); - - // Code text — offset by scrollX - graphics.text(font, lines[i], textAreaX() - scrollX, screenY, COLOR_TEXT, true); - } - */ Font font = editorScreen.getFont(); int lineH = font.lineHeight; - List files = editorManager.getFiles(); + List files = editorManager.getFiles(); + for(int i = 0; i < files.size(); i++) { graphics.text(font, files.get(i).getName(), getStartX(), getStartY() + i * lineH, COLOR_TEXT, true); } diff --git a/src/client/java/com/aranroig/client/editor/EditorFileView.java b/src/client/java/com/aranroig/client/editor/EditorFileView.java index 6325314..6a60f1b 100644 --- a/src/client/java/com/aranroig/client/editor/EditorFileView.java +++ b/src/client/java/com/aranroig/client/editor/EditorFileView.java @@ -11,6 +11,9 @@ import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import org.jspecify.annotations.NonNull; +import java.util.Collections; +import java.util.List; + public class EditorFileView extends Widget { private StringBuilder text = new StringBuilder(); @@ -27,24 +30,17 @@ public class EditorFileView extends Widget { } // ── Scroll state ────────────────────────────────────────────────────── - private int scrollX = 0; // horizontal scroll in pixels - private int scrollY = 0; // vertical scroll in pixels + private int scrollX = 0; + private int scrollY = 0; - /** How many pixels to scroll per mouse-wheel notch (vertically). */ - private static final int SCROLL_SPEED = 3; // lines per notch - - /** Width reserved for line numbers + a small right-padding. */ + private static final int SCROLL_SPEED = 3; private static final int GUTTER_WIDTH = 30; private static final int GUTTER_MARGIN = 5; - /** Where actual text content starts (after the gutter). */ private int gutterDivX() { return getStartX() + GUTTER_WIDTH; } private int textAreaX() { return getStartX() + GUTTER_WIDTH + GUTTER_MARGIN; } - - /** Visible width available for text (excludes gutter). */ - private int textAreaWidth() { return getEndX() - textAreaX(); } - /** Visible height available for text. */ - private int textAreaHeight() { return getEndY() - getStartY(); } + private int textAreaWidth() { return getEndX() - textAreaX(); } + private int textAreaHeight() { return getEndY() - getStartY(); } // ── Colors ──────────────────────────────────────────────────────────── private static final int COLOR_TEXT = 0xFFFFFFFF; @@ -54,6 +50,47 @@ public class EditorFileView extends Widget { private static final int COLOR_GUTTER_TEXT = 0xFF888888; private static final int COLOR_GUTTER_DIV = 0xFF444444; + // ── Autocomplete state ──────────────────────────────────────────────── + + private final CompletionProvider completionProvider = new CompletionProvider(); + private final LuaHighlighter luaHighlighter = new LuaHighlighter(); + + /** + * Per-line multiline-string continuation state, rebuilt whenever the text + * changes. Index {@code i} is {@code true} when line {@code i} is inside a + * {@code [[…]]} block that started before that line. + * Lazily rebuilt in {@link #rebuildHighlightState()}. + */ + private boolean[] multilineState = new boolean[0]; + private int highlightVersion = -1; // bumped on every text edit + + /** Current list of suggestions (empty = popup hidden). */ + private List completions = Collections.emptyList(); + + /** Index of the highlighted item inside {@link #completions}. */ + private int completionIndex = 0; + + /** Maximum number of suggestions shown at once. */ + private static final int MAX_COMPLETIONS = 8; + + // Popup colours — dark, VSCode-ish + private static final int COLOR_POPUP_BG = 0xFF1E1E1E; + private static final int COLOR_POPUP_BORDER = 0xFF666666; + private static final int COLOR_POPUP_SELECTED = 0xFF383868; + private static final int COLOR_POPUP_TEXT = 0xFFD4D4D4; + private static final int COLOR_POPUP_DETAIL = 0xFF888888; + + private static final int COLOR_KIND_KEYWORD = 0xFF569CD6; + private static final int COLOR_KIND_TYPE = 0xFF4EC9B0; + private static final int COLOR_KIND_METHOD = 0xFFDCDCAA; + private static final int COLOR_KIND_FIELD = 0xFF9CDCFE; + private static final int COLOR_KIND_SNIPPET = 0xFFCE9178; + + private static final int POPUP_ITEM_HEIGHT = 12; + private static final int POPUP_PADDING = 4; + private static final int POPUP_WIDTH = 240; + private static final int KIND_BADGE_WIDTH = 4; // coloured left strip + // ───────────────────────────────────────────────────────────────────── private String[] getLines() { @@ -64,22 +101,16 @@ public class EditorFileView extends Widget { return selectionAnchor >= 0 && selectionAnchor != cursorPos; } - /** Replaces \t with 4 spaces so font.width() measures correctly. */ private String sanitize(String input) { return input.replace("\t", " "); } - /** - * Returns the rendered pixel width of a prefix of a raw line up to - * character index {@code charCount}, expanding tabs properly. - */ private int widthUpTo(String rawLine, int charCount) { return font.width(sanitize(rawLine.substring(0, charCount))); } // ── Scroll helpers ──────────────────────────────────────────────────── - /** Maximum horizontal scroll so the widest line end is just visible. */ private int maxScrollX() { if (font == null) return 0; int maxW = 0; @@ -87,7 +118,6 @@ public class EditorFileView extends Widget { return Math.max(0, maxW - textAreaWidth()); } - /** Maximum vertical scroll so the last line is just visible. */ private int maxScrollY() { if (font == null) return 0; int totalH = getLines().length * font.lineHeight; @@ -99,17 +129,12 @@ public class EditorFileView extends Widget { scrollY = Math.max(0, Math.min(scrollY, maxScrollY())); } - /** - * Scrolls the view so the cursor is always visible. - * Call after any cursor movement. - */ private void scrollToCursor() { if (font == null) return; int[] lc = offsetToLineCol(cursorPos); String[] lines = getLines(); String lineText = (lc[0] < lines.length) ? lines[lc[0]] : ""; - // Vertical int cursorTop = lc[0] * font.lineHeight; int cursorBottom = cursorTop + font.lineHeight; if (cursorTop < scrollY) @@ -117,7 +142,6 @@ public class EditorFileView extends Widget { else if (cursorBottom > scrollY + textAreaHeight()) scrollY = cursorBottom - textAreaHeight(); - // Horizontal — measure sanitized prefix so tabs expand correctly int cursorPx = widthUpTo(lineText, Math.min(lc[1], lineText.length())); if (cursorPx < scrollX) scrollX = cursorPx; @@ -148,16 +172,10 @@ public class EditorFileView extends Widget { return offset + targetCol; } - /** - * Maps a pixel coordinate to a text offset. - * Accounts for the gutter AND current scroll position. - * Uses sanitized widths so tab expansion is reflected in click targets. - */ private int pixelToOffset(double mouseX, double mouseY) { String[] lines = getLines(); int lineH = font.lineHeight; - // Apply scroll to convert screen coords → content coords double contentY = mouseY - getStartY() + scrollY; double contentX = mouseX - textAreaX() + scrollX; @@ -168,7 +186,6 @@ public class EditorFileView extends Widget { String sanitized = sanitize(lineText); int col = 0; for (int i = 0; i < lineText.length(); i++) { - // Measure using sanitized prefix widths so tabs count as 4 spaces float charW = font.width(sanitized.substring(i, i + 1)); float midPoint = font.width(sanitized.substring(0, i)) + charW / 2f; if (contentX < midPoint) { col = i; break; } @@ -186,6 +203,130 @@ public class EditorFileView extends Widget { selectionAnchor = -1; } + // ── Autocomplete helpers ────────────────────────────────────────────── + + /** Refresh the completion list after every text or cursor change. */ + private void updateCompletions() { + completions = completionProvider.getCompletions(text.toString(), cursorPos, MAX_COMPLETIONS); + completionIndex = 0; + } + + /** Hide the popup without clearing the suggestion list. */ + private void dismissCompletions() { + completions = Collections.emptyList(); + } + + private boolean isPopupVisible() { + return !completions.isEmpty(); + } + + /** + * Accept the currently-highlighted suggestion: replace the typed prefix + * with the completion's insertion text. + */ + private void acceptCompletion() { + if (!isPopupVisible()) return; + CompletionItem item = completions.get(completionIndex); + + String prefix = completionProvider.extractPrefix(text.toString(), cursorPos); + int prefixStart = cursorPos - prefix.length(); + + text.replace(prefixStart, cursorPos, item.insert()); + cursorPos = prefixStart + item.insert().length(); + selectionAnchor = -1; + dismissCompletions(); + scrollToCursor(); + } + + /** + * Pixel coordinates of the top-left corner of the autocomplete popup, + * anchored to the current cursor position. + */ + private int[] popupOrigin() { + if (font == null) return new int[]{0, 0}; + int[] lc = offsetToLineCol(cursorPos); + String[] lines = getLines(); + String lineText = (lc[0] < lines.length) ? lines[lc[0]] : ""; + + // Cursor pixel x/y (screen space) + int cx = textAreaX() + widthUpTo(lineText, Math.min(lc[1], lineText.length())) - scrollX; + int cy = getStartY() + lc[0] * font.lineHeight - scrollY; + + int popupH = completions.size() * POPUP_ITEM_HEIGHT + POPUP_PADDING * 2; + int popupY = cy + font.lineHeight + 2; + + // Flip above the cursor if it would overflow below + if (popupY + popupH > getEndY()) { + popupY = cy - popupH - 2; + } + + // Keep within horizontal bounds + int popupX = cx; + if (popupX + POPUP_WIDTH > getEndX()) { + popupX = getEndX() - POPUP_WIDTH; + } + popupX = Math.max(textAreaX(), popupX); + + return new int[]{popupX, popupY}; + } + + private int kindColor(CompletionItem.Kind kind) { + return switch (kind) { + case KEYWORD -> COLOR_KIND_KEYWORD; + case TYPE -> COLOR_KIND_TYPE; + case METHOD -> COLOR_KIND_METHOD; + case FIELD -> COLOR_KIND_FIELD; + case SNIPPET -> COLOR_KIND_SNIPPET; + }; + } + + /** Single-character kind badge text. */ + private String kindLabel(CompletionItem.Kind kind) { + return switch (kind) { + case KEYWORD -> "k"; + case TYPE -> "T"; + case METHOD -> "m"; + case FIELD -> "f"; + case SNIPPET -> "s"; + }; + } + + // ── Syntax highlighting ─────────────────────────────────────────────── + + /** + * Walks all lines once and records which ones start inside a multi-line + * string/comment block. Called lazily before every draw when the text has + * changed (tracked via {@link #highlightVersion}). + */ + private void rebuildHighlightState(String[] lines) { + if (multilineState.length != lines.length) { + multilineState = new boolean[lines.length]; + } + boolean inBlock = false; + for (int i = 0; i < lines.length; i++) { + multilineState[i] = inBlock; + LuaHighlighter.LineTokens result = luaHighlighter.tokenizeLine(lines[i], inBlock); + inBlock = result.continuesMultilineString(); + } + } + + /** + * Draws a single source line with syntax highlighting. + * Tabs have already been expanded by {@link #sanitize} for width, but the + * highlighter works on the raw line; we expand tabs in each span before + * measuring/drawing so the x positions stay consistent. + */ + private void drawHighlightedLine(GuiGraphicsExtractor graphics, String rawLine, + int drawX, int drawY, boolean inMultiline) { + LuaHighlighter.LineTokens tokens = luaHighlighter.tokenizeLine(rawLine, inMultiline); + int x = drawX; + for (LuaHighlighter.Span span : tokens.spans()) { + String display = sanitize(span.text()); + graphics.text(font, display, x, drawY, span.color(), true); + x += font.width(display); + } + } + // ── Draw ────────────────────────────────────────────────────────────── public void draw(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, double delta) { @@ -196,19 +337,25 @@ public class EditorFileView extends Widget { int start_x = getStartX(); int start_y = getStartY(); - int end_x = getEndX(); - int end_y = getEndY(); + int end_x = getEndX(); + int end_y = getEndY(); graphics.enableScissor(start_x, start_y, end_x, end_y); // Background graphics.fill(start_x, start_y, end_x, end_y, 0xFF303030); - // Gutter background + divider (not scrolled — always fixed on left) + // Gutter background + divider graphics.fill(start_x, start_y, gutterDivX() - 1, end_y, COLOR_GUTTER_BG); graphics.fill(gutterDivX() - 1, start_y, gutterDivX(), end_y, COLOR_GUTTER_DIV); - // Only render lines that are at least partially visible + // Rebuild per-line multiline-string state whenever text changes + int currentVersion = text.length() ^ text.toString().hashCode(); + if (currentVersion != highlightVersion) { + rebuildHighlightState(lines); + highlightVersion = currentVersion; + } + int firstLine = Math.max(0, scrollY / lineH); int lastLine = Math.min(lines.length - 1, (scrollY + textAreaHeight()) / lineH + 1); @@ -225,12 +372,9 @@ public class EditorFileView extends Widget { int colStart = (ln == loLC[0]) ? loLC[1] : 0; int colEnd = (ln == hiLC[0]) ? hiLC[1] : lineText.length(); - // Use sanitized widths so tabs expand correctly in the highlight int x0 = textAreaX() + widthUpTo(lineText, colStart) - scrollX; int x1 = textAreaX() + widthUpTo(lineText, colEnd) - scrollX; if (x1 == x0) x1 = x0 + 2; - - // Clamp to text area so highlight doesn't bleed into the gutter x0 = Math.max(x0, textAreaX()); x1 = Math.min(x1, end_x); @@ -243,15 +387,14 @@ public class EditorFileView extends Widget { for (int i = firstLine; i <= lastLine; i++) { int screenY = start_y + i * lineH - scrollY; - // Line number — right-aligned, not scrolled horizontally String lineNum = String.valueOf(i + 1); int numW = font.width(lineNum); graphics.text(font, lineNum, gutterDivX() - 4 - numW, screenY, COLOR_GUTTER_TEXT, false); - // Code text — sanitized so \t renders as 4 spaces, offset by scrollX - graphics.text(font, sanitize(lines[i]), textAreaX() - scrollX, screenY, COLOR_TEXT, true); + boolean lineInMultiline = (i < multilineState.length) && multilineState[i]; + drawHighlightedLine(graphics, lines[i], textAreaX() - scrollX, screenY, lineInMultiline); } // Blinking cursor @@ -261,25 +404,133 @@ public class EditorFileView extends Widget { int ln = lc[0]; int col = lc[1]; - // Sanitize before measuring so the tab expansion is accounted for int cursorX = textAreaX() + widthUpTo( (ln < lines.length) ? lines[ln] : "", (ln < lines.length) ? Math.min(col, lines[ln].length()) : 0 ) - scrollX; int cursorY = start_y + ln * lineH - scrollY; - // Only draw cursor if it's within the visible text area if (cursorX >= textAreaX() && cursorX <= end_x) { graphics.fill(cursorX, cursorY, cursorX + 1, cursorY + lineH, COLOR_CURSOR); } } graphics.disableScissor(); + + // ── Autocomplete popup (drawn outside scissor so it overlaps neighbour widgets) ── + if (isPopupVisible()) { + drawCompletionPopup(graphics, mouseX, mouseY); + } + } + + private void drawCompletionPopup(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + int[] origin = popupOrigin(); + int px = origin[0]; + int py = origin[1]; + + int popupH = completions.size() * POPUP_ITEM_HEIGHT + POPUP_PADDING * 2; + + // Shadow (subtle depth) + graphics.fill(px + 2, py + 2, px + POPUP_WIDTH + 2, py + popupH + 2, 0x55000000); + + // Background + border + graphics.fill(px, py, px + POPUP_WIDTH, py + popupH, COLOR_POPUP_BG); + // Top border + graphics.fill(px, py, px + POPUP_WIDTH, py + 1, COLOR_POPUP_BORDER); + // Bottom border + graphics.fill(px, py + popupH - 1, px + POPUP_WIDTH, py + popupH, COLOR_POPUP_BORDER); + // Left border + graphics.fill(px, py, px + 1, py + popupH, COLOR_POPUP_BORDER); + // Right border + graphics.fill(px + POPUP_WIDTH - 1, py, px + POPUP_WIDTH, py + popupH, COLOR_POPUP_BORDER); + + String currentPrefix = completionProvider.extractPrefix(text.toString(), cursorPos); + + for (int i = 0; i < completions.size(); i++) { + CompletionItem item = completions.get(i); + int itemY = py + POPUP_PADDING + i * POPUP_ITEM_HEIGHT; + boolean selected = (i == completionIndex); + boolean hovered = mouseX >= px && mouseX < px + POPUP_WIDTH + && mouseY >= itemY && mouseY < itemY + POPUP_ITEM_HEIGHT; + + // Row background + if (selected) { + graphics.fill(px + 1, itemY, px + POPUP_WIDTH - 1, itemY + POPUP_ITEM_HEIGHT, + COLOR_POPUP_SELECTED); + } else if (hovered) { + graphics.fill(px + 1, itemY, px + POPUP_WIDTH - 1, itemY + POPUP_ITEM_HEIGHT, + 0xFF2A2A3A); + } + + // Kind colour strip (left edge) + int kindCol = kindColor(item.kind()); + graphics.fill(px + 1, itemY, px + 1 + KIND_BADGE_WIDTH, itemY + POPUP_ITEM_HEIGHT, kindCol); + + // Kind letter badge + int badgeTextX = px + 8; + int badgeTextY = itemY + (POPUP_ITEM_HEIGHT - font.lineHeight) / 2; + graphics.text(font, kindLabel(item.kind()), badgeTextX, badgeTextY, + kindCol, false); + + // Completion label — highlight the matched prefix in a brighter colour + int labelX = px + KIND_BADGE_WIDTH + 14; + int labelY = itemY + (POPUP_ITEM_HEIGHT - font.lineHeight) / 2; + String label = item.label(); + + if (!currentPrefix.isEmpty() + && label.toLowerCase().startsWith(currentPrefix.toLowerCase())) { + // Draw the matched prefix brighter / white + String matchedPart = label.substring(0, currentPrefix.length()); + String remainingPart = label.substring(currentPrefix.length()); + + graphics.text(font, matchedPart, labelX, labelY, 0xFFFFFFFF, false); + int afterMatchX = labelX + font.width(matchedPart); + graphics.text(font, remainingPart, afterMatchX, labelY, COLOR_POPUP_TEXT, false); + } else { + graphics.text(font, label, labelX, labelY, COLOR_POPUP_TEXT, false); + } + + // Detail annotation — right-aligned, dimmed + String detail = item.detail(); + int detailW = font.width(detail); + int detailX = px + POPUP_WIDTH - detailW - POPUP_PADDING; + // Only draw if it fits without overlapping the label + if (detailX > labelX + font.width(label) + 4) { + graphics.text(font, detail, detailX, labelY, COLOR_POPUP_DETAIL, false); + } + } + + // Hint line below the popup when more than one item is present + if (completions.size() > 1) { + int hintY = py + popupH + 2; + String hint = "↑↓ navigate Tab/Enter accept Esc dismiss"; + graphics.text(font, hint, px, hintY, 0xFF555577, false); + } } // ── Input handlers ──────────────────────────────────────────────────── public boolean handleMouseClicked(MouseButtonEvent event) { + // Check if clicking inside the popup + if (isPopupVisible()) { + int[] origin = popupOrigin(); + int px = origin[0], py = origin[1]; + int popupH = completions.size() * POPUP_ITEM_HEIGHT + POPUP_PADDING * 2; + + if (event.x() >= px && event.x() < px + POPUP_WIDTH + && event.y() >= py && event.y() < py + popupH) { + int relY = (int)(event.y() - py - POPUP_PADDING); + int clicked = relY / POPUP_ITEM_HEIGHT; + if (clicked >= 0 && clicked < completions.size()) { + completionIndex = clicked; + acceptCompletion(); + } + return true; + } + // Click outside popup → dismiss + dismissCompletions(); + } + int clicked = pixelToOffset(event.x(), event.y()); cursorPos = clicked; selectionAnchor = clicked; @@ -289,7 +540,6 @@ public class EditorFileView extends Widget { public boolean handleMouseDragged(MouseButtonEvent event) { cursorPos = pixelToOffset(event.x(), event.y()); - // Auto-scroll when dragging outside the editor bounds if (font != null) { int lineH = font.lineHeight; double my = event.y(); @@ -298,7 +548,7 @@ public class EditorFileView extends Widget { if (my < getStartY()) scrollY = Math.max(0, scrollY - lineH); else if (my > getEndY()) scrollY = Math.min(maxScrollY(), scrollY + lineH); - if (mx < textAreaX()) scrollX = Math.max(0, scrollX - 10); + if (mx < textAreaX()) scrollX = Math.max(0, scrollX - 10); else if (mx > getEndX()) scrollX = Math.min(maxScrollX(), scrollX + 10); } @@ -310,17 +560,10 @@ public class EditorFileView extends Widget { return true; } - /** - * Call this from your Screen's mouseScrolled override whenever inside() is true. - * - * @param delta positive = scroll up (content moves down), negative = scroll down - */ public boolean handleMouseScrolled(double delta) { if (font == null) return true; int amount = (int) (delta * SCROLL_SPEED * font.lineHeight); - scrollY = Math.max(0, Math.min(scrollY - amount, maxScrollY())); - return true; } @@ -332,6 +575,14 @@ public class EditorFileView extends Widget { cursorPos += Character.charCount(codePoint); selectionAnchor = -1; scrollToCursor(); + + // Trigger autocomplete after typing an identifier char or '.' + char c = (char) codePoint; + if (Character.isJavaIdentifierPart(c) || c == '.') { + updateCompletions(); + } else { + dismissCompletions(); + } } return true; } @@ -343,6 +594,33 @@ public class EditorFileView extends Widget { Minecraft minecraft = Minecraft.getInstance(); + // ── Popup navigation (intercept before normal key handling) ─────── + if (isPopupVisible()) { + switch (key) { + case 264 -> { // Down + completionIndex = (completionIndex + 1) % completions.size(); + return true; + } + case 265 -> { // Up + completionIndex = (completionIndex - 1 + completions.size()) % completions.size(); + return true; + } + case 258 -> { // Tab — accept + acceptCompletion(); + return true; + } + case 257 -> { // Enter — accept (fall through to newline below if no popup) + acceptCompletion(); + return true; + } + case 256 -> { // Escape — dismiss + dismissCompletions(); + return true; + } + } + } + + // ── Normal key handling ────────────────────────────────────────── switch (key) { case 257 -> { // Enter if (hasSelection()) deleteSelection(); @@ -350,14 +628,16 @@ public class EditorFileView extends Widget { cursorPos++; selectionAnchor = -1; scrollToCursor(); + dismissCompletions(); return true; } - case 258 -> { // Tab + case 258 -> { // Tab (no popup) if (hasSelection()) deleteSelection(); text.insert(cursorPos, '\t'); cursorPos++; selectionAnchor = -1; scrollToCursor(); + dismissCompletions(); return true; } case 259 -> { // Backspace @@ -365,6 +645,7 @@ public class EditorFileView extends Widget { else if (cursorPos > 0) { text.deleteCharAt(--cursorPos); } selectionAnchor = -1; scrollToCursor(); + updateCompletions(); // re-compute after deletion return true; } case 261 -> { // Delete @@ -372,6 +653,7 @@ public class EditorFileView extends Widget { else if (cursorPos < text.length()) { text.deleteCharAt(cursorPos); } selectionAnchor = -1; scrollToCursor(); + updateCompletions(); return true; } case 263 -> { // Left @@ -383,6 +665,7 @@ public class EditorFileView extends Widget { else if (cursorPos > 0) cursorPos--; selectionAnchor = -1; } + dismissCompletions(); scrollToCursor(); return true; } @@ -395,18 +678,20 @@ public class EditorFileView extends Widget { else if (cursorPos < text.length()) cursorPos++; selectionAnchor = -1; } + dismissCompletions(); scrollToCursor(); return true; } - case 265 -> { // Up + case 265 -> { // Up (no popup open) if (shift && selectionAnchor < 0) selectionAnchor = cursorPos; int[] lc = offsetToLineCol(cursorPos); cursorPos = (lc[0] - 1 >= 0) ? lineColToOffset(lc[0] - 1, lc[1]) : 0; if (!shift) selectionAnchor = -1; + dismissCompletions(); scrollToCursor(); return true; } - case 264 -> { // Down + case 264 -> { // Down (no popup open) if (shift && selectionAnchor < 0) selectionAnchor = cursorPos; int[] lc2 = offsetToLineCol(cursorPos); String[] lines = getLines(); @@ -414,6 +699,7 @@ public class EditorFileView extends Widget { ? lineColToOffset(lc2[0] + 1, lc2[1]) : text.length(); if (!shift) selectionAnchor = -1; + dismissCompletions(); scrollToCursor(); return true; } @@ -421,6 +707,7 @@ public class EditorFileView extends Widget { if (shift && selectionAnchor < 0) selectionAnchor = cursorPos; cursorPos = lineColToOffset(offsetToLineCol(cursorPos)[0], 0); if (!shift) selectionAnchor = -1; + dismissCompletions(); scrollToCursor(); return true; } @@ -429,11 +716,29 @@ public class EditorFileView extends Widget { int[] lc4 = offsetToLineCol(cursorPos); cursorPos = lineColToOffset(lc4[0], getLines()[lc4[0]].length()); if (!shift) selectionAnchor = -1; + dismissCompletions(); scrollToCursor(); return true; } + case 256 -> { // Escape (no popup) + dismissCompletions(); + return true; + } + case 32 -> { // Space + if (ctrl) { + // Ctrl+Space: manually trigger autocomplete + updateCompletions(); + return true; + } + } case 65 -> { // Ctrl+A - if (ctrl) { selectionAnchor = 0; cursorPos = text.length(); scrollToCursor(); return true; } + if (ctrl) { + selectionAnchor = 0; + cursorPos = text.length(); + dismissCompletions(); + scrollToCursor(); + return true; + } } case 67, 88 -> { // Ctrl+C / Ctrl+X if (ctrl && hasSelection()) { @@ -452,6 +757,7 @@ public class EditorFileView extends Widget { cursorPos += clip.length(); selectionAnchor = -1; scrollToCursor(); + dismissCompletions(); return true; } } @@ -465,14 +771,18 @@ public class EditorFileView extends Widget { public void setEditorFile(EditorFile currentEditorFile) { this.currentEditorFile = currentEditorFile; text = new StringBuilder(currentEditorFile.getText()); + dismissCompletions(); } public void saveEditorFile() { - if(this.currentEditorFile == null) return; - this.currentEditorFile.setText(text.toString()); + editorManager.save(); + } - // Send save - ServerboundSaveCodePayload payload = new ServerboundSaveCodePayload(this.currentEditorFile); - ClientPlayNetworking.send(payload); + public void writeChanges() { + currentEditorFile.setText(text.toString()); + } + + public EditorFile getCurrentEditorFile() { + return currentEditorFile; } } \ No newline at end of file diff --git a/src/client/java/com/aranroig/client/editor/EditorManager.java b/src/client/java/com/aranroig/client/editor/EditorManager.java index ea8dd2c..829efd0 100644 --- a/src/client/java/com/aranroig/client/editor/EditorManager.java +++ b/src/client/java/com/aranroig/client/editor/EditorManager.java @@ -1,6 +1,12 @@ package com.aranroig.client.editor; +import com.aranroig.SavedProjectData; import com.aranroig.client.wrapper.Logger; +import com.aranroig.editor.EditorFile; +import com.aranroig.editor.RunInfo; +import com.aranroig.payloads.ServerboundRunCodePayload; +import com.aranroig.payloads.ServerboundSaveCodePayload; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.minecraft.client.Minecraft; import party.iroiro.luajava.Lua; import party.iroiro.luajava.luaj.LuaJ; @@ -16,7 +22,7 @@ public class EditorManager { EditorTopBar editorTopBar; EditorFileView editorFileView; - List files; + List files; public EditorManager() { client = Minecraft.getInstance(); @@ -24,15 +30,9 @@ public class EditorManager { editorFileTree = new EditorFileTree(this); editorTopBar = new EditorTopBar(this); editorFileView = new EditorFileView(this); - - files = new ArrayList<>(); - - // Add example file - com.aranroig.editor.EditorFile editorFile = new com.aranroig.editor.EditorFile("test.lua", ""); - files.add(editorFile); } - public List getFiles() { + public List getFiles() { return files; } @@ -54,6 +54,17 @@ public class EditorManager { editorFileView.setEditorFile(files.getFirst()); editorFileView.addToEditorScreen(textEditorScreen); + /* + files = new ArrayList<>(); + + // Add example file + EditorFile editorFile = new EditorFile("main.lua", ""); + files.add(editorFile); + + */ + + // De alguna forma cargar archivos de server + client.setScreenAndShow(textEditorScreen); } @@ -61,18 +72,31 @@ public class EditorManager { } public void save() { + EditorFile currentEditorFile = editorFileView.getCurrentEditorFile(); + if (currentEditorFile == null) return; + editorFileView.writeChanges(); + + ServerboundSaveCodePayload payload = new ServerboundSaveCodePayload(currentEditorFile); + ClientPlayNetworking.send(payload); } public void undo() { } public void run() { + RunInfo runInfo = new RunInfo("main.lua"); + ServerboundRunCodePayload payload = new ServerboundRunCodePayload(runInfo); + + ClientPlayNetworking.send(payload); + /* String code = files.getFirst().getText(); System.out.println(code); Lua l = new LuaJ(); l.register("print", Logger.print()); l.run(code); + + */ } public void redo() { diff --git a/src/client/java/com/aranroig/client/editor/LuaHighlighter.java b/src/client/java/com/aranroig/client/editor/LuaHighlighter.java new file mode 100644 index 0000000..cc20c72 --- /dev/null +++ b/src/client/java/com/aranroig/client/editor/LuaHighlighter.java @@ -0,0 +1,267 @@ +package com.aranroig.client.editor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Tokenizes a single raw Lua source line into a list of {@link Span}s, each + * carrying the text to render and its ARGB colour. The caller then draws each + * span in sequence, advancing the x-position by {@code font.width(span.text())} + * between them. + * + *

Handles (within a single line): + *

    + *
  • Single-line comments {@code -- …}
  • + *
  • Keywords
  • + *
  • Built-in global names ({@code print}, {@code pairs}, …)
  • + *
  • Standard library prefixes ({@code math}, {@code string}, …)
  • + *
  • String literals {@code "…"} and {@code '…'}
  • + *
  • Number literals (integer, hex, float)
  • + *
  • Boolean / nil literals {@code true}, {@code false}, {@code nil}
  • + *
  • Operators and punctuation
  • + *
  • Plain identifiers and everything else (default colour)
  • + *
+ * + *

Multi-line strings/comments ({@code [[ … ]]}) require cross-line state; + * that state is carried through {@link #tokenizeLine(String, boolean)} via the + * {@code inMultilineString} flag, and the method returns whether the caller + * should pass {@code true} for the next line. + */ +public class LuaHighlighter { + + // ── Token colours (VSCode-dark palette) ────────────────────────────── + public static final int C_DEFAULT = 0xFFD4D4D4; // plain identifiers / text + public static final int C_KEYWORD = 0xFF569CD6; // language keywords + public static final int C_BUILTIN = 0xFF4EC9B0; // built-in functions & globals + public static final int C_LIBRARY = 0xFF4EC9B0; // std-lib table names (math, …) + public static final int C_STRING = 0xFFCE9178; // string literals + public static final int C_NUMBER = 0xFFB5CEA8; // numeric literals + public static final int C_COMMENT = 0xFF6A9955; // comments + public static final int C_OPERATOR = 0xFFD4D4D4; // operators / punctuation + public static final int C_BOOLEAN = 0xFF569CD6; // true / false / nil + public static final int C_PARAM = 0xFF9CDCFE; // function parameters (heuristic) + + // ── Lua vocabulary ──────────────────────────────────────────────────── + + private static final Set KEYWORDS = Set.of( + "and", "break", "do", "else", "elseif", "end", + "false", "for", "function", "goto", "if", "in", + "local", "nil", "not", "or", "repeat", "return", + "then", "true", "until", "while" + ); + + private static final Set BOOLEAN_NIL = Set.of("true", "false", "nil"); + + private static final Set BUILTINS = Set.of( + "assert", "collectgarbage", "dofile", "error", + "getmetatable", "ipairs", "load", "loadfile", "next", + "pairs", "pcall", "print", "rawequal", "rawget", + "rawlen", "rawset", "require", "select", "setmetatable", + "tonumber", "tostring", "type", "unpack", "xpcall", + "_G", "_VERSION" + ); + + private static final Set LIBRARIES = Set.of( + "string", "table", "math", "io", "os", + "coroutine", "package", "utf8", "debug" + ); + + // ── Public types ────────────────────────────────────────────────────── + + /** A single coloured text fragment for one draw call. */ + public record Span(String text, int color) {} + + /** + * Result of tokenizing one line. Carries the spans to draw plus the + * multiline-string continuation state for the next line. + */ + public record LineTokens(List spans, boolean continuesMultilineString) {} + + // ── Main API ────────────────────────────────────────────────────────── + + /** + * Tokenizes {@code rawLine} (tabs already kept as-is; the caller sanitizes + * tabs to spaces for width measurement but we work on the raw chars here). + * + * @param rawLine the raw source line + * @param inMultilineString {@code true} if a {@code [[ }block started on a + * previous line and hasn't been closed yet + * @return spans + continuation flag + */ + public LineTokens tokenizeLine(String rawLine, boolean inMultilineString) { + List spans = new ArrayList<>(); + int len = rawLine.length(); + int i = 0; + + // ── Carry-over multi-line string ────────────────────────────────── + if (inMultilineString) { + int close = rawLine.indexOf("]]"); + if (close == -1) { + // Entire line is still inside the block string + spans.add(new Span(rawLine, C_STRING)); + return new LineTokens(spans, true); + } else { + // Closing delimiter is on this line + spans.add(new Span(rawLine.substring(0, close + 2), C_STRING)); + i = close + 2; + inMultilineString = false; + } + } + + StringBuilder plain = new StringBuilder(); + + while (i < len) { + char c = rawLine.charAt(i); + + // ── Single-line comment ─────────────────────────────────────── + if (c == '-' && i + 1 < len && rawLine.charAt(i + 1) == '-') { + flushPlain(spans, plain); + // Check for long comment --[[ … ]] + if (i + 3 < len && rawLine.charAt(i + 2) == '[' && rawLine.charAt(i + 3) == '[') { + int close = rawLine.indexOf("]]", i + 4); + if (close == -1) { + spans.add(new Span(rawLine.substring(i), C_COMMENT)); + return new LineTokens(spans, true); // multi-line comment (treated like string) + } else { + spans.add(new Span(rawLine.substring(i, close + 2), C_COMMENT)); + i = close + 2; + continue; + } + } + // Regular -- comment: rest of line + spans.add(new Span(rawLine.substring(i), C_COMMENT)); + return new LineTokens(spans, false); + } + + // ── Multi-line string literal [[ ───────────────────────────── + if (c == '[' && i + 1 < len && rawLine.charAt(i + 1) == '[') { + flushPlain(spans, plain); + int close = rawLine.indexOf("]]", i + 2); + if (close == -1) { + spans.add(new Span(rawLine.substring(i), C_STRING)); + return new LineTokens(spans, true); + } else { + spans.add(new Span(rawLine.substring(i, close + 2), C_STRING)); + i = close + 2; + continue; + } + } + + // ── String literal " or ' ───────────────────────────────────── + if (c == '"' || c == '\'') { + flushPlain(spans, plain); + char quote = c; + int start = i; + i++; + while (i < len) { + char ch = rawLine.charAt(i); + if (ch == '\\') { i += 2; continue; } // escape sequence + if (ch == quote) { i++; break; } + i++; + } + spans.add(new Span(rawLine.substring(start, i), C_STRING)); + continue; + } + + // ── Number literal ──────────────────────────────────────────── + if (Character.isDigit(c) || (c == '.' && i + 1 < len && Character.isDigit(rawLine.charAt(i + 1)))) { + flushPlain(spans, plain); + int start = i; + // Hex? + if (c == '0' && i + 1 < len && (rawLine.charAt(i + 1) == 'x' || rawLine.charAt(i + 1) == 'X')) { + i += 2; + while (i < len && isHexDigit(rawLine.charAt(i))) i++; + } else { + while (i < len && (Character.isDigit(rawLine.charAt(i)) || rawLine.charAt(i) == '.')) i++; + if (i < len && (rawLine.charAt(i) == 'e' || rawLine.charAt(i) == 'E')) { + i++; + if (i < len && (rawLine.charAt(i) == '+' || rawLine.charAt(i) == '-')) i++; + while (i < len && Character.isDigit(rawLine.charAt(i))) i++; + } + } + spans.add(new Span(rawLine.substring(start, i), C_NUMBER)); + continue; + } + + // ── Identifier or keyword ───────────────────────────────────── + if (Character.isLetter(c) || c == '_') { + flushPlain(spans, plain); + int start = i; + while (i < len && (Character.isLetterOrDigit(rawLine.charAt(i)) || rawLine.charAt(i) == '_')) i++; + String word = rawLine.substring(start, i); + + // Peek ahead: if followed by '(' it's a call — highlight as builtin/method + boolean isCall = (i < len && rawLine.charAt(i) == '('); + // Peek behind: if preceded by ':' or '.' it's a method — leave as default + boolean isMember = (start > 0 && (rawLine.charAt(start - 1) == '.' || rawLine.charAt(start - 1) == ':')); + + if (BOOLEAN_NIL.contains(word)) { + spans.add(new Span(word, C_BOOLEAN)); + } else if (KEYWORDS.contains(word)) { + spans.add(new Span(word, C_KEYWORD)); + } else if (!isMember && LIBRARIES.contains(word)) { + spans.add(new Span(word, C_LIBRARY)); + } else if (!isMember && BUILTINS.contains(word)) { + spans.add(new Span(word, C_BUILTIN)); + } else if (isCall) { + // User-defined function call: slightly highlighted + spans.add(new Span(word, C_BUILTIN)); + } else { + spans.add(new Span(word, C_DEFAULT)); + } + continue; + } + + // ── Operator / punctuation ──────────────────────────────────── + if (isOperator(c)) { + flushPlain(spans, plain); + // Grab multi-char operators: ~=, ==, <=, >=, .., ... + if (i + 2 < len && rawLine.charAt(i) == '.' && rawLine.charAt(i+1) == '.' && rawLine.charAt(i+2) == '.') { + spans.add(new Span("...", C_OPERATOR)); i += 3; + } else if (i + 1 < len && isTwoCharOp(rawLine, i)) { + spans.add(new Span(rawLine.substring(i, i + 2), C_OPERATOR)); i += 2; + } else { + spans.add(new Span(String.valueOf(c), C_OPERATOR)); i++; + } + continue; + } + + // ── Everything else (spaces, tabs, unknown) ─────────────────── + plain.append(c); + i++; + } + + flushPlain(spans, plain); + return new LineTokens(spans, false); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static void flushPlain(List spans, StringBuilder sb) { + if (sb.length() > 0) { + spans.add(new Span(sb.toString(), C_DEFAULT)); + sb.setLength(0); + } + } + + private static boolean isHexDigit(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + private static boolean isOperator(char c) { + return "+-*/%^#&|~<>=(){}[];:,./\\".indexOf(c) >= 0; + } + + private static boolean isTwoCharOp(String line, int i) { + char a = line.charAt(i), b = line.charAt(i + 1); + return (a == '=' && b == '=') + || (a == '~' && b == '=') + || (a == '<' && b == '=') + || (a == '>' && b == '=') + || (a == '.' && b == '.') + || (a == ':' && b == ':') + || (a == '<' && b == '<') + || (a == '>' && b == '>'); + } +} \ No newline at end of file diff --git a/src/main/java/com/aranroig/Codecraft.java b/src/main/java/com/aranroig/Codecraft.java index c7f0ecb..43a6291 100644 --- a/src/main/java/com/aranroig/Codecraft.java +++ b/src/main/java/com/aranroig/Codecraft.java @@ -1,5 +1,7 @@ package com.aranroig; +import com.aranroig.editor.EditorFile; +import com.aranroig.payloads.ServerboundRunCodePayload; import com.aranroig.payloads.ServerboundSaveCodePayload; import net.fabricmc.api.ModInitializer; @@ -7,9 +9,13 @@ import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.entity.vehicle.minecart.Minecart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; + public class Codecraft implements ModInitializer { public static final String MOD_ID = "codecraft"; @@ -18,6 +24,8 @@ public class Codecraft implements ModInitializer { // That way, it's clear which mod wrote info, warnings, and errors. public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + private List editorFiles; + @Override public void onInitialize() { // This code runs as soon as Minecraft is in a mod-load-ready state. @@ -26,10 +34,49 @@ public class Codecraft implements ModInitializer { LOGGER.info("Hello Fabric world!"); PayloadTypeRegistry.serverboundPlay().register(ServerboundSaveCodePayload.TYPE, ServerboundSaveCodePayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(ServerboundRunCodePayload.TYPE, ServerboundRunCodePayload.CODEC); ServerPlayNetworking.registerGlobalReceiver(ServerboundSaveCodePayload.TYPE, (payload, context) -> { - System.out.println(payload.data().getName()); - System.out.println(payload.data().getText()); + MinecraftServer server = context.player().level().getServer(); + if(server == null) return; + + SavedProjectData savedData = SavedProjectData.getSavedBlockData(server); + List files = savedData.getEditorFiles(); + + EditorFile editorFile = payload.data(); + boolean existing = false; + for(EditorFile file : files) { + if(file.getName().equals(editorFile.getName())) { + existing = true; + file.setText(editorFile.getText()); + break; + } + } + if(!existing) { + files.add(editorFile); + } + + savedData.setEditorFiles(files); + System.out.println("Successfully saved files"); + }); + + ServerPlayNetworking.registerGlobalReceiver(ServerboundRunCodePayload.TYPE, (payload, context) -> { + // HERE + MinecraftServer server = context.player().level().getServer(); + if(server == null) return; + + SavedProjectData savedData = SavedProjectData.getSavedBlockData(server); + List files = savedData.getEditorFiles(); + + String entrypoint = payload.data().getEntrypoint(); + System.out.println("Want to run the code " + payload.data().getEntrypoint()); + + // TODO: Run the entrypoint, might change later + for(EditorFile file : files) { + if(file.getName().equals(entrypoint)) { + System.out.println(file.getText()); + } + } }); } diff --git a/src/main/java/com/aranroig/SavedProjectData.java b/src/main/java/com/aranroig/SavedProjectData.java new file mode 100644 index 0000000..f63e382 --- /dev/null +++ b/src/main/java/com/aranroig/SavedProjectData.java @@ -0,0 +1,65 @@ +package com.aranroig; + +import com.aranroig.editor.EditorFile; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +import java.util.ArrayList; +import java.util.List; + +public class SavedProjectData extends SavedData { + private List editorFiles; + + public SavedProjectData() { + editorFiles = new ArrayList(); + } + + public SavedProjectData(List editorFiles) { + this.editorFiles = editorFiles; + } + + public List getEditorFiles() { + return editorFiles; + } + + public void setEditorFiles(List editorFiles) { + this.editorFiles = editorFiles; + setDirty(); + } + + + private static final Codec CODEC = RecordCodecBuilder.create(instance -> + instance.group( + EditorFile.CODEC.listOf() + .fieldOf("editor_files") + .forGetter(SavedProjectData::getEditorFiles) + ).apply(instance, SavedProjectData::new) + ); + + private static final SavedDataType TYPE = new SavedDataType( + Identifier.fromNamespaceAndPath(Codecraft.MOD_ID, "saved_project_data"), // The unique name for this saved data. + SavedProjectData::new, // If there's no 'SavedBlockData', yet create one and refresh fields. + CODEC, // The codec used for serialization/deserialization. + null // A data fixer, which is not needed here. + ); + + public static SavedProjectData getSavedBlockData(MinecraftServer server) { + // This could be either the overworld or another dimension. + ServerLevel level = server.getLevel(ServerLevel.OVERWORLD); + + if (level == null) { + return new SavedProjectData(); // Return a new instance if the level is null. + } + + // The first time the following 'computeIfAbsent' function is called, it creates a new 'SavedBlockData' + // instance and stores it inside the 'DimensionDataStorage'. + // Subsequent calls to 'computeIfAbsent' returns the saved 'SavedBlockData' NBT on disk to the Codec in our type, + // using the Codec to decode the NBT into our saved data. + return level.getDataStorage().computeIfAbsent(TYPE); + } +} diff --git a/src/main/java/com/aranroig/editor/EditorFile.java b/src/main/java/com/aranroig/editor/EditorFile.java index 393781f..0eb5371 100644 --- a/src/main/java/com/aranroig/editor/EditorFile.java +++ b/src/main/java/com/aranroig/editor/EditorFile.java @@ -1,11 +1,13 @@ package com.aranroig.editor; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.StreamCodec; public class EditorFile { - public static final StreamCodec CODEC = + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ByteBufCodecs.STRING_UTF8, EditorFile::getName, @@ -16,6 +18,13 @@ public class EditorFile { EditorFile::new ); + public static final Codec CODEC = RecordCodecBuilder.create(instance -> + instance.group( + Codec.STRING.fieldOf("name").forGetter(EditorFile::getName), + Codec.STRING.fieldOf("text").forGetter(EditorFile::getText) + ).apply(instance, EditorFile::new) + ); + // Class with file info private String name; private String text; diff --git a/src/main/java/com/aranroig/editor/Project.java b/src/main/java/com/aranroig/editor/Project.java new file mode 100644 index 0000000..3b28f49 --- /dev/null +++ b/src/main/java/com/aranroig/editor/Project.java @@ -0,0 +1,9 @@ +package com.aranroig.editor; + +import java.util.List; + +public record Project(List editorFiles) { + /* + TODO: Acabar esto. En principio hay que sustituir las listas de los archivos de proyecto por esta clase + */ +} diff --git a/src/main/java/com/aranroig/editor/RunInfo.java b/src/main/java/com/aranroig/editor/RunInfo.java new file mode 100644 index 0000000..d8da231 --- /dev/null +++ b/src/main/java/com/aranroig/editor/RunInfo.java @@ -0,0 +1,30 @@ +package com.aranroig.editor; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +public class RunInfo { + public static final StreamCodec CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, + RunInfo::getEntrypoint, + + RunInfo::new + ); + + // Class with file info + private String entrypoint; + + public RunInfo(String entrypoint) { + this.entrypoint = entrypoint; + } + + public String getEntrypoint() { + return entrypoint; + } + + public void setEntrypoint(String entrypoint) { + this.entrypoint = entrypoint; + } +} diff --git a/src/main/java/com/aranroig/payloads/ClientboundSaveCodePayload.java b/src/main/java/com/aranroig/payloads/ClientboundSaveCodePayload.java index 197f800..5c94c1e 100644 --- a/src/main/java/com/aranroig/payloads/ClientboundSaveCodePayload.java +++ b/src/main/java/com/aranroig/payloads/ClientboundSaveCodePayload.java @@ -14,7 +14,7 @@ public record ClientboundSaveCodePayload(EditorFile data) implements CustomPacke public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(SAVE_CODE_PAYLOAD_ID); public static final StreamCodec CODEC = - StreamCodec.composite(EditorFile.CODEC, ClientboundSaveCodePayload::data, ClientboundSaveCodePayload::new); + StreamCodec.composite(EditorFile.STREAM_CODEC, ClientboundSaveCodePayload::data, ClientboundSaveCodePayload::new); @Override public Type type() { diff --git a/src/main/java/com/aranroig/payloads/ServerboundRunCodePayload.java b/src/main/java/com/aranroig/payloads/ServerboundRunCodePayload.java new file mode 100644 index 0000000..43aacb2 --- /dev/null +++ b/src/main/java/com/aranroig/payloads/ServerboundRunCodePayload.java @@ -0,0 +1,24 @@ +package com.aranroig.payloads; + +import com.aranroig.Codecraft; +import com.aranroig.editor.EditorFile; +import com.aranroig.editor.RunInfo; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record ServerboundRunCodePayload(RunInfo data) implements CustomPacketPayload { + public static final Identifier RUN_CODE_PAYLOAD_ID = Identifier.fromNamespaceAndPath(Codecraft.MOD_ID, "run_code"); + + public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(RUN_CODE_PAYLOAD_ID); + + public static final StreamCodec CODEC = + StreamCodec.composite(RunInfo.CODEC, ServerboundRunCodePayload::data, ServerboundRunCodePayload::new); + + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/com/aranroig/payloads/ServerboundSaveCodePayload.java b/src/main/java/com/aranroig/payloads/ServerboundSaveCodePayload.java index 2deb09c..93dc2d6 100644 --- a/src/main/java/com/aranroig/payloads/ServerboundSaveCodePayload.java +++ b/src/main/java/com/aranroig/payloads/ServerboundSaveCodePayload.java @@ -13,7 +13,7 @@ public record ServerboundSaveCodePayload(EditorFile data) implements CustomPacke public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(SAVE_CODE_PAYLOAD_ID); public static final StreamCodec CODEC = - StreamCodec.composite(EditorFile.CODEC, ServerboundSaveCodePayload::data, ServerboundSaveCodePayload::new); + StreamCodec.composite(EditorFile.STREAM_CODEC, ServerboundSaveCodePayload::data, ServerboundSaveCodePayload::new); @Override public CustomPacketPayload.Type type() {