diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java index 88d0abaef..d98eae47d 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -2088,7 +2088,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (closeLineIdx < this.container.lines.size()) { int closeIndent = IndentHelper.getLineIndent( this.container.lines.get(closeLineIdx).text); - if (closeIndent == indent.length()) { + // <= handles multi-line declarations where '{' sits on a + // continuation line with higher indent than the closing '}' + if (closeIndent <= indent.length()) { hasMatchingClose = true; } } @@ -2940,9 +2942,11 @@ public class GuiScriptTextArea extends GuiNpcTextField { } private void formatText() { - // Calculate viewport width for line wrapping (account for gutter and scrollbar) - int viewportWidth = this.width - LINE_NUMBER_GUTTER_WIDTH - 10; - IndentHelper.FormatResult result = IndentHelper.formatText(text, selection.getCursorPosition(), viewportWidth); + int viewportPixels = this.width - LINE_NUMBER_GUTTER_WIDTH - 10; + int avgCharWidth = Math.max(1, ClientProxy.Font.width("abcdefghijklmnopqrstuvwxyz") / 26); + int viewportChars = (int) (viewportPixels * 0.8f / avgCharWidth); + int maxLineLength = Math.max(60, Math.min(viewportChars, 120)); + IndentHelper.FormatResult result = IndentHelper.formatText(text, selection.getCursorPosition(), maxLineLength); setText(result.text); selection.reset(Math.max(0, Math.min(result.cursorPosition, this.text.length()))); } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/BracketMatcher.java b/src/main/java/noppes/npcs/client/gui/util/script/BracketMatcher.java index b7b9ab63a..d2ec240e7 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/BracketMatcher.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/BracketMatcher.java @@ -326,7 +326,8 @@ public class BracketMatcher { } /** - * Check if a brace at given position has a matching close with same indent + * Check if a brace at given position has a matching close with compatible indent. + * Uses <= to handle multi-line declarations where '{' is on a continuation line. */ public static boolean hasMatchingCloseWithSameIndent(String text, int bracePos, List lines, int currentIndent) { if (text == null || lines == null || bracePos < 0) return false; @@ -350,7 +351,7 @@ public class BracketMatcher { if (closeLineIdx < lines.size()) { String closeLine = lines.get(closeLineIdx).text; int closeIndent = getLineIndent(closeLine); - return closeIndent == currentIndent; + return closeIndent <= currentIndent; } } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java b/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java index a22f4ca4a..989656ddf 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/FormatHelper.java @@ -1642,7 +1642,10 @@ public class FormatHelper { if (text == null || text.isEmpty()) return text; int width = maxWidth > 0 ? maxWidth : settings.maxLineLength; - String[] lines = text.split("\n", -1); + + String joined = format(joinContinuationLines(text)); + + String[] lines = joined.split("\n", -1); StringBuilder result = new StringBuilder(); for (int i = 0; i < lines.length; i++) { @@ -1670,6 +1673,92 @@ public class FormatHelper { return result.toString(); } + /** + * Join continuation lines back onto their parent line so they can be + * re-evaluated and re-wrapped at the current target width. + *

+ * A "continuation line" is one whose trimmed content starts with an operator, + * dot, comma, {@code ?}, or {@code :} — i.e. the wrapping markers used by + * {@link #wrapCodeContent}. Only code continuation lines are joined; comment + * lines ({@code //}, {@code *}) and lines inside lambda/block bodies (deeper + * indent than expected) are left alone. + */ + private String joinContinuationLines(String text) { + String[] lines = text.split("\n", -1); + List out = new ArrayList<>(); + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + String trimmed = line.trim(); + + if (trimmed.isEmpty() + || trimmed.startsWith("//") + || trimmed.startsWith("/*") + || trimmed.startsWith("*")) { + out.add(line); + continue; + } + + boolean shouldJoin = false; + int parentIdx = -1; + + if ((isContinuationLine(trimmed) || startsWithDeclarationKeyword(trimmed)) + && !out.isEmpty()) { + int idx = out.size() - 1; + while (idx >= 0 && out.get(idx).trim().isEmpty()) idx--; + if (idx >= 0) { + String parentTrimmed = out.get(idx).trim(); + char last = parentTrimmed.isEmpty() ? 0 : parentTrimmed.charAt(parentTrimmed.length() - 1); + if (last != '{' && last != '}' && last != ';') { + shouldJoin = true; + parentIdx = idx; + } + } + } + + if (!shouldJoin && !out.isEmpty()) { + int idx = out.size() - 1; + while (idx >= 0 && out.get(idx).trim().isEmpty()) idx--; + if (idx >= 0) { + String parentTrimmed = out.get(idx).trim(); + if (!parentTrimmed.isEmpty()) { + char last = parentTrimmed.charAt(parentTrimmed.length() - 1); + if (last == '&' || last == '|' || last == '<' || last == '+' + || last == '-' || last == '?' || last == ',' || last == '>') { + shouldJoin = true; + parentIdx = idx; + } + } + } + } + + if (shouldJoin) { + out.set(parentIdx, trimTrailing(out.get(parentIdx)) + " " + trimmed); + continue; + } + + out.add(line); + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < out.size(); i++) { + sb.append(out.get(i)); + if (i < out.size() - 1) sb.append("\n"); + } + return sb.toString(); + } + + private boolean startsWithDeclarationKeyword(String trimmed) { + for (String kw : DECLARATION_BREAK_KEYWORDS) { + if (trimmed.startsWith(kw) + && (trimmed.length() == kw.length() + || !Character.isJavaIdentifierPart(trimmed.charAt(kw.length())))) { + return true; + } + } + return false; + } + /** * Get visual length of a line (tabs count as 4 spaces). */ @@ -1987,6 +2076,13 @@ public class FormatHelper { * Core code wrapping logic (extracted from old wrapCodeLine, operates on code without comments). */ private String wrapCodeContent(String content, String indent, int maxWidth) { + if (settings.wrapMethodArguments) { + String argWrapped = tryArgumentAwareWrap(content, indent, maxWidth); + if (argWrapped != null) { + return argWrapped; + } + } + String wrapIndent = indent + spaces(settings.wrapIndentSpaces); String fullLine = indent + content; @@ -2005,36 +2101,28 @@ public class FormatHelper { int currentLen = getVisualLength(indent); int lastBreak = 0; - boolean isFirstLine = true; for (BreakPoint bp : breakPoints) { int segmentEnd = bp.position; String segment = content.substring(lastBreak, segmentEnd); int segmentLen = getVisualLength(segment); - if (currentLen + segmentLen > maxWidth && !isFirstLine) { + if (currentLen + segmentLen > maxWidth && lastBreak > 0) { result.append("\n").append(wrapIndent); currentLen = getVisualLength(wrapIndent); - isFirstLine = false; - segment = trimLeading(segment); - } else if (currentLen + segmentLen > maxWidth && isFirstLine && lastBreak > 0) { - result.append("\n").append(wrapIndent); - currentLen = getVisualLength(wrapIndent); - isFirstLine = false; } result.append(segment); currentLen += getVisualLength(segment); lastBreak = segmentEnd; - isFirstLine = false; } if (lastBreak < content.length()) { String remaining = content.substring(lastBreak); int remLen = getVisualLength(remaining); - if (currentLen + remLen > maxWidth && !isFirstLine) { + if (currentLen + remLen > maxWidth && lastBreak > 0) { result.append("\n").append(wrapIndent); remaining = trimLeading(remaining); } @@ -2046,8 +2134,188 @@ public class FormatHelper { } /** - * Trim leading whitespace (Java 8 compatible). + * Try argument-aware wrapping for method/constructor calls. + * When a call like {@code new Foo(arg1, arg2, arg3)} exceeds maxWidth, + * break after the opening paren, put each argument on its own line, + * and close paren on its own line: + *

+     * new Foo(
+     *     arg1,
+     *     arg2,
+     *     arg3
+     * )
+     * 
+ * + * @return the wrapped string, or {@code null} if this content doesn't + * qualify for argument-aware wrapping */ + private String tryArgumentAwareWrap(String content, String indent, int maxWidth) { + String fullLine = indent + content; + if (getVisualLength(fullLine) <= maxWidth) { + return null; + } + + int callParenIdx = findOutermostCallParen(content); + if (callParenIdx < 0) { + return null; + } + + int matchingClose = findMatchingClose(content, callParenIdx); + if (matchingClose < 0 || matchingClose >= content.length()) { + return null; + } + + String prefix = content.substring(0, callParenIdx + 1); + String argsBody = content.substring(callParenIdx + 1, matchingClose); + String suffix = content.substring(matchingClose); + + List args = splitArguments(argsBody); + if (args.size() < 2) { + return null; + } + + String argIndent = indent + spaces(settings.wrapIndentSpaces); + String closeIndent = indent; + + StringBuilder result = new StringBuilder(); + result.append(indent).append(prefix).append("\n"); + + for (int i = 0; i < args.size(); i++) { + String arg = args.get(i).trim(); + if (arg.isEmpty()) continue; + + result.append(argIndent).append(arg); + if (i < args.size() - 1) { + result.append(","); + } + result.append("\n"); + } + + result.append(closeIndent).append(suffix); + + String wrapped = result.toString(); + if (getVisualLength(indent + prefix) <= maxWidth) { + return wrapped; + } + + return wrapped; + } + + /** + * Find the opening paren of the outermost method/constructor call + * that should trigger argument-aware wrapping. + * Looks for patterns: {@code identifier(}, {@code new Type(}, {@code .method(} + * + * @return index of the '(' or -1 if no suitable call found + */ + private int findOutermostCallParen(String content) { + int bestIdx = -1; + boolean inString = false; + char stringChar = 0; + + for (int i = 0; i < content.length(); i++) { + char c = content.charAt(i); + char prev = i > 0 ? content.charAt(i - 1) : ' '; + + if ((c == '"' || c == '\'') && prev != '\\') { + if (!inString) { + inString = true; + stringChar = c; + } else if (c == stringChar) { + inString = false; + } + } + if (inString) continue; + + if (c == '(') { + if (i > 0 && (Character.isJavaIdentifierPart(content.charAt(i - 1)) || + content.charAt(i - 1) == '>')) { + bestIdx = i; + break; + } + } + } + + return bestIdx; + } + + private int findMatchingClose(String content, int openIdx) { + int depth = 1; + boolean inString = false; + char stringChar = 0; + + for (int i = openIdx + 1; i < content.length(); i++) { + char c = content.charAt(i); + char prev = i > 0 ? content.charAt(i - 1) : ' '; + + if ((c == '"' || c == '\'') && prev != '\\') { + if (!inString) { + inString = true; + stringChar = c; + } else if (c == stringChar) { + inString = false; + } + } + if (inString) continue; + + if (c == '(') depth++; + else if (c == ')') { + depth--; + if (depth == 0) return i; + } + } + + return -1; + } + + /** + * Split argument body by top-level commas, preserving nested parens, + * brackets, braces, strings, and lambda bodies. + */ + private List splitArguments(String argsBody) { + List args = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + int depth = 0; + boolean inString = false; + char stringChar = 0; + + for (int i = 0; i < argsBody.length(); i++) { + char c = argsBody.charAt(i); + char prev = i > 0 ? argsBody.charAt(i - 1) : ' '; + + if ((c == '"' || c == '\'') && prev != '\\') { + if (!inString) { + inString = true; + stringChar = c; + } else if (c == stringChar) { + inString = false; + } + } + + if (inString) { + current.append(c); + continue; + } + + if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') depth--; + + if (c == ',' && depth == 0) { + args.add(current.toString()); + current = new StringBuilder(); + continue; + } + + current.append(c); + } + + if (current.length() > 0) { + args.add(current.toString()); + } + + return args; + } + private String trimLeading(String s) { int start = 0; while (start < s.length() && Character.isWhitespace(s.charAt(start))) { @@ -2070,6 +2338,10 @@ public class FormatHelper { } } + private static final String[] DECLARATION_BREAK_KEYWORDS = { + "extends", "implements", "throws", "permits" + }; + /** * Find good positions to break a line. */ @@ -2125,15 +2397,41 @@ public class FormatHelper { if ((c == '+' || c == '-') && i > 0 && i < content.length() - 1) { char next = content.charAt(i + 1); if (prev != '(' && prev != '[' && prev != ',' && prev != '=' && - next != '+' && next != '-' && next != '=') { + next != '+' && next != '-' && next != '=' && next != '>') { points.add(new BreakPoint(i + 1, 3 + depth)); } } if (c == '&' && i + 1 < content.length() && content.charAt(i + 1) == '&') { points.add(new BreakPoint(i + 2, 2 + depth)); + i++; // skip second & + continue; } if (c == '|' && i + 1 < content.length() && content.charAt(i + 1) == '|') { points.add(new BreakPoint(i + 2, 2 + depth)); + i++; // skip second | + continue; + } + // Single & as type intersection operator (e.g. T extends A & B) + if (c == '&' && i > 0 && i < content.length() - 1 + && content.charAt(i + 1) != '&' && content.charAt(i + 1) != '=') { + points.add(new BreakPoint(i + 1, 3 + depth)); + } + if (c == '?' && depth == 0 && i > 0 && i < content.length() - 1) { + points.add(new BreakPoint(i, 2)); + } + if (c == ':' && depth == 0 && i > 0 && i < content.length() - 1) { + if (prev != ':' && (i + 1 >= content.length() || content.charAt(i + 1) != ':')) { + points.add(new BreakPoint(i, 2)); + } + } + if (c == '=' && depth == 0 && i > 0 && i < content.length() - 1) { + char next = content.charAt(i + 1); + if (prev != '=' && prev != '!' && prev != '<' && prev != '>' && + prev != '+' && prev != '-' && prev != '*' && prev != '/' && + prev != '%' && prev != '&' && prev != '|' && prev != '^' && + next != '=' && next != '>') { + points.add(new BreakPoint(i + 1, 4 + depth)); + } } } @@ -2142,9 +2440,28 @@ public class FormatHelper { points.add(new BreakPoint(i, 2 + depth)); } - // After opening brace - if (c == '{' && i + 1 < content.length()) { - points.add(new BreakPoint(i + 1, 1)); + // After opening brace (even when last char — allows break before {) + if (c == '{') { + if (i + 1 < content.length()) { + points.add(new BreakPoint(i + 1, 1)); + } + // Break before { so the brace can move to its own line + if (i > 0) { + points.add(new BreakPoint(i, 2)); + } + } + + // Break before declaration keywords (extends, implements, throws, permits) + if (c == ' ' && i + 1 < content.length() && Character.isLetter(content.charAt(i + 1))) { + for (String kw : DECLARATION_BREAK_KEYWORDS) { + if (i + 1 + kw.length() <= content.length() + && content.substring(i + 1, i + 1 + kw.length()).equals(kw) + && (i + 1 + kw.length() >= content.length() + || !Character.isJavaIdentifierPart(content.charAt(i + 1 + kw.length())))) { + points.add(new BreakPoint(i + 1, 2)); + break; + } + } } } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/IndentHelper.java b/src/main/java/noppes/npcs/client/gui/util/script/IndentHelper.java index 12a5567ad..30a63dfe2 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/IndentHelper.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/IndentHelper.java @@ -157,9 +157,9 @@ public class IndentHelper { * * @param text The text to format * @param cursorPosition Current cursor position - * @param viewportWidth Viewport width for line wrapping (0 = no wrapping) + * @param maxLineLength Maximum line length in characters for wrapping (0 = no wrapping) */ - public static FormatResult formatText(String text, int cursorPosition, int viewportWidth) { + public static FormatResult formatText(String text, int cursorPosition, int maxLineLength) { // First pass: fix indentation String indented = formatIndentation(text, cursorPosition); @@ -167,19 +167,17 @@ public class IndentHelper { FormatHelper helper = new FormatHelper(); FormatHelper.FormatSettings settings = helper.getSettings(); - // Enable wrapping if viewport width is provided - if (viewportWidth > 0) { + // Enable wrapping if max line length is provided + if (maxLineLength > 0) { settings.wrapLongLines = true; settings.wrapComments = true; - // Use 75% of viewport width as max line length for some margin - int charWidth = 6; // Approximate character width - settings.maxLineLength = Math.max(60, (int) (viewportWidth * 0.8f / charWidth)); + settings.maxLineLength = maxLineLength; } String formatted = helper.format(indented); // Apply wrapping if enabled - if (viewportWidth > 0 && settings.wrapLongLines) { + if (maxLineLength > 0 && settings.wrapLongLines) { formatted = helper.wrapLines(formatted, settings.maxLineLength); }