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 4ecd25847..f9b1c74ca 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -23,6 +23,8 @@ import static net.minecraft.client.gui.GuiScreen.isCtrlKeyDown; * smooth scrolling, and IDE-like features. * * Helper classes used: + * - ScrollState: smooth scroll animation and state management + * - SelectionState: cursor position and text selection management * - BracketMatcher: bracket matching and brace span computation * - IndentHelper: indentation utilities and text formatting * - CommentHandler: line comment toggling @@ -44,7 +46,6 @@ public class GuiScriptTextArea extends GuiNpcTextField { public boolean clicked = false; public boolean doubleClicked = false; public boolean tripleClicked = false; - public boolean clickScrolling = false; private int clickCount = 0; private long lastClicked = 0L; @@ -52,28 +53,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { public String text = null; private JavaTextContainer container = null; private boolean enableCodeHighlighting = false; - - // ==================== SELECTION STATE ==================== - private int startSelection; - private int endSelection; - private int cursorPosition; - - // ==================== SCROLL STATE ==================== - private int scrolledLine = 0; - private double scrollPos = 0.0; - private double targetScroll = 0.0; - private double scrollVelocity = 0.0; - private long lastScrollTime = 0L; - private int scrollbarDragOffset = 0; - - // Scroll animation constants - private static final double SCROLL_TAU = 0.1; // Time constant (~55ms) - private static final double SCROLL_SNAP = 0.01; // Snap threshold - private static final double SCROLL_MAX_DT = 0.05; // Max delta time + + // ==================== HELPER CLASS INSTANCES ==================== + private final ScrollState scroll = new ScrollState(); + private final SelectionState selection = new SelectionState(); // ==================== UI COMPONENTS ==================== private int cursorCounter; - private long lastInputTime = 0L; private ITextChangeListener listener; private static int LINE_NUMBER_GUTTER_WIDTH = 25; @@ -133,94 +119,56 @@ public class GuiScriptTextArea extends GuiNpcTextField { container.visibleLines = height / container.lineHeight-1; int maxScroll = Math.max(0, this.container.linesCount - container.visibleLines); + + // Handle mouse wheel scroll if (listener instanceof GuiNPCInterface) { - int k2 = ((GuiNPCInterface) listener).mouseScroll = Mouse.getDWheel(); - if (k2 != 0) { - // update target scroll smoothly instead of jumping - double sign = Math.copySign(1, k2); - targetScroll -= sign * 2.0; // one line per wheel tick - if (targetScroll < 0) targetScroll = 0; - if (targetScroll > maxScroll) targetScroll = maxScroll; + int wheelDelta = ((GuiNPCInterface) listener).mouseScroll = Mouse.getDWheel(); + if (wheelDelta != 0) { + scroll.applyWheelScroll(wheelDelta, maxScroll); } } - if (clickScrolling) { - // keep dragging while mouse button is down - clickScrolling = Mouse.isButtonDown(0); + // Handle scrollbar dragging + if (scroll.isClickScrolling()) { + scroll.setClickScrolling(Mouse.isButtonDown(0)); int diff = Math.max(0, container.linesCount - container.visibleLines); if (diff > 0) { int sbSize = Math.max((int) (1f * container.visibleLines / container.linesCount * height), 2); int trackTop = y + 1; int trackHeight = Math.max(1, height - 4); int thumbRange = Math.max(1, trackHeight - sbSize); - - // current thumb top for hit testing double linesCountD = Math.max(1, (double) container.linesCount); - int thumbTop = (int) (y + 1f * scrollPos / linesCountD * (height - 4)) + 1; + int thumbTop = (int) (y + 1f * scroll.getScrollPos() / linesCountD * (height - 4)) + 1; - // If user clicked on the track (outside the thumb), jump to proportional position if (yMouse < thumbTop || yMouse > thumbTop + sbSize) { double centerRatio = (double) (yMouse - trackTop) / (double) trackHeight; centerRatio = Math.max(0.0, Math.min(1.0, centerRatio)); - targetScroll = Math.max(0, Math.min(diff, centerRatio * diff)); - // prepare drag offset for subsequent drags (center grab) - scrollbarDragOffset = sbSize / 2; + scroll.setTargetScroll(centerRatio * diff, maxScroll); + scroll.setScrollbarDragOffset(sbSize / 2); } else { - // Dragging the thumb itself: preserve initial grab offset - int desiredTop = yMouse - scrollbarDragOffset; + int desiredTop = yMouse - scroll.getScrollbarDragOffset(); desiredTop = Math.max(trackTop, Math.min(trackTop + thumbRange, desiredTop)); double ratio = (double) (desiredTop - trackTop) / (double) thumbRange; ratio = Math.max(0.0, Math.min(1.0, ratio)); - targetScroll = Math.max(0, Math.min(diff, ratio * diff)); + scroll.setTargetScroll(ratio * diff, maxScroll); } } - if (!clickScrolling) { - // reset drag offset when released - scrollbarDragOffset = 0; + if (!scroll.isClickScrolling()) { + scroll.setScrollbarDragOffset(0); } } - // Animate scrollPos towards targetScroll using exponential smoothing - long nowMs = System.currentTimeMillis(); - if (scrollPos < 0) { - scrollPos = scrolledLine; // initialize - lastScrollTime = nowMs; - } - double dt = Math.min(0.05, (nowMs - lastScrollTime) / 1000.0); // clamp dt for stability - lastScrollTime = nowMs; - - double dist = targetScroll - scrollPos; - // If close enough, snap to target to avoid long slow tail - if (Math.abs(dist) < 0.01) { - scrollPos = targetScroll; - scrollVelocity = 0.0; - } else { - // Time constant controlling speed (seconds). Smaller -> faster snap. - final double tau = 0.1; // ~55ms time constant feels snappy - double alpha = 1.0 - Math.exp(-dt / Math.max(1e-6, tau)); - double prev = scrollPos; - scrollPos += dist * alpha; - scrollVelocity = (scrollPos - prev) / (dt > 0 ? dt : 1e-6); - // Clamp overshoot - if ((dist > 0 && scrollPos > targetScroll) || (dist < 0 && scrollPos < targetScroll)) { - scrollPos = targetScroll; - scrollVelocity = 0.0; - } - } - // Ensure scrollPos stays in valid bounds - if (scrollPos < 0) scrollPos = 0; - if (scrollPos > maxScroll) scrollPos = maxScroll; - - // Update integer scrolledLine for compatibility - scrolledLine = Math.max(0, Math.min((int) Math.floor(scrollPos), maxScroll)); - + // Update scroll animation + scroll.initializeIfNeeded(scroll.getScrolledLine()); + scroll.update(maxScroll); + // Handle click-dragging for selection if (clicked) { clicked = Mouse.isButtonDown(0); int i = getSelectionPos(xMouse, yMouse); - if (i != cursorPosition) { + if (i != selection.getCursorPosition()) { if (doubleClicked || tripleClicked) { - startSelection = endSelection = cursorPosition; + selection.reset(selection.getCursorPosition()); doubleClicked = false; tripleClicked = false; } @@ -231,11 +179,11 @@ public class GuiScriptTextArea extends GuiNpcTextField { tripleClicked = false; } - //Calculate braces next to cursor to highlight + // Calculate braces next to cursor to highlight int startBracket = 0, endBracket = 0; - if (startSelection >= 0 && text != null && text.length() > 0 && - (endSelection - startSelection == 1 || startSelection == endSelection)) { - int[] span = findBracketSpanAt(startSelection); + if (selection.getStartSelection() >= 0 && text != null && text.length() > 0 && + (selection.getEndSelection() - selection.getStartSelection() == 1 || !selection.hasSelection())) { + int[] span = BracketMatcher.findBracketSpanAt(text,selection.getStartSelection()); if (span != null) { startBracket = span[0]; endBracket = span[1]; @@ -245,7 +193,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { List list = new ArrayList<>(container.lines); // Build brace spans: {origDepth, open line, close line, adjustedDepth} - List braceSpans = computeBraceSpans(text, list); + List braceSpans = BracketMatcher.computeBraceSpans(text, list); // Determine which exact brace span (openLine/closeLine) should be highlighted based on bracket under caret int highlightedOpenLine = -1; @@ -273,20 +221,20 @@ public class GuiScriptTextArea extends GuiNpcTextField { } String wordHightLight = null; - if (startSelection != endSelection) { + if (selection.hasSelection()) { Matcher m = container.regexWord.matcher(text); while (m.find()) { - if (m.start() == startSelection && m.end() == endSelection) { - wordHightLight = text.substring(startSelection, endSelection); + if (m.start() == selection.getStartSelection() && m.end() == selection.getEndSelection()) { + wordHightLight = text.substring(selection.getStartSelection(), selection.getEndSelection()); } } } // Expand render range by one line above/below so partially-visible lines are drawn - int renderStart = Math.max(0, scrolledLine - 1); - int renderEnd = Math.min(list.size() - 1, scrolledLine + container.visibleLines + 1); + int renderStart = Math.max(0, scroll.getScrolledLine() - 1); + int renderEnd = Math.min(list.size() - 1, scroll.getScrolledLine() + container.visibleLines + 1); // Apply fractional GL translate for sub-pixel smooth scrolling - double fracOffset = scrollPos - scrolledLine; + double fracOffset = scroll.getFractionalOffset(); float fracPixels = (float) (fracOffset * container.lineHeight); GL11.glPushMatrix(); GL11.glTranslatef(0.0f, -fracPixels, 0.0f); @@ -301,7 +249,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Render LINE GUTTER numbers for (int i = renderStart; i <= renderEnd; i++) { - int posY = y + (i - scrolledLine) * container.lineHeight + stringYOffset; + int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight + stringYOffset; String lineNum = String.valueOf(i + 1); int lineNumWidth = ClientProxy.Font.width(lineNum); int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5; // right-align with 5px padding @@ -311,7 +259,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (active && isEnabled()) { for (int li = 0; li < list.size(); li++) { LineData ld = list.get(li); - if (cursorPosition >= ld.start && cursorPosition < ld.end || (li == list.size() - 1 && cursorPosition == text.length())) { + if (selection.getCursorPosition() >= ld.start && selection.getCursorPosition() < ld.end || (li == list.size() - 1 && selection.getCursorPosition() == text.length())) { if (li == i) { lineNumColor = 0xFFb9c7d6; break; @@ -327,13 +275,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { GL11.glPushMatrix(); GL11.glTranslatef(0.0f, -fracPixels, 0.0f); - renderStart = Math.max(0, scrolledLine - 1); + renderStart = Math.max(0, scroll.getScrolledLine() - 1); for (int i = renderStart; i <= renderEnd; i++) { LineData data = list.get(i); String line = data.text; int w = line.length(); // Use integer Y relative to scrolledLine; fractional offset applied via GL translate - int posY = y + (i - scrolledLine) * container.lineHeight; + int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight; if (i >= renderStart && i <= renderEnd) { //Highlight braces the cursor position is on if (startBracket != endBracket) { @@ -360,21 +308,23 @@ public class GuiScriptTextArea extends GuiNpcTextField { } } // Highlight the current line (light gray) under any selection - if (active && isEnabled() && (cursorPosition >= data.start && cursorPosition < data.end || (i == list.size() - 1 && cursorPosition == text.length()))) { + if (active && isEnabled() && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { drawRect(x , posY, x + width - 1, posY + container.lineHeight, 0x22e0e0e0); } // Highlight selection - if (startSelection != endSelection && endSelection > data.start && startSelection <= data.end) { - if (startSelection < data.end) { - int s = ClientProxy.Font.width(line.substring(0, Math.max(startSelection - data.start, 0))); - int e = ClientProxy.Font.width(line.substring(0, Math.min(endSelection - data.start, w))) + 1; + if (selection.hasSelection() && selection.getEndSelection() > data.start && selection.getStartSelection() <= data.end) { + if (selection.getStartSelection() < data.end) { + int s = ClientProxy.Font.width( + line.substring(0, Math.max(selection.getStartSelection() - data.start, 0))); + int e = ClientProxy.Font.width( + line.substring(0, Math.min(selection.getEndSelection() - data.start, w))) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x992172ff); } } // Draw indent guides once per visible block based on brace spans - if (i == Math.max(0, scrolledLine) && !braceSpans.isEmpty()) { - int visStart = Math.max(0, scrolledLine); + if (i == Math.max(0, scroll.getScrolledLine()) && !braceSpans.isEmpty()) { + int visStart = Math.max(0, scroll.getScrolledLine()); int visEnd = Math.min(list.size() - 1, visStart + container.visibleLines - 0); for (int[] span : braceSpans) { int originalDepth = span[0]; @@ -402,8 +352,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { int px = ClientProxy.Font.width(sb.toString()); int gx = x + LINE_NUMBER_GUTTER_WIDTH + 4 + px - 2; // shift left ~2px for the IntelliJ feel - int topY = y + (drawStart - scrolledLine) * container.lineHeight; - int bottomY = y + (endLine - scrolledLine + 1) * container.lineHeight; + int topY = y + (drawStart - scroll.getScrolledLine()) * container.lineHeight; + int bottomY = y + (endLine - scroll.getScrolledLine() + 1) * container.lineHeight; int guideColor = (openLine == highlightedOpenLine && closeLine == highlightedCloseLine) ? 0x9933cc00 : 0x33FFFFFF; drawRect(gx, topY, gx + 1, bottomY, guideColor); } @@ -412,10 +362,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { data.drawString(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0); // Draw cursor: pause blinking while user is active recently - boolean recentInput = System.currentTimeMillis() - this.lastInputTime < 500; - if (active && isEnabled() && (recentInput || (cursorCounter / 10) % 2 == 0) && (cursorPosition >= data.start && cursorPosition < data.end || (i == list.size() - 1 && cursorPosition == text.length()))) { + boolean recentInput = selection.hadRecentInput(); + if (active && isEnabled() && (recentInput || (cursorCounter / 10) % 2 == 0) && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { int posX = x + LINE_NUMBER_GUTTER_WIDTH + ClientProxy.Font.width( - line.substring(0, Math.min(cursorPosition - data.start, line.length()))); + line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length()))); drawRect(posX + 1, yPos -1, posX + 2, yPos - 1 + container.lineHeight, 0xffffffff); } } @@ -428,30 +378,12 @@ public class GuiScriptTextArea extends GuiNpcTextField { int posX = x + width - 6; double linesCount = Math.max(1, (double) container.linesCount); - int posY = (int) (y + 1f * scrollPos / linesCount * (height - 4)) + 1; + int posY = (int) (y + 1f * scroll.getScrollPos() / linesCount * (height - 4)) + 1; drawRect(posX, posY, posX + 5, posY + sbSize + 2, 0xFFe0e0e0); } GL11.glDisable(GL11.GL_SCISSOR_TEST); } - // ==================== BRACKET MATCHING ==================== - // Uses BracketMatcher helper for bracket span detection - - private int[] findBracketSpanAt(int pos) { - return BracketMatcher.findBracketSpanAt(text, pos); - } - - private int findClosingBracket(String str, char s, char e) { - return BracketMatcher.findClosingBracket(str, s, e); - } - - private int findOpeningBracket(String str, char s, char e) { - return BracketMatcher.findOpeningBracket(str, s, e); - } - - private List computeBraceSpans(String fullText, List lines) { - return BracketMatcher.computeBraceSpans(fullText, lines); - } // ==================== SELECTION & CURSOR POSITION ==================== @@ -459,16 +391,15 @@ public class GuiScriptTextArea extends GuiNpcTextField { xMouse -= (this.x + LINE_NUMBER_GUTTER_WIDTH + 1); yMouse -= this.y + 1; // Adjust yMouse to account for fractional GL translation (negative offset applied in rendering) - double fracOffset = scrollPos - scrolledLine; - double fracPixels = fracOffset * container.lineHeight; + double fracPixels = scroll.getFractionalOffset() * container.lineHeight; yMouse = (int) Math.round(yMouse + fracPixels); ArrayList list = new ArrayList(this.container.lines); for (int i = 0; i < list.size(); ++i) { LineData data = (LineData) list.get(i); - if (i >= this.scrolledLine && i <= this.scrolledLine + this.container.visibleLines) { - int yPos = (i - this.scrolledLine) * this.container.lineHeight; + if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines) { + int yPos = (i - scroll.getScrolledLine()) * this.container.lineHeight; if (yMouse >= yPos && yMouse < yPos + this.container.lineHeight) { int lineWidth = 0; char[] chars = data.text.toCharArray(); @@ -498,30 +429,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Find which line the cursor is on (0-indexed) private int getCursorLineIndex() { - if (container == null || container.lines == null || container.lines.isEmpty()) return 0; - for (int i = 0; i < container.lines.size(); i++) { - LineData ld = container.lines.get(i); - // For last line, use <= since end doesn't include +1 for newline - // For other lines, use < since end includes the newline position - boolean isLastLine = (i == container.lines.size() - 1); - boolean isOnLine = isLastLine - ? (cursorPosition >= ld.start && cursorPosition <= ld.end) - : (cursorPosition >= ld.start && cursorPosition < ld.end); - if (isOnLine) { - return i; - } - } - return container.lines.size() - 1; + return selection.getCursorLineIndex(container.lines, text != null ? text.length() : 0); } - // Check if cursor is on a specific line, handling the last line specially - // (last line doesn't have trailing newline, so use <= for end boundary) - private boolean isCursorOnLine(int lineIndex, LineData line) { - boolean isLastLine = (lineIndex == container.lines.size() - 1); - return isLastLine - ? (cursorPosition >= line.start && cursorPosition <= line.end) - : (cursorPosition >= line.start && cursorPosition < line.end); - } // Scroll viewport to keep cursor visible (minimal adjustment, like IntelliJ) // Only scrolls if cursor is outside the visible area @@ -531,23 +441,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { int lineIdx = getCursorLineIndex(); int visible = Math.max(1, container.visibleLines); int maxScroll = Math.max(0, container.linesCount - visible); - - int firstVisible = scrolledLine; - int lastFullyVisible = scrolledLine + visible; - - if (lineIdx < firstVisible) { - // Cursor is above viewport - scroll up just enough to show it - targetScroll = lineIdx; - } else if (lineIdx > lastFullyVisible) { - // Cursor is below viewport - scroll down just enough to show it - targetScroll = Math.min(lineIdx - visible, maxScroll); - } - // If cursor is visible, don't change scroll - } - - // Legacy method name for compatibility - private void ensureCursorVisible() { - scrollToCursor(); + + scroll.scrollToLine(lineIdx, visible, maxScroll); } // ==================== KEYBOARD INPUT HANDLING ==================== @@ -558,8 +453,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { return false; if (this.isKeyComboCtrlA(i)) { - startSelection = cursorPosition = 0; - endSelection = text.length(); + selection.selectAll(text.length()); return true; } @@ -568,7 +462,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { LineData currentLine = null; for (LineData line : this.container.lines) { - if (startSelection >= line.start && startSelection < line.end) { + if (selection.getStartSelection() >= line.start && selection.getStartSelection() < line.end) { currentLine = line; break; } @@ -578,26 +472,26 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (i == Keyboard.KEY_LEFT) { int j = 1; if (isCtrlKeyDown()) { - Matcher m = container.regexWord.matcher(text.substring(0, cursorPosition)); + Matcher m = container.regexWord.matcher(text.substring(0, selection.getCursorPosition())); while (m.find()) { if (m.start() == m.end()) continue; - j = cursorPosition - m.start(); + j = selection.getCursorPosition() - m.start(); } } - int newPos = Math.max(cursorPosition - j, 0); + int newPos = Math.max(selection.getCursorPosition() - j, 0); setCursor(newPos, GuiScreen.isShiftKeyDown()); return true; } if (i == Keyboard.KEY_RIGHT) { int j = 1; if (isCtrlKeyDown()) { - Matcher m = container.regexWord.matcher(text.substring(cursorPosition)); + Matcher m = container.regexWord.matcher(text.substring(selection.getCursorPosition())); if (m.find() && m.start() > 0 || m.find()) { j = m.start(); } } - int newPos = Math.min(cursorPosition + j, text.length()); + int newPos = Math.min(selection.getCursorPosition() + j, text.length()); setCursor(newPos, GuiScreen.isShiftKeyDown()); return true; } @@ -613,21 +507,21 @@ public class GuiScriptTextArea extends GuiNpcTextField { } if (i == Keyboard.KEY_DELETE) { String s = getSelectionAfterText(); - if (!s.isEmpty() && startSelection == endSelection) + if (!s.isEmpty() && !selection.hasSelection()) s = s.substring(1); setText(getSelectionBeforeText() + s); - endSelection = cursorPosition = startSelection; + selection.reset(selection.getStartSelection()); return true; } if (isKeyComboCtrlBackspace(i)) { String s = getSelectionBeforeText(); - if (startSelection > 0 && startSelection == endSelection) { - int nearestCondition = cursorPosition; + if (selection.getStartSelection() > 0 && !selection.hasSelection()) { + int nearestCondition = selection.getCursorPosition(); int g; - boolean cursorInWhitespace = Character.isWhitespace(s.charAt(cursorPosition - 1)); + boolean cursorInWhitespace = Character.isWhitespace(s.charAt(selection.getCursorPosition() - 1)); if (cursorInWhitespace) { // Find the nearest word if we are starting in whitespace - for (g = cursorPosition - 1; g >= 0; g--) { + for (g = selection.getCursorPosition() - 1; g >= 0; g--) { char currentChar = s.charAt(g); if (!Character.isWhitespace(currentChar)) { nearestCondition = g; @@ -639,7 +533,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { } } else { // Find the nearest blank space or new line - for (g = cursorPosition - 1; g >= 0; g--) { + for (g = selection.getCursorPosition() - 1; g >= 0; g--) { char currentChar = s.charAt(g); if (Character.isWhitespace(currentChar) || currentChar == '\n') { nearestCondition = g; @@ -653,47 +547,37 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Remove all text to the left up to the nearest boundary s = s.substring(0, nearestCondition); - startSelection -= (cursorPosition - nearestCondition); + selection.setStartSelection( + selection.getStartSelection() - (selection.getCursorPosition() - nearestCondition)); } setText(s + getSelectionAfterText()); - endSelection = cursorPosition = startSelection; + selection.reset(selection.getStartSelection()); return true; } if (i == Keyboard.KEY_BACK) { // Handle selection deletion first - if (startSelection != endSelection) { + if (selection.hasSelection()) { String s = getSelectionBeforeText(); setText(s + getSelectionAfterText()); - endSelection = cursorPosition = startSelection; + selection.reset(selection.getStartSelection()); scrollToCursor(); return true; } // Nothing to delete if at start - if (startSelection <= 0) { + if (selection.getStartSelection() <= 0) { return true; } // Find current line for indent-aware backspace - LineData curr = null; - for (int idx = 0; idx < container.lines.size(); idx++) { - LineData line = container.lines.get(idx); - boolean isLastLine = (idx == container.lines.size() - 1); - boolean isOnLine = isLastLine - ? (cursorPosition >= line.start && cursorPosition <= line.end) - : (cursorPosition >= line.start && cursorPosition < line.end); - if (isOnLine) { - curr = line; - break; - } - } + LineData curr = selection.findCurrentLine(container.lines); // Indent-aware backspace: if cursor is at or before the EXPECTED indent, merge with previous line // If cursor is after expected indent (even in extra whitespace), do normal backspace if (curr != null && curr.start > 0) { - int col = cursorPosition - curr.start; - int actualIndent = getLineIndent(curr.text); - int expectedIndent = getExpectedIndent(curr); + int col = selection.getCursorPosition() - curr.start; + int actualIndent = IndentHelper.getLineIndent(curr.text); + int expectedIndent = IndentHelper.getExpectedIndent(curr, container.lines); // Only trigger smart merge if cursor is at or before the expected indent position if (col <= expectedIndent) { @@ -713,7 +597,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { setText(before + after); // Cursor goes to end of previous line (at the newline position, which is now end of prev line content) int newCursor = Math.max(0, curr.start - 1); - endSelection = cursorPosition = startSelection = newCursor; + selection.reset(newCursor); scrollToCursor(); return true; } else { @@ -741,7 +625,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { setText(before + spacer + content); int newCursor = before.length() + spacer.length(); - endSelection = cursorPosition = startSelection = newCursor; + selection.reset(newCursor); scrollToCursor(); return true; } @@ -749,19 +633,20 @@ public class GuiScriptTextArea extends GuiNpcTextField { } // Auto-pair deletion: if deleting an opening bracket/quote and next char is the matching closer - if (startSelection > 0 && startSelection < text.length()) { - char prev = text.charAt(startSelection - 1); - char nextc = text.charAt(startSelection); + if (selection.getStartSelection() > 0 && selection.getStartSelection() < text.length()) { + char prev = text.charAt(selection.getStartSelection() - 1); + char nextc = text.charAt(selection.getStartSelection()); if ((prev == '(' && nextc == ')') || (prev == '[' && nextc == ']') || (prev == '{' && nextc == '}') || (prev == '\'' && nextc == '\'') || (prev == '"' && nextc == '"')) { - String before = text.substring(0, startSelection - 1); - String after = startSelection + 1 < text.length() ? text.substring(startSelection + 1) : ""; + String before = text.substring(0, selection.getStartSelection() - 1); + String after = selection.getStartSelection() + 1 < text.length() ? text.substring( + selection.getStartSelection() + 1) : ""; setText(before + after); - startSelection -= 1; - endSelection = cursorPosition = startSelection; + selection.setStartSelection(selection.getStartSelection() - 1); + selection.reset(selection.getStartSelection()); scrollToCursor(); return true; } @@ -770,25 +655,25 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Normal backspace: delete one character String s = getSelectionBeforeText(); s = s.substring(0, s.length() - 1); - startSelection--; + selection.setStartSelection(selection.getStartSelection() - 1); setText(s + getSelectionAfterText()); - endSelection = cursorPosition = startSelection; + selection.reset(selection.getStartSelection()); scrollToCursor(); return true; } if (this.isKeyComboCtrlX(i)) { - if (startSelection != endSelection) { - NoppesStringUtils.setClipboardContents(text.substring(startSelection, endSelection)); + if (selection.hasSelection()) { + NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); String s = getSelectionBeforeText(); setText(s + getSelectionAfterText()); - endSelection = startSelection = cursorPosition = s.length(); + selection.reset(s.length()); scrollToCursor(); } return true; } if (this.isKeyComboCtrlC(i)) { - if (startSelection != endSelection) { - NoppesStringUtils.setClipboardContents(text.substring(startSelection, endSelection)); + if (selection.hasSelection()) { + NoppesStringUtils.setClipboardContents(selection.getSelectedText(text)); } return true; } @@ -801,10 +686,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (undoList.isEmpty()) return false; undoing = true; - redoList.add(new UndoData(this.text, this.cursorPosition)); + redoList.add(new UndoData(this.text, selection.getCursorPosition())); UndoData data = undoList.remove(undoList.size() - 1); setText(data.text); - endSelection = startSelection = cursorPosition = data.cursorPosition; + selection.reset(data.cursorPosition); undoing = false; scrollToCursor(); return true; @@ -813,10 +698,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (redoList.isEmpty()) return false; undoing = true; - undoList.add(new UndoData(this.text, this.cursorPosition)); + undoList.add(new UndoData(this.text, selection.getCursorPosition())); UndoData data = redoList.remove(redoList.size() - 1); setText(data.text); - endSelection = startSelection = cursorPosition = data.cursorPosition; + selection.reset(data.cursorPosition); undoing = false; scrollToCursor(); return true; @@ -836,7 +721,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { return true; } if (i == Keyboard.KEY_RETURN) { - if (cursorPosition > 0 && cursorPosition <= text.length() && text.charAt(cursorPosition - 1) == '{') { + if (selection.getCursorPosition() > 0 && selection.getCursorPosition() <= text.length() && text.charAt( + selection.getCursorPosition() - 1) == '{') { String indent = getIndentCurrentLine(); String childIndent = indent + " "; String before = getSelectionBeforeText(); @@ -860,7 +746,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { try { // Find the line index containing the opening brace (cursorPosition-1) int openLineIdx = -1; - int bracePos = cursorPosition - 1; + int bracePos = selection.getCursorPosition() - 1; for (int li = 0; li < this.container.lines.size(); li++) { LineData ld = this.container.lines.get(li); if (bracePos >= ld.start && bracePos < ld.end) { @@ -870,13 +756,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { } if (openLineIdx >= 0) { - List spans = computeBraceSpans(text, this.container.lines); + List spans = BracketMatcher.computeBraceSpans(text, this.container.lines); for (int[] span : spans) { int spanOpen = span[1]; int spanClose = span[2]; if (spanOpen == openLineIdx) { // Found a matching close; check its indent equals current indent - int closeIndent = getLineIndent(this.container.lines.get(spanClose).text); + int closeIndent = IndentHelper.getLineIndent(this.container.lines.get(spanClose).text); if (closeIndent == indent.length()) { hasMatchingCloseSameIndent = true; } @@ -897,7 +783,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { String insert = "\n" + childIndent + "\n" + indent + "}"; setText(before + insert + after); int newCursor = before.length() + 1 + childIndent.length(); - startSelection = endSelection = cursorPosition = newCursor; + selection.reset(newCursor); scrollToCursor(); } } else { @@ -912,32 +798,32 @@ public class GuiScriptTextArea extends GuiNpcTextField { // If typing a closing char and the next char is already that closer, skip over it if ((c == ')' || c == ']' || c == '"' || c == '\'' ) && after.length() > 0 && after.charAt(0) == c) { // move cursor forward over existing closer - startSelection = endSelection = cursorPosition = before.length() + 1; + selection.reset(before.length() + 1); scrollToCursor(); return true; } if (c == '"') { setText(before + "\"\"" + after); - startSelection = endSelection = cursorPosition = before.length() + 1; + selection.reset(before.length() + 1); scrollToCursor(); return true; } if (c == '\'') { setText(before + "''" + after); - startSelection = endSelection = cursorPosition = before.length() + 1; + selection.reset(before.length() + 1); scrollToCursor(); return true; } if (c == '[') { setText(before + "[]" + after); - startSelection = endSelection = cursorPosition = before.length() + 1; + selection.reset(before.length() + 1); scrollToCursor(); return true; } if (c == '(') { setText(before + "()" + after); - startSelection = endSelection = cursorPosition = before.length() + 1; + selection.reset(before.length() + 1); scrollToCursor(); return true; } @@ -947,7 +833,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { } if (i == Keyboard.KEY_SLASH && isCtrlKeyDown()) { - if (startSelection != endSelection) { + if (selection.hasSelection()) { toggleCommentSelection(); } else { toggleCommentLineAtCursor(); @@ -956,11 +842,11 @@ public class GuiScriptTextArea extends GuiNpcTextField { } if (i == Keyboard.KEY_D && isCtrlKeyDown()) { - if (startSelection != endSelection) { + if (selection.hasSelection()) { // Handle multi-line selection duplication LineData firstLine = null, lastLine = null; for (LineData line : container.lines) { - if (line.end > startSelection && line.start < endSelection) { + if (line.end > selection.getStartSelection() && line.start < selection.getEndSelection()) { if (firstLine == null) firstLine = line; lastLine = line; } @@ -970,19 +856,20 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Insert the selected block immediately after the last line without adding an extra newline String insertText = selectedText; // Save selection before setText - int savedStart = startSelection; - int savedEnd = endSelection; + int savedStart = selection.getStartSelection(); + int savedEnd = selection.getEndSelection(); int insertAt = lastLine.end; setText(text.substring(0, insertAt) + insertText + text.substring(insertAt)); // Restore cursor and selection - startSelection = savedStart; - endSelection = this.cursorPosition = savedEnd; + selection.setStartSelection(savedStart); + selection.setEndSelection(savedEnd); + selection.setCursorPositionDirect(savedEnd); return true; } } else { // Single line duplication (existing logic) for (LineData line : container.lines) { - if (cursorPosition >= line.start && cursorPosition <= line.end) { + if (selection.getCursorPosition() >= line.start && selection.getCursorPosition() <= line.end) { int lineStart = line.start, lineEnd = line.end; String lineText = text.substring(lineStart, lineEnd); // Avoid inserting an extra blank line: if lineText already ends with a newline, @@ -997,7 +884,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { setText(text.substring(0, insertionPoint) + insertText + text.substring(insertionPoint)); // Place cursor at end of duplicated line (just before any trailing newline) int newCursor = insertionPoint + insertText.length() - (insertText.endsWith("\n") ? 1 : 0); - startSelection = endSelection = cursorPosition = Math.max(0, Math.min(newCursor, this.text.length())); + selection.reset(Math.max(0, Math.min(newCursor, this.text.length()))); return true; } } @@ -1016,16 +903,16 @@ public class GuiScriptTextArea extends GuiNpcTextField { private void toggleCommentSelection() { CommentHandler.SelectionToggleResult result = CommentHandler.toggleCommentSelection( - text, container.lines, startSelection, endSelection); + text, container.lines, selection.getStartSelection(), selection.getEndSelection()); setText(result.newText); - startSelection = result.newStartSelection; - endSelection = result.newEndSelection; - cursorPosition = endSelection; + selection.setStartSelection(result.newStartSelection); + selection.setEndSelection(result.newEndSelection); + selection.setCursorPositionDirect(result.newEndSelection); } private void toggleCommentLineAtCursor() { CommentHandler.SingleLineToggleResult result = CommentHandler.toggleCommentAtCursor( - text, container.lines, cursorPosition); + text, container.lines, selection.getCursorPosition()); setText(result.newText); setCursor(result.newCursorPosition, false); } @@ -1058,7 +945,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { private String getIndentCurrentLine() { for (LineData data : this.container.lines) { - if (this.cursorPosition > data.start && this.cursorPosition <= data.end) { + if (selection.getCursorPosition() > data.start && selection.getCursorPosition() <= data.end) { int i; for (i = 0; i < data.text.length() && data.text.charAt(i) == ' '; ++i) { } @@ -1069,99 +956,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { } private String getAutoIndentForEnter() { - LineData currentLine = null; - for (LineData line : this.container.lines) { - if (this.cursorPosition >= line.start && this.cursorPosition < line.end) { - currentLine = line; - break; - } - } + LineData currentLine = selection.findCurrentLine(container.lines); if (currentLine == null) { return ""; } - - String lineText = currentLine.text; - int leading = 0; - while (leading < lineText.length() && (lineText.charAt(leading) == ' ' || lineText.charAt(leading) == '\t')) { - leading++; - } - - String baseIndent = lineText.substring(0, leading); - int relativeCursor = Math.max(0, Math.min(this.cursorPosition - currentLine.start, lineText.length())); - String beforeCursor = lineText.substring(0, relativeCursor); - - // Find last non-whitespace before cursor - int lastNonWs = -1; - for (int idx = beforeCursor.length() - 1; idx >= 0; idx--) { - if (!Character.isWhitespace(beforeCursor.charAt(idx))) { - lastNonWs = idx; - break; - } - } - - // Only add extra indent if cursor is actually after the opening brace (not just in trailing whitespace) - // Check that the last non-whitespace is at or after the line's indent position - boolean opensBlock = lastNonWs >= 0 && lastNonWs >= leading && beforeCursor.charAt(lastNonWs) == '{'; - if (opensBlock) { - return baseIndent + " "; - } - - return baseIndent; + return IndentHelper.getAutoIndentForEnter(currentLine.text, selection.getCursorPosition() - currentLine.start); } - - private int getLineIndent(String lineText) { - int leading = 0; - while (leading < lineText.length() && (lineText.charAt(leading) == ' ' || lineText.charAt( - leading) == '\t')) { - leading++; - } - return leading; - } - - // Calculate the expected/proper indent for the current line based on surrounding context - private int getExpectedIndent(LineData currentLine) { - if (currentLine == null) { - return 0; - } - - int idx = container.lines.indexOf(currentLine); - if (idx <= 0) { - return 0; // First line has no expected indent - } - - // Find the previous non-empty line - LineData prevLine = null; - for (int i = idx - 1; i >= 0; i--) { - LineData line = container.lines.get(i); - if (line.text.trim().length() > 0) { - prevLine = line; - break; - } - } - - if (prevLine == null) { - return 0; - } - - int prevIndent = getLineIndent(prevLine.text); - String prevTrimmed = prevLine.text.trim(); - String currTrimmed = currentLine.text.trim(); - - // If current line starts with }, expected indent is one level back - if (currTrimmed.startsWith("}")) { - return Math.max(0, prevIndent - getTabSize()); - } - - // If previous line ends with {, expected indent is one level forward - if (prevTrimmed.endsWith("{")) { - return prevIndent + getTabSize(); - } - - // Otherwise, expected indent matches previous line - return prevIndent; - } - // ==================== TEXT FORMATTING ==================== private int getTabSize() { @@ -1173,39 +974,28 @@ public class GuiScriptTextArea extends GuiNpcTextField { } private void formatText() { - IndentHelper.FormatResult result = IndentHelper.formatText(text, cursorPosition); + IndentHelper.FormatResult result = IndentHelper.formatText(text, selection.getCursorPosition()); setText(result.text); - startSelection = endSelection = cursorPosition = Math.max(0, Math.min(result.cursorPosition, this.text.length())); + selection.reset(Math.max(0, Math.min(result.cursorPosition, this.text.length()))); } // ==================== TAB HANDLING ==================== private void handleTab() { - LineData currentLine = null; - for (int i = 0; i < this.container.lines.size(); i++) { - LineData line = this.container.lines.get(i); - boolean isLastLine = (i == this.container.lines.size() - 1); - boolean isOnLine = isLastLine - ? (this.cursorPosition >= line.start && this.cursorPosition <= line.end) - : (this.cursorPosition >= line.start && this.cursorPosition < line.end); - if (isOnLine) { - currentLine = line; - break; - } - } + LineData currentLine = selection.findCurrentLine(container.lines); if (currentLine == null) { addText(" "); return; } int tab = getTabSize(); - int indentLen = getLineIndent(currentLine.text); + int indentLen = IndentHelper.getLineIndent(currentLine.text); int textStartPos = currentLine.start + indentLen; - if (this.cursorPosition <= textStartPos) { + if (selection.getCursorPosition() <= textStartPos) { // Cursor before any text: if cursor is exactly at text start, move forward to next tab stop. // If cursor is inside leading whitespace (before text start), choose nearest tab stop (tie -> forward). int targetIndent; - if (this.cursorPosition == textStartPos) { + if (selection.getCursorPosition() == textStartPos) { targetIndent = ((indentLen / tab) + 1) * tab; } else { int remainder = indentLen % tab; @@ -1231,10 +1021,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { String after = text.substring(sepEnd); setText(before + newIndent + rest + sep + after); int newCursor = currentLine.start + targetIndent; - startSelection = endSelection = cursorPosition = Math.min(newCursor, this.text.length()); + selection.reset(Math.min(newCursor, this.text.length())); } else { // Cursor is after start of text: insert spaces at cursor to move following text to next tab stop - int column = this.cursorPosition - currentLine.start; + int column = selection.getCursorPosition() - currentLine.start; int targetColumn = ((column / tab) + 1) * tab; int toInsert = Math.max(0, targetColumn - column); if (toInsert > 0) { @@ -1245,25 +1035,14 @@ public class GuiScriptTextArea extends GuiNpcTextField { } private void handleShiftTab() { - LineData currentLine = null; - for (int i = 0; i < this.container.lines.size(); i++) { - LineData line = this.container.lines.get(i); - boolean isLastLine = (i == this.container.lines.size() - 1); - boolean isOnLine = isLastLine - ? (this.cursorPosition >= line.start && this.cursorPosition <= line.end) - : (this.cursorPosition >= line.start && this.cursorPosition < line.end); - if (isOnLine) { - currentLine = line; - break; - } - } + LineData currentLine = selection.findCurrentLine(container.lines); if (currentLine == null) return; int tab = getTabSize(); - int indentLen = getLineIndent(currentLine.text); + int indentLen = IndentHelper.getLineIndent(currentLine.text); int textStartPos = currentLine.start + indentLen; - if (this.cursorPosition <= textStartPos) { + if (selection.getCursorPosition() <= textStartPos) { // Cursor before any text: reduce leading indent to previous tab stop int targetIndent = Math.max(0, ((indentLen - 1) / tab) * tab); String newIndent = repeatSpace(targetIndent); @@ -1275,14 +1054,14 @@ public class GuiScriptTextArea extends GuiNpcTextField { String after = text.substring(sepEnd); setText(before + newIndent + rest + sep + after); int newCursor = currentLine.start + targetIndent; - startSelection = endSelection = cursorPosition = Math.min(newCursor, this.text.length()); + selection.reset(Math.min(newCursor, this.text.length())); } else { // Cursor after start of text: remove up to previous tab stop worth of spaces immediately before cursor - int column = this.cursorPosition - currentLine.start; + int column = selection.getCursorPosition() - currentLine.start; int mod = column % tab; int toRemove = mod == 0 ? tab : mod; int removed = 0; - int pos = this.cursorPosition - 1; + int pos = selection.getCursorPosition() - 1; while (pos >= currentLine.start && removed < toRemove && text.charAt(pos) == ' ') { pos--; removed++; @@ -1290,10 +1069,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (removed > 0) { int removeStart = pos + 1; String before = text.substring(0, removeStart); - String after = text.substring(this.cursorPosition); + String after = text.substring(selection.getCursorPosition()); setText(before + after); int newCursor = removeStart; - startSelection = endSelection = cursorPosition = Math.min(newCursor, this.text.length()); + selection.reset(Math.min(newCursor, this.text.length())); } } } @@ -1301,104 +1080,29 @@ public class GuiScriptTextArea extends GuiNpcTextField { // ==================== CURSOR MANAGEMENT ==================== private void setCursor(int i, boolean select) { - i = Math.min(Math.max(i, 0), this.text.length()); - if (i != this.cursorPosition) { - if (!select) { - this.endSelection = this.startSelection = this.cursorPosition = i; - } else { - int diff = this.cursorPosition - i; - if (this.cursorPosition == this.startSelection) { - this.startSelection -= diff; - } else if (this.cursorPosition == this.endSelection) { - this.endSelection -= diff; - } - - if (this.startSelection > this.endSelection) { - int j = this.endSelection; - this.endSelection = this.startSelection; - this.startSelection = j; - } - - this.cursorPosition = i; - } - - clampSelectionBounds(); - // Moving the cursor is user activity — pause blinking briefly - this.lastInputTime = System.currentTimeMillis(); - } + selection.setCursor(i, text != null ? text.length() : 0, select); } private void addText(String s) { + int insertPos = selection.getStartSelection(); this.setText(this.getSelectionBeforeText() + s + this.getSelectionAfterText()); - this.endSelection = this.startSelection = this.cursorPosition = this.startSelection + s.length(); - this.lastInputTime = System.currentTimeMillis(); + selection.afterTextInsert(insertPos + s.length()); } private int cursorUp() { - for (int i = 0; i < this.container.lines.size(); ++i) { - LineData data = (LineData) this.container.lines.get(i); - // For last line, use <= since end doesn't include +1 for newline - // For other lines, use < since end includes the newline position - boolean isLastLine = (i == this.container.lines.size() - 1); - boolean isOnLine = isLastLine - ? (this.cursorPosition >= data.start && this.cursorPosition <= data.end) - : (this.cursorPosition >= data.start && this.cursorPosition < data.end); - if (isOnLine) { - if (i == 0) { - return 0; - } - - int column = Math.min(this.cursorPosition - data.start, data.text.length()); - LineData target = this.container.lines.get(i - 1); - // Calculate target position on the line above, maintaining column if possible - int targetColumn = Math.min(column, target.text.length()); - int targetIndent = getLineIndent(target.text); - int minPos = target.start + Math.min(targetIndent, target.text.length()); - int targetPos = target.start + targetColumn; - return Math.max(minPos, targetPos); - } - } - - return 0; + return CursorNavigation.cursorUp(selection.getCursorPosition(), container.lines, text); } private int cursorDown() { - if (cursorPosition == text.length()) - return cursorPosition; - for (int i = 0; i < this.container.lines.size(); ++i) { - LineData data = (LineData) this.container.lines.get(i); - // For last line, use <= since end doesn't include +1 for newline - // For other lines, use < since end includes the newline position - boolean isLastLine = (i == this.container.lines.size() - 1); - boolean isOnLine = isLastLine - ? (this.cursorPosition >= data.start && this.cursorPosition <= data.end) - : (this.cursorPosition >= data.start && this.cursorPosition < data.end); - if (isOnLine) { - // If already on last line, stay at current position - if (i >= this.container.lines.size() - 1) { - return cursorPosition; - } - - int column = Math.min(this.cursorPosition - data.start, data.text.length()); - LineData target = this.container.lines.get(i + 1); - // Calculate target position on the line below, maintaining column if possible - int targetColumn = Math.min(column, target.text.length()); - int targetIndent = getLineIndent(target.text); - int minPos = target.start + Math.min(targetIndent, target.text.length()); - int targetPos = target.start + targetColumn; - return Math.max(minPos, targetPos); - } - } - - return this.text.length(); + return CursorNavigation.cursorDown(selection.getCursorPosition(), container.lines, text); } public String getSelectionBeforeText() { - return this.startSelection == 0 ? "" : this.text.substring(0, this.startSelection); + return selection.getTextBefore(text); } public String getSelectionAfterText() { - return this.text.substring(this.endSelection); + return selection.getTextAfter(text); } // ==================== MOUSE HANDLING ==================== @@ -1406,18 +1110,19 @@ public class GuiScriptTextArea extends GuiNpcTextField { public void mouseClicked(int xMouse, int yMouse, int mouseButton) { this.active = xMouse >= this.x && xMouse < this.x + this.width && yMouse >= this.y && yMouse < this.y + this.height; if (this.active) { - this.startSelection = this.endSelection = this.cursorPosition = this.getSelectionPos(xMouse, yMouse); - this.lastInputTime = System.currentTimeMillis(); + int clickPos = this.getSelectionPos(xMouse, yMouse); + selection.reset(clickPos); + selection.markActivity(); this.clicked = mouseButton == 0; this.doubleClicked = false; this.tripleClicked = false; long time = System.currentTimeMillis(); if (this.clicked && this.container.linesCount * this.container.lineHeight > this.height && xMouse > this.x + this.width - 8) { this.clicked = false; - this.clickScrolling = true; + scroll.setClickScrolling(true); double linesCountD = Math.max(1, (double) this.container.linesCount); - int thumbTop = (int) (this.y + 1f * this.scrollPos / linesCountD * (this.height - 4)) + 1; - this.scrollbarDragOffset = yMouse - thumbTop; + int thumbTop = (int) (this.y + 1f * scroll.getScrollPos() / linesCountD * (this.height - 4)) + 1; + scroll.setScrollbarDragOffset(yMouse - thumbTop); } else { if (time - this.lastClicked < 300L) { this.clickCount++; @@ -1430,9 +1135,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { this.doubleClicked = true; Matcher m = this.container.regexWord.matcher(this.text); while (m.find()) { - if (this.cursorPosition >= m.start() && this.cursorPosition <= m.end()) { - this.startSelection = m.start(); - this.endSelection = m.end(); + if (selection.getCursorPosition() >= m.start() && selection.getCursorPosition() <= m.end()) { + selection.setSelection(m.start(), m.end()); break; } } @@ -1440,9 +1144,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Triple-click: select entire line this.tripleClicked = true; for (LineData line : container.lines) { - if (this.cursorPosition >= line.start && this.cursorPosition <= line.end) { - this.startSelection = line.start; - this.endSelection = line.end; + if (selection.getCursorPosition() >= line.start && selection.getCursorPosition() <= line.end) { + selection.setSelection(line.start, line.end); break; } } @@ -1474,7 +1177,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { } if (!this.undoing) { - this.undoList.add(new GuiScriptTextArea.UndoData(this.text, this.cursorPosition)); + this.undoList.add(new GuiScriptTextArea.UndoData(this.text, selection.getCursorPosition())); this.redoList.clear(); } @@ -1488,20 +1191,12 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Ensure scroll state stays in bounds after text change int maxScroll = Math.max(0, this.container.linesCount - this.container.visibleLines); - if (this.scrolledLine > maxScroll) { - this.scrolledLine = maxScroll; - } - if (this.targetScroll > maxScroll) { - this.targetScroll = maxScroll; - } - if (this.scrollPos > maxScroll) { - this.scrollPos = maxScroll; - } + scroll.clampToBounds(maxScroll); - clampSelectionBounds(); + selection.clamp(this.text.length()); // Consider text changes user activity to pause caret blinking briefly - this.lastInputTime = System.currentTimeMillis(); + selection.markActivity(); } } @@ -1528,15 +1223,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { } private void clampSelectionBounds() { - if (this.text == null || this.text.isEmpty()) { - this.startSelection = this.endSelection = this.cursorPosition = 0; - return; - } - - int length = this.text.length(); - this.startSelection = Math.max(0, Math.min(this.startSelection, length)); - this.endSelection = Math.max(0, Math.min(this.endSelection, length)); - this.cursorPosition = Math.max(0, Math.min(this.cursorPosition, length)); + selection.clamp(text != null ? text.length() : 0); } // ==================== INNER CLASSES ==================== 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 new file mode 100644 index 000000000..a4c0e7a30 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/BracketMatcher.java @@ -0,0 +1,252 @@ +package noppes.npcs.client.gui.util.script; + +import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import java.util.ArrayList; +import java.util.List; + +/** + * Handles bracket matching, brace span computation, and indent guide generation. + */ +public class BracketMatcher { + + /** + * Find matching bracket at cursor position. + * @return int[2] with {startPos, endPos} or null if no bracket found + */ + public static int[] findBracketSpanAt(String text, int pos) { + if (text == null || text.isEmpty()) return null; + + // Try at current position + if (pos >= 0 && pos < text.length()) { + char c = text.charAt(pos); + int found = 0; + if (c == '{') found = findClosingBracket(text.substring(pos), '{', '}'); + else if (c == '[') found = findClosingBracket(text.substring(pos), '[', ']'); + else if (c == '(') found = findClosingBracket(text.substring(pos), '(', ')'); + else if (c == '}') found = findOpeningBracket(text.substring(0, pos + 1), '{', '}'); + else if (c == ']') found = findOpeningBracket(text.substring(0, pos + 1), '[', ']'); + else if (c == ')') found = findOpeningBracket(text.substring(0, pos + 1), '(', ')'); + if (found != 0) return new int[]{pos, pos + found}; + } + + // Scan backwards, skipping whitespace on same line + if (pos > 0) { + int scan = pos - 1; + while (scan >= 0) { + char sc = text.charAt(scan); + if (sc == ' ' || sc == '\t') { scan--; continue; } + if (sc == '\n') break; // Don't cross line boundary + + int found2 = 0; + if (sc == '{') found2 = findClosingBracket(text.substring(scan), '{', '}'); + else if (sc == '[') found2 = findClosingBracket(text.substring(scan), '[', ']'); + else if (sc == '(') found2 = findClosingBracket(text.substring(scan), '(', ')'); + else if (sc == '}') found2 = findOpeningBracket(text.substring(0, scan + 1), '{', '}'); + else if (sc == ']') found2 = findOpeningBracket(text.substring(0, scan + 1), '[', ']'); + else if (sc == ')') found2 = findOpeningBracket(text.substring(0, scan + 1), '(', ')'); + + if (found2 != 0) return new int[]{scan, scan + found2}; + break; // Not a bracket; stop + } + } + return null; + } + + /** + * Find closing bracket, returns offset from start or 0 if not found + */ + public static int findClosingBracket(String str, char open, char close) { + int depth = 0; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == open) depth++; + else if (c == close) { + depth--; + if (depth == 0) return i; + } + } + return 0; + } + + /** + * Find opening bracket, returns negative offset from end or 0 if not found + */ + public static int findOpeningBracket(String str, char open, char close) { + int depth = 0; + for (int i = str.length() - 1; i >= 0; i--) { + char c = str.charAt(i); + if (c == close) depth++; + else if (c == open) { + depth--; + if (depth == 0) return i - str.length() + 1; + } + } + return 0; + } + + /** + * Compute brace spans for indent guides. + * Returns list of int[4]: {originalDepth, openLineIdx, closeLineIdx, adjustedDepth} + */ + public static List computeBraceSpans(String text, List lines) { + List spans = new ArrayList<>(); + if (text == null || text.isEmpty() || lines == null || lines.isEmpty()) + return spans; + + int lineIdx = 0; + int lineEnd = lines.get(0).end; + List openStack = new ArrayList<>(); + + boolean inLineComment = false; + boolean inBlockComment = false; + boolean inString = false; + boolean escape = false; + char stringDelimiter = 0; + + for (int pos = 0; pos < text.length(); pos++) { + // Advance line index + while (lineIdx < lines.size() - 1 && pos >= lineEnd) { + lineIdx++; + lineEnd = lines.get(lineIdx).end; + } + + char c = text.charAt(pos); + char next = pos + 1 < text.length() ? text.charAt(pos + 1) : 0; + + // String handling + if (inString) { + if (escape) { escape = false; } + else if (c == '\\') { escape = true; } + else if (c == stringDelimiter) { inString = false; } + else if (c == '\n') { inString = false; } // Unterminated string + continue; + } + + // Block comment handling + if (inBlockComment) { + if (c == '*' && next == '/') { inBlockComment = false; pos++; } + continue; + } + + // Line comment handling + if (inLineComment) { + if (c == '\n') { inLineComment = false; } + continue; + } + + // Start of comments + if (c == '/' && next == '/') { inLineComment = true; pos++; continue; } + if (c == '/' && next == '*') { inBlockComment = true; pos++; continue; } + + // Start of string + if (c == '"' || c == '\'') { + inString = true; + stringDelimiter = c; + escape = false; + continue; + } + + // Brace matching + if (c == '{') { + openStack.add(lineIdx); + } else if (c == '}') { + if (!openStack.isEmpty()) { + int openLine = openStack.remove(openStack.size() - 1); + int spanDepth = openStack.size() + 1; + spans.add(new int[]{spanDepth, openLine, lineIdx}); + } + } + } + + // Normalize depths for unmatched braces + if (!openStack.isEmpty()) { + int baseline = openStack.size(); + List adjusted = new ArrayList<>(spans.size()); + for (int[] span : spans) { + int adjustedDepth = Math.max(1, span[0] - baseline); + adjusted.add(new int[]{span[0], span[1], span[2], adjustedDepth}); + } + return adjusted; + } else { + for (int i = 0; i < spans.size(); i++) { + int[] span = spans.get(i); + spans.set(i, new int[]{span[0], span[1], span[2], span[0]}); + } + } + return spans; + } + + /** + * Find which brace span should be highlighted based on bracket at position + * @return int[2] {openLine, closeLine} or null + */ + public static int[] findHighlightedSpan(int bracketPos, List lines, List spans) { + if (bracketPos < 0 || lines == null || spans == null) return null; + + // Find line containing bracket + int bracketLineIdx = -1; + for (int i = 0; i < lines.size(); i++) { + LineData ld = lines.get(i); + if (bracketPos >= ld.start && bracketPos < ld.end) { + bracketLineIdx = i; + break; + } + } + + if (bracketLineIdx >= 0) { + for (int[] span : spans) { + int openLine = span[1]; + int closeLine = span[2]; + if (bracketLineIdx >= openLine && bracketLineIdx <= closeLine) { + return new int[]{openLine, closeLine}; + } + } + } + return null; + } + + /** + * Check if a brace at given position has a matching close with same indent + */ + public static boolean hasMatchingCloseWithSameIndent(String text, int bracePos, List lines, int currentIndent) { + if (text == null || lines == null || bracePos < 0) return false; + + // Find line containing the brace + int openLineIdx = -1; + for (int i = 0; i < lines.size(); i++) { + LineData ld = lines.get(i); + if (bracePos >= ld.start && bracePos < ld.end) { + openLineIdx = i; + break; + } + } + + if (openLineIdx < 0) return false; + + List spans = computeBraceSpans(text, lines); + for (int[] span : spans) { + if (span[1] == openLineIdx) { + int closeLineIdx = span[2]; + if (closeLineIdx < lines.size()) { + String closeLine = lines.get(closeLineIdx).text; + int closeIndent = getLineIndent(closeLine); + return closeIndent == currentIndent; + } + } + } + return false; + } + + /** + * Get leading whitespace count of a line + */ + private static int getLineIndent(String line) { + int indent = 0; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == ' ' || c == '\t') indent++; + else break; + } + return indent; + } +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/CommentHandler.java b/src/main/java/noppes/npcs/client/gui/util/script/CommentHandler.java new file mode 100644 index 000000000..b5c0f2205 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/CommentHandler.java @@ -0,0 +1,217 @@ +package noppes.npcs.client.gui.util.script; + +import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import java.util.List; + +/** + * Handles line comment toggling for the script editor. + */ +public class CommentHandler { + + /** + * Result of comment toggle operation + */ + public static class ToggleResult { + public final String text; + public final int delta; // Change in length (negative if comment removed) + public final int nonWsIndex; // Index of first non-whitespace character + + public ToggleResult(String text, int delta, int nonWsIndex) { + this.text = text; + this.delta = delta; + this.nonWsIndex = nonWsIndex; + } + + public boolean wasCommented() { + return delta < 0; + } + } + + /** + * Toggle line comment on a single line + */ + public static ToggleResult toggleLine(String lineText) { + // Find first non-whitespace + int nonWs = 0; + while (nonWs < lineText.length() && Character.isWhitespace(lineText.charAt(nonWs))) { + nonWs++; + } + + boolean hasContent = nonWs < lineText.length(); + boolean hasComment = hasContent && lineText.startsWith("//", nonWs); + String newText; + + if (hasComment) { + // Remove comment + int cut = Math.min(nonWs + 2, lineText.length()); + newText = lineText.substring(0, nonWs) + lineText.substring(cut); + } else if (hasContent) { + // Add comment + newText = lineText.substring(0, nonWs) + "//" + lineText.substring(nonWs); + } else { + // Empty/whitespace line - no change + newText = lineText; + } + + return new ToggleResult(newText, newText.length() - lineText.length(), nonWs); + } + + /** + * Result of toggling comments on a selection + */ + public static class SelectionToggleResult { + public final String newText; + public final int newStartSelection; + public final int newEndSelection; + + public SelectionToggleResult(String text, int start, int end) { + this.newText = text; + this.newStartSelection = start; + this.newEndSelection = end; + } + } + + /** + * Toggle comments on all lines in a selection + */ + public static SelectionToggleResult toggleCommentSelection( + String text, + List lines, + int startSelection, + int endSelection) { + + StringBuilder newText = new StringBuilder(text.length() + 100); + int prevEnd = 0; + int newStart = -1; + int newEnd = -1; + int newIndex = 0; + + for (LineData line : lines) { + int safeLineStart = Math.max(0, Math.min(line.start, text.length())); + int safeLineEnd = Math.max(0, Math.min(line.end, text.length())); + + // Handle gap between lines (shouldn't happen normally) + if (safeLineStart > prevEnd) { + if (newStart == -1 && startSelection >= prevEnd && startSelection <= safeLineStart) { + newStart = newIndex + (startSelection - prevEnd); + } + if (newEnd == -1 && endSelection >= prevEnd && endSelection <= safeLineStart) { + newEnd = newIndex + (endSelection - prevEnd); + } + newText.append(text, prevEnd, safeLineStart); + newIndex += safeLineStart - prevEnd; + } + + // Check if this line is in selection + if (safeLineEnd > startSelection && safeLineStart < endSelection) { + String lineText = text.substring(safeLineStart, safeLineEnd); + ToggleResult tr = toggleLine(lineText); + String newLineText = tr.text; + int nonWs = tr.nonWsIndex; + int delta = tr.delta; + + // Update start selection position + if (newStart == -1 && startSelection >= safeLineStart && startSelection <= safeLineEnd) { + int offsetInOld = startSelection - safeLineStart; + int newOffset = offsetInOld; + if (offsetInOld >= nonWs) { + newOffset = offsetInOld + delta; + } + newStart = newIndex + Math.max(0, newOffset); + } + + // Update end selection position + if (newEnd == -1 && endSelection >= safeLineStart && endSelection <= safeLineEnd) { + int offsetInOld = endSelection - safeLineStart; + int newOffset = offsetInOld; + if (offsetInOld >= nonWs) { + newOffset = offsetInOld + delta; + } + newEnd = newIndex + Math.max(0, newOffset); + } + + newText.append(newLineText); + newIndex += newLineText.length(); + } else { + // Line not in selection - keep unchanged + if (newStart == -1 && startSelection >= safeLineStart && startSelection <= safeLineEnd) { + newStart = newIndex + (startSelection - safeLineStart); + } + if (newEnd == -1 && endSelection >= safeLineStart && endSelection <= safeLineEnd) { + newEnd = newIndex + (endSelection - safeLineStart); + } + newText.append(text, safeLineStart, safeLineEnd); + newIndex += safeLineEnd - safeLineStart; + } + prevEnd = safeLineEnd; + } + + // Handle remaining text after last line + if (prevEnd < text.length()) { + if (newStart == -1 && startSelection >= prevEnd && startSelection <= text.length()) { + newStart = newIndex + (startSelection - prevEnd); + } + if (newEnd == -1 && endSelection >= prevEnd && endSelection <= text.length()) { + newEnd = newIndex + (endSelection - prevEnd); + } + newText.append(text, prevEnd, text.length()); + newIndex += text.length() - prevEnd; + } + + // Fallback for positions not found + if (newStart == -1) newStart = Math.max(0, Math.min(startSelection, newText.length())); + if (newEnd == -1) newEnd = Math.max(0, Math.min(endSelection, newText.length())); + + return new SelectionToggleResult(newText.toString(), newStart, newEnd); + } + + /** + * Toggle comment on a single line at cursor position + */ + public static SingleLineToggleResult toggleCommentAtCursor( + String text, + List lines, + int cursorPosition) { + + for (LineData line : lines) { + int lineStart = Math.max(0, Math.min(line.start, text.length())); + int lineEnd = Math.max(0, Math.min(line.end, text.length())); + + if (cursorPosition >= lineStart && cursorPosition <= lineEnd) { + String lineText = text.substring(lineStart, lineEnd); + ToggleResult tr = toggleLine(lineText); + boolean hadComment = tr.delta < 0; + + String before = text.substring(0, lineStart); + String after = lineEnd <= text.length() ? text.substring(lineEnd) : ""; + String newText = before + tr.text + after; + + // Calculate new cursor position + int cursorDelta = hadComment ? -2 : 2; + int newCursor = cursorPosition + cursorDelta; + if (cursorPosition < lineStart + tr.nonWsIndex + (hadComment ? 2 : 0)) { + newCursor = cursorPosition; + } + newCursor = Math.max(lineStart, newCursor); + + return new SingleLineToggleResult(newText, newCursor); + } + } + + // No line found at cursor - return unchanged + return new SingleLineToggleResult(text, cursorPosition); + } + + /** + * Result of toggling comment on single line + */ + public static class SingleLineToggleResult { + public final String newText; + public final int newCursorPosition; + + public SingleLineToggleResult(String text, int cursor) { + this.newText = text; + this.newCursorPosition = cursor; + } + } +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/CursorNavigation.java b/src/main/java/noppes/npcs/client/gui/util/script/CursorNavigation.java new file mode 100644 index 000000000..2a39d5bbb --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/CursorNavigation.java @@ -0,0 +1,217 @@ +package noppes.npcs.client.gui.util.script; + +import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Handles cursor navigation logic (up, down, left, right, word movement). + */ +public class CursorNavigation { + + /** + * Move cursor up one line, maintaining column position + */ + public static int cursorUp(int cursorPosition, List lines, String text) { + if (lines == null || lines.isEmpty()) return 0; + + for (int i = 0; i < lines.size(); i++) { + LineData data = lines.get(i); + boolean isLastLine = (i == lines.size() - 1); + boolean isOnLine = isLastLine + ? (cursorPosition >= data.start && cursorPosition <= data.end) + : (cursorPosition >= data.start && cursorPosition < data.end); + + if (isOnLine) { + if (i == 0) { + return 0; // Already on first line + } + + int column = Math.min(cursorPosition - data.start, data.text.length()); + LineData target = lines.get(i - 1); + int targetColumn = Math.min(column, target.text.length()); + int targetIndent = getLineIndent(target.text); + int minPos = target.start + Math.min(targetIndent, target.text.length()); + int targetPos = target.start + targetColumn; + return Math.max(minPos, targetPos); + } + } + return 0; + } + + /** + * Move cursor down one line, maintaining column position + */ + public static int cursorDown(int cursorPosition, List lines, String text) { + if (lines == null || lines.isEmpty()) return text != null ? text.length() : 0; + if (text != null && cursorPosition == text.length()) return cursorPosition; + + for (int i = 0; i < lines.size(); i++) { + LineData data = lines.get(i); + boolean isLastLine = (i == lines.size() - 1); + boolean isOnLine = isLastLine + ? (cursorPosition >= data.start && cursorPosition <= data.end) + : (cursorPosition >= data.start && cursorPosition < data.end); + + if (isOnLine) { + if (i >= lines.size() - 1) { + return cursorPosition; // Already on last line + } + + int column = Math.min(cursorPosition - data.start, data.text.length()); + LineData target = lines.get(i + 1); + int targetColumn = Math.min(column, target.text.length()); + int targetIndent = getLineIndent(target.text); + int minPos = target.start + Math.min(targetIndent, target.text.length()); + int targetPos = target.start + targetColumn; + return Math.max(minPos, targetPos); + } + } + return text != null ? text.length() : 0; + } + + /** + * Move cursor left, optionally by word + */ + public static int cursorLeft(int cursorPosition, String text, Pattern wordPattern, boolean byWord) { + if (text == null || cursorPosition <= 0) return 0; + + int moveAmount = 1; + if (byWord && wordPattern != null) { + Matcher m = wordPattern.matcher(text.substring(0, cursorPosition)); + while (m.find()) { + if (m.start() != m.end()) { + moveAmount = cursorPosition - m.start(); + } + } + } + return Math.max(cursorPosition - moveAmount, 0); + } + + /** + * Move cursor right, optionally by word + */ + public static int cursorRight(int cursorPosition, String text, Pattern wordPattern, boolean byWord) { + if (text == null) return 0; + int textLen = text.length(); + if (cursorPosition >= textLen) return textLen; + + int moveAmount = 1; + if (byWord && wordPattern != null) { + Matcher m = wordPattern.matcher(text.substring(cursorPosition)); + if (m.find() && m.start() > 0) { + moveAmount = m.start(); + } else if (m.find()) { + moveAmount = m.start(); + } + } + return Math.min(cursorPosition + moveAmount, textLen); + } + + /** + * Get cursor position from mouse click coordinates + */ + public static int getPositionFromMouse( + int mouseX, int mouseY, + int areaX, int areaY, + int gutterWidth, + int lineHeight, + int scrolledLine, + double scrollFracOffset, + List lines, + String text, + FontWidthProvider fontWidth) { + + int xMouse = mouseX - (areaX + gutterWidth + 1); + int yMouse = mouseY - areaY - 1; + + // Account for fractional scroll offset + double fracPixels = scrollFracOffset * lineHeight; + yMouse = (int) Math.round(yMouse + fracPixels); + + if (lines == null || lines.isEmpty()) { + return text != null ? text.length() : 0; + } + + int visibleLines = Math.min(lines.size(), scrolledLine + 50); // Reasonable limit + + for (int i = 0; i < lines.size(); i++) { + LineData data = lines.get(i); + + if (i >= scrolledLine && i <= visibleLines) { + int yPos = (i - scrolledLine) * lineHeight; + + if (yMouse >= yPos && yMouse < yPos + lineHeight) { + // Found the line, now find character position + int lineWidth = 0; + char[] chars = data.text.toCharArray(); + + for (int j = 1; j <= chars.length; j++) { + int w = fontWidth.getWidth(data.text.substring(0, j)); + if (xMouse < lineWidth + (w - lineWidth) / 2) { + return data.start + j - 1; + } + lineWidth = w; + } + + // Click past end of line - place cursor at end of text (not on newline) + int posAfterChars = data.start + chars.length; + return Math.min(posAfterChars, text.length()); + } + } + } + + return text != null ? text.length() : 0; + } + + /** + * Interface for font width measurement + */ + public interface FontWidthProvider { + int getWidth(String text); + } + + /** + * Get leading whitespace count of a line + */ + private static int getLineIndent(String lineText) { + int indent = 0; + for (int i = 0; i < lineText.length(); i++) { + char c = lineText.charAt(i); + if (c == ' ' || c == '\t') indent++; + else break; + } + return indent; + } + + /** + * Find line index containing given position + */ + public static int findLineIndex(int position, List lines) { + if (lines == null || lines.isEmpty()) return 0; + + for (int i = 0; i < lines.size(); i++) { + LineData line = lines.get(i); + boolean isLastLine = (i == lines.size() - 1); + boolean isOnLine = isLastLine + ? (position >= line.start && position <= line.end) + : (position >= line.start && position < line.end); + if (isOnLine) { + return i; + } + } + return lines.size() - 1; + } + + /** + * Find line data containing given position + */ + public static LineData findLineAt(int position, List lines) { + int idx = findLineIndex(position, lines); + if (idx >= 0 && idx < lines.size()) { + return lines.get(idx); + } + return null; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..8a7acaa2a --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/IndentHelper.java @@ -0,0 +1,246 @@ +package noppes.npcs.client.gui.util.script; + +import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import java.util.Arrays; +import java.util.List; + +/** + * Helper class for indentation-related operations. + */ +public class IndentHelper { + + public static final int TAB_SIZE = 4; + + /** + * Get leading whitespace count of a line + */ + public static int getLineIndent(String lineText) { + int leading = 0; + for (int i = 0; i < lineText.length(); i++) { + char c = lineText.charAt(i); + if (c == ' ' || c == '\t') leading++; + else break; + } + return leading; + } + + /** + * Extract leading whitespace as string + */ + public static String getIndentString(String lineText) { + int indent = getLineIndent(lineText); + return lineText.substring(0, indent); + } + + /** + * Create a string of n spaces + */ + public static String spaces(int n) { + if (n <= 0) return ""; + char[] arr = new char[n]; + Arrays.fill(arr, ' '); + return new String(arr); + } + + /** + * Calculate expected indent for a line based on context + */ + public static int getExpectedIndent(LineData currentLine, List allLines) { + if (currentLine == null || allLines == null) return 0; + + int idx = allLines.indexOf(currentLine); + if (idx <= 0) return 0; + + // Find previous non-empty line + LineData prevLine = null; + for (int i = idx - 1; i >= 0; i--) { + LineData line = allLines.get(i); + if (line.text.trim().length() > 0) { + prevLine = line; + break; + } + } + + if (prevLine == null) return 0; + + int prevIndent = getLineIndent(prevLine.text); + String prevTrimmed = prevLine.text.trim(); + String currTrimmed = currentLine.text.trim(); + + // If current line starts with }, expected is one level back + if (currTrimmed.startsWith("}")) { + return Math.max(0, prevIndent - TAB_SIZE); + } + + // If previous line ends with {, expected is one level forward + if (prevTrimmed.endsWith("{")) { + return prevIndent + TAB_SIZE; + } + + return prevIndent; + } + + /** + * Calculate auto-indent for Enter key + */ + public static String getAutoIndentForEnter(String lineText, int cursorInLine) { + int leading = 0; + while (leading < lineText.length() && + (lineText.charAt(leading) == ' ' || lineText.charAt(leading) == '\t')) { + leading++; + } + + String baseIndent = lineText.substring(0, leading); + int relativeCursor = Math.max(0, Math.min(cursorInLine, lineText.length())); + String beforeCursor = lineText.substring(0, relativeCursor); + + // Find last non-whitespace before cursor + int lastNonWs = -1; + for (int i = beforeCursor.length() - 1; i >= 0; i--) { + if (!Character.isWhitespace(beforeCursor.charAt(i))) { + lastNonWs = i; + break; + } + } + + // Add extra indent if last char is { + // Only add extra indent if cursor is actually after the opening brace (not just in trailing whitespace) + // Check that the last non-whitespace is at or after the line's indent position + boolean opensBlock = lastNonWs >= 0 && lastNonWs >= leading && beforeCursor.charAt(lastNonWs) == '{'; + if (opensBlock) { + return baseIndent + spaces(TAB_SIZE); + } + + return baseIndent; + } + + /** + * Calculate target indent for Tab key + */ + public static int calculateTabTargetIndent(int currentIndent, int cursorColumn, boolean atTextStart) { + if (atTextStart) { + // At text start, move to next tab stop + return ((currentIndent / TAB_SIZE) + 1) * TAB_SIZE; + } else { + // Inside indent, snap to nearest tab stop + int remainder = currentIndent % TAB_SIZE; + if (remainder == 0) { + return currentIndent + TAB_SIZE; + } else { + int down = currentIndent - remainder; + int up = currentIndent + (TAB_SIZE - remainder); + int distDown = remainder; + int distUp = TAB_SIZE - remainder; + if (distUp <= distDown) return up; + else return down; + } + } + } + + /** + * Calculate target indent for Shift+Tab + */ + public static int calculateShiftTabTargetIndent(int currentIndent) { + return Math.max(0, ((currentIndent - 1) / TAB_SIZE) * TAB_SIZE); + } + + /** + * Format entire text with proper indentation + */ + public static FormatResult formatText(String text, int cursorPosition) { + String[] linesArr = text.split("\n", -1); + StringBuilder out = new StringBuilder(text.length() + 32); + + // Track cursor position in formatted output + int cursorLine = -1; + int cursorColInContent = 0; + int lineStartPos = 0; + + // Find cursor's line and column + for (int li = 0; li < linesArr.length; li++) { + String line = linesArr[li]; + int lineEndPos = lineStartPos + line.length(); + + if (cursorPosition >= lineStartPos && cursorPosition <= lineEndPos) { + cursorLine = li; + int leadingSpaces = line.length() - line.replaceAll("^[ \\t]+", "").length(); + cursorColInContent = Math.max(0, cursorPosition - lineStartPos - leadingSpaces); + break; + } + lineStartPos = lineEndPos + 1; + } + + boolean inString = false; + boolean escape = false; + boolean inBlockComment = false; + int depth = 0; + int newCursorPos = 0; + + for (int li = 0; li < linesArr.length; li++) { + String line = linesArr[li]; + String trimmedLeading = line.replaceAll("^[ \\t]+", ""); + + int opens = 0, closes = 0; + boolean startsWithClose = false; + + // Parse line for braces + for (int idx = 0; idx < line.length(); idx++) { + char c = line.charAt(idx); + char next = idx + 1 < line.length() ? line.charAt(idx + 1) : 0; + + if (inString) { + if (escape) { escape = false; } + else if (c == '\\') { escape = true; } + else if (c == '"') { inString = false; } + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { inBlockComment = false; idx++; } + continue; + } + + if (c == '/' && next == '/') break; + if (c == '/' && next == '*') { inBlockComment = true; idx++; continue; } + if (c == '"') { inString = true; escape = false; continue; } + + if (c == '{') { + opens++; + } else if (c == '}') { + closes++; + if (!startsWithClose && line.substring(0, idx).trim().isEmpty()) { + startsWithClose = true; + } + } + } + + int indentLevel = depth; + if (startsWithClose) indentLevel = Math.max(0, indentLevel - 1); + int targetIndent = indentLevel * TAB_SIZE; + + if (li == cursorLine) { + newCursorPos = out.length() + targetIndent + Math.min(cursorColInContent, trimmedLeading.length()); + } + + out.append(spaces(targetIndent)).append(trimmedLeading); + if (li < linesArr.length - 1) out.append('\n'); + + depth = Math.max(0, depth + opens - closes); + } + + return new FormatResult(out.toString(), newCursorPos); + } + + /** + * Result of text formatting + */ + public static class FormatResult { + public final String text; + public final int cursorPosition; + + public FormatResult(String text, int cursorPosition) { + this.text = text; + this.cursorPosition = cursorPosition; + } + } +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java b/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java new file mode 100644 index 000000000..cbee81e4e --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/ScrollState.java @@ -0,0 +1,153 @@ +package noppes.npcs.client.gui.util.script; + +/** + * Manages smooth scrolling state and animation for the script text area. + * Uses exponential smoothing for fluid scroll animations. + */ +public class ScrollState { + // Current fractional scroll position (in lines) + private double scrollPos = -1.0; + // Target scroll position to animate towards + private double targetScroll = 0.0; + // Current scroll velocity for momentum calculations + private double scrollVelocity = 0.0; + // Last timestamp for delta time calculation + private long lastScrollTime = 0L; + // Integer line index for compatibility with rendering + private int scrolledLine = 0; + // Scrollbar drag offset when dragging thumb + private int scrollbarDragOffset = 0; + // Whether currently dragging the scrollbar + private boolean clickScrolling = false; + + // Animation parameters + private static final double TAU = 0.1; // Time constant (~55ms feels snappy) + private static final double SNAP_THRESHOLD = 0.01; // Snap to target when this close + private static final double MAX_DT = 0.05; // Max delta time for stability + + /** + * Reset scroll state to initial values + */ + public void reset() { + scrollPos = 0.0; + targetScroll = 0.0; + scrollVelocity = 0.0; + scrolledLine = 0; + lastScrollTime = System.currentTimeMillis(); + } + + /** + * Initialize scroll position if not already initialized + */ + public void initializeIfNeeded(int currentLine) { + if (scrollPos < 0) { + scrollPos = currentLine; + lastScrollTime = System.currentTimeMillis(); + } + } + + /** + * Update scroll animation. Call once per frame. + * @param maxScroll Maximum valid scroll position + */ + public void update(int maxScroll) { + long nowMs = System.currentTimeMillis(); + double dt = Math.min(MAX_DT, (nowMs - lastScrollTime) / 1000.0); + lastScrollTime = nowMs; + + double dist = targetScroll - scrollPos; + + // Snap to target if close enough + if (Math.abs(dist) < SNAP_THRESHOLD) { + scrollPos = targetScroll; + scrollVelocity = 0.0; + } else { + // Exponential smoothing + double alpha = 1.0 - Math.exp(-dt / Math.max(1e-6, TAU)); + double prev = scrollPos; + scrollPos += dist * alpha; + scrollVelocity = (scrollPos - prev) / (dt > 0 ? dt : 1e-6); + + // Clamp overshoot + if ((dist > 0 && scrollPos > targetScroll) || (dist < 0 && scrollPos < targetScroll)) { + scrollPos = targetScroll; + scrollVelocity = 0.0; + } + } + + // Keep in bounds + clampToBounds(maxScroll); + + // Update integer line for rendering + scrolledLine = Math.max(0, Math.min((int) Math.floor(scrollPos), maxScroll)); + } + + /** + * Clamp scroll position to valid bounds + */ + public void clampToBounds(int maxScroll) { + if (scrollPos < 0) scrollPos = 0; + if (scrollPos > maxScroll) scrollPos = maxScroll; + if (targetScroll < 0) targetScroll = 0; + if (targetScroll > maxScroll) targetScroll = maxScroll; + scrolledLine = Math.max(0, Math.min(scrolledLine, maxScroll)); + } + + /** + * Apply mouse wheel scroll + * @param wheelDelta Positive = scroll up, negative = scroll down + * @param maxScroll Maximum valid scroll position + */ + public void applyWheelScroll(int wheelDelta, int maxScroll) { + double sign = Math.copySign(1, wheelDelta); + targetScroll -= sign * 2.0; // 2 lines per wheel tick + clampToBounds(maxScroll); + } + + /** + * Set target scroll directly (for scrollbar dragging) + */ + public void setTargetScroll(double target, int maxScroll) { + targetScroll = Math.max(0, Math.min(target, maxScroll)); + } + + /** + * Scroll to make a specific line visible + * @param lineIdx Line index to make visible + * @param visibleLines Number of visible lines in viewport + * @param maxScroll Maximum scroll position + */ + public void scrollToLine(int lineIdx, int visibleLines, int maxScroll) { + int firstVisible = scrolledLine; + int lastFullyVisible = scrolledLine + visibleLines; + + if (lineIdx < firstVisible) { + // Line is above viewport - scroll up + targetScroll = lineIdx; + } else if (lineIdx > lastFullyVisible) { + // Line is below viewport - scroll down + targetScroll = Math.min(lineIdx - visibleLines, maxScroll); + } + // If line is visible, don't change scroll + } + + // Getters + public double getScrollPos() { return scrollPos; } + public double getTargetScroll() { return targetScroll; } + public int getScrolledLine() { return scrolledLine; } + public double getScrollVelocity() { return scrollVelocity; } + public boolean isClickScrolling() { return clickScrolling; } + public int getScrollbarDragOffset() { return scrollbarDragOffset; } + + /** + * Get fractional offset for sub-pixel rendering + */ + public double getFractionalOffset() { + return scrollPos - scrolledLine; + } + + // Setters for scrollbar interaction + public void setClickScrolling(boolean scrolling) { this.clickScrolling = scrolling; } + public void setScrollbarDragOffset(int offset) { this.scrollbarDragOffset = offset; } + public void setScrolledLine(int line) { this.scrolledLine = line; } +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/SelectionState.java b/src/main/java/noppes/npcs/client/gui/util/script/SelectionState.java new file mode 100644 index 000000000..2374ad261 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/SelectionState.java @@ -0,0 +1,192 @@ +package noppes.npcs.client.gui.util.script; + +import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData; +import java.util.List; + +/** + * Manages cursor position, text selection, and related operations. + */ +public class SelectionState { + private int startSelection = 0; + private int endSelection = 0; + private int cursorPosition = 0; + private long lastInputTime = 0L; + + /** + * Reset selection to a single point + */ + public void reset(int position) { + startSelection = endSelection = cursorPosition = position; + } + + /** + * Clamp all selection bounds to valid text range + */ + public void clamp(int textLength) { + if (textLength <= 0) { + startSelection = endSelection = cursorPosition = 0; + return; + } + startSelection = Math.max(0, Math.min(startSelection, textLength)); + endSelection = Math.max(0, Math.min(endSelection, textLength)); + cursorPosition = Math.max(0, Math.min(cursorPosition, textLength)); + } + + /** + * Set cursor position, optionally extending selection + */ + public void setCursor(int position, int textLength, boolean extendSelection) { + position = Math.max(0, Math.min(position, textLength)); + + if (position != cursorPosition) { + if (!extendSelection) { + endSelection = startSelection = cursorPosition = position; + } else { + int diff = cursorPosition - position; + if (cursorPosition == startSelection) { + startSelection -= diff; + } else if (cursorPosition == endSelection) { + endSelection -= diff; + } + + // Ensure start <= end + if (startSelection > endSelection) { + int temp = endSelection; + endSelection = startSelection; + startSelection = temp; + } + cursorPosition = position; + } + clamp(textLength); + markActivity(); + } + } + + /** + * Select a range of text + */ + public void setSelection(int start, int end) { + startSelection = Math.min(start, end); + endSelection = Math.max(start, end); + cursorPosition = endSelection; + } + + /** + * Select all text + */ + public void selectAll(int textLength) { + startSelection = cursorPosition = 0; + endSelection = textLength; + } + + /** + * Check if there's an active selection (not just cursor) + */ + public boolean hasSelection() { + return startSelection != endSelection; + } + + /** + * Get selected text + */ + public String getSelectedText(String text) { + if (!hasSelection() || text == null) return ""; + int start = Math.max(0, Math.min(startSelection, text.length())); + int end = Math.max(0, Math.min(endSelection, text.length())); + return text.substring(start, end); + } + + /** + * Get text before selection/cursor + */ + public String getTextBefore(String text) { + if (text == null || startSelection <= 0) return ""; + return text.substring(0, Math.min(startSelection, text.length())); + } + + /** + * Get text after selection/cursor + */ + public String getTextAfter(String text) { + if (text == null) return ""; + int pos = Math.min(endSelection, text.length()); + return text.substring(pos); + } + + /** + * Find which line the cursor is on (0-indexed) + */ + public int getCursorLineIndex(List lines, int textLength) { + if (lines == null || lines.isEmpty()) return 0; + + for (int i = 0; i < lines.size(); i++) { + LineData ld = lines.get(i); + boolean isLastLine = (i == lines.size() - 1); + boolean isOnLine = isLastLine + ? (cursorPosition >= ld.start && cursorPosition <= ld.end) + : (cursorPosition >= ld.start && cursorPosition < ld.end); + if (isOnLine) { + return i; + } + } + return lines.size() - 1; + } + + /** + * Check if cursor is on a specific line, handling the last line specially + * (last line doesn't have trailing newline, so use <= for end boundary) + */ + public boolean isCursorOnLine(int lineIndex, LineData line, int totalLines) { + boolean isLastLine = (lineIndex == totalLines - 1); + return isLastLine + ? (cursorPosition >= line.start && cursorPosition <= line.end) + : (cursorPosition >= line.start && cursorPosition < line.end); + } + + /** + * Find line containing the cursor + */ + public LineData findCurrentLine(List lines) { + if (lines == null) return null; + for (int i = 0; i < lines.size(); i++) { + LineData line = lines.get(i); + if (isCursorOnLine(i, line, lines.size())) { + return line; + } + } + return null; + } + + /** + * Mark user activity (for cursor blink timing) + */ + public void markActivity() { + lastInputTime = System.currentTimeMillis(); + } + + /** + * Check if there was recent input (for cursor blink pause) + */ + public boolean hadRecentInput() { + return System.currentTimeMillis() - lastInputTime < 500; + } + + // Getters + public int getStartSelection() { return startSelection; } + public int getEndSelection() { return endSelection; } + public int getCursorPosition() { return cursorPosition; } + public long getLastInputTime() { return lastInputTime; } + + // Direct setters (use with caution, prefer higher-level methods) + public void setStartSelection(int pos) { this.startSelection = pos; } + public void setEndSelection(int pos) { this.endSelection = pos; } + public void setCursorPositionDirect(int pos) { this.cursorPosition = pos; } + + /** + * After text modification, update selection to new position + */ + public void afterTextInsert(int newPosition) { + endSelection = startSelection = cursorPosition = newPosition; + markActivity(); + } +} \ No newline at end of file diff --git a/src/main/java/noppes/npcs/client/gui/util/script/UndoManager.java b/src/main/java/noppes/npcs/client/gui/util/script/UndoManager.java new file mode 100644 index 000000000..d7cad5295 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/util/script/UndoManager.java @@ -0,0 +1,119 @@ +package noppes.npcs.client.gui.util.script; + +import java.util.ArrayList; +import java.util.List; + +/** + * Manages undo/redo stacks for the text editor. + */ +public class UndoManager { + + /** + * Stores a snapshot of text state for undo/redo + */ + public static class UndoState { + public final String text; + public final int cursorPosition; + + public UndoState(String text, int cursorPosition) { + this.text = text; + this.cursorPosition = cursorPosition; + } + } + + private final List undoStack = new ArrayList<>(); + private final List redoStack = new ArrayList<>(); + private boolean isUndoing = false; + + // Optional: limit stack size to prevent memory issues + private static final int MAX_STACK_SIZE = 100; + + /** + * Record current state before making a change + */ + public void recordState(String text, int cursorPosition) { + if (isUndoing) return; + + undoStack.add(new UndoState(text, cursorPosition)); + redoStack.clear(); + + // Trim stack if too large + while (undoStack.size() > MAX_STACK_SIZE) { + undoStack.remove(0); + } + } + + /** + * Undo to previous state + * @param currentText Current text before undo + * @param currentCursor Current cursor position + * @return Previous state, or null if nothing to undo + */ + public UndoState undo(String currentText, int currentCursor) { + if (undoStack.isEmpty()) return null; + + isUndoing = true; + redoStack.add(new UndoState(currentText, currentCursor)); + UndoState state = undoStack.remove(undoStack.size() - 1); + isUndoing = false; + + return state; + } + + /** + * Redo to next state + * @param currentText Current text before redo + * @param currentCursor Current cursor position + * @return Next state, or null if nothing to redo + */ + public UndoState redo(String currentText, int currentCursor) { + if (redoStack.isEmpty()) return null; + + isUndoing = true; + undoStack.add(new UndoState(currentText, currentCursor)); + UndoState state = redoStack.remove(redoStack.size() - 1); + isUndoing = false; + + return state; + } + + /** + * Check if undo is available + */ + public boolean canUndo() { + return !undoStack.isEmpty(); + } + + /** + * Check if redo is available + */ + public boolean canRedo() { + return !redoStack.isEmpty(); + } + + /** + * Clear all history + */ + public void clear() { + undoStack.clear(); + redoStack.clear(); + } + + /** + * Check if currently performing an undo/redo operation + */ + public boolean isUndoing() { + return isUndoing; + } + + /** + * Set undoing flag (for external control during setText) + */ + public void setUndoing(boolean undoing) { + this.isUndoing = undoing; + } + + // For backward compatibility with existing code + public List getUndoList() { return undoStack; } + public List getRedoList() { return redoStack; } +} \ No newline at end of file