Files
CustomNPC-Plus/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java
T
KAMKEEL a028142dda Fix mouse wheel scrolling in the script editors
Mouse.getDWheel() clears the accumulated delta, so whichever widget polled it
first in a frame took it and every later reader saw zero. The script text areas
polled it themselves, after the parent screen had already drained it for its
scroll lists, so the wheel did nothing in the player, NPC and event script GUIs.
The screen now reads the wheel once per frame into the mouseScroll field it
already declared for this, and each widget zeroes it when it consumes it.
2026-08-11 05:08:15 -04:00

3736 lines
164 KiB
Java

package noppes.npcs.client.gui.util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.gui.ScaledResolution;
import net.minecraft.util.ChatAllowedCharacters;
import noppes.npcs.NoppesStringUtils;
import noppes.npcs.client.ClientProxy;
import noppes.npcs.client.gui.script.GuiScriptInterface;
import noppes.npcs.client.gui.util.key.OverlayKeyPresetViewer;
import noppes.npcs.client.gui.util.script.*;
import noppes.npcs.client.gui.util.script.JavaTextContainer.LineData;
// New interpreter system imports
import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteMenu;
import noppes.npcs.client.gui.util.script.interpreter.ScriptLine;
import noppes.npcs.client.gui.util.script.interpreter.ScriptTextContainer;
import noppes.npcs.client.gui.util.script.interpreter.field.FieldInfo;
import noppes.npcs.client.gui.util.script.interpreter.field.FieldAccessInfo;
import noppes.npcs.client.gui.util.script.interpreter.method.MethodCallInfo;
import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo;
import noppes.npcs.client.gui.util.script.interpreter.token.Token;
import noppes.npcs.client.gui.util.script.interpreter.token.ScriptColorScheme;
import noppes.npcs.client.gui.util.script.interpreter.token.TokenType;
import noppes.npcs.client.gui.util.script.interpreter.hover.GutterIconRenderer;
import noppes.npcs.client.gui.util.script.interpreter.hover.HoverState;
import noppes.npcs.client.gui.util.script.interpreter.hover.TokenHoverRenderer;
import noppes.npcs.client.gui.util.script.autocomplete.AutocompleteManager;
import noppes.npcs.client.gui.util.script.interpreter.type.ScriptTypeInfo;
import noppes.npcs.client.key.impl.ScriptEditorKeys;
import noppes.npcs.constants.ScriptContext;
import noppes.npcs.util.ValueUtil;
import org.lwjgl.input.Keyboard;
import org.lwjgl.input.Mouse;
import org.lwjgl.opengl.GL11;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import static net.minecraft.client.gui.GuiScreen.isCtrlKeyDown;
/**
* Script text editor component with syntax highlighting, bracket matching,
* 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
* - CursorNavigation: cursor movement logic
*/
public class GuiScriptTextArea extends GuiNpcTextField {
private GuiScriptInterface parent;
// ==================== DIMENSIONS & POSITION ====================
public int x;
public int y;
// ==================== STATE FLAGS ====================
public boolean active = false;
public boolean enabled = true;
public boolean visible = true;
public boolean clicked = false;
public boolean doubleClicked = false;
public boolean tripleClicked = false;
private int clickCount = 0;
private long lastClicked = 0L;
// ==================== TEXT & CONTAINER ====================
public String text = null;
public String highlightedWord;
private ScriptTextContainer container = null;
private boolean enableCodeHighlighting = false;
// Extra empty lines to allow padding at the bottom of the editor viewport
private int bottomPaddingLines = 6;
// Search bar layout tracking to allow idempotent/resilient resizing
private int searchBaseY = 0;
private int searchBaseHeight = 0;
private int searchAppliedOffset = 0;
private boolean searchBaseInitialized = false;
private int getPaddedLineCount() {
if (container == null) return 0;
// Only add bottom padding when the content is already scrollable. This avoids
// introducing a scrollbar when there's nothing to scroll for.
if (container.linesCount > container.visibleLines - bottomPaddingLines) {
return Math.max(0, container.linesCount + bottomPaddingLines);
} else {
return container.linesCount;
}
}
// ==================== HELPER CLASS INSTANCES ====================
private final ScrollState scroll = new ScrollState();
private final SelectionState selection = new SelectionState();
private final HoverState hoverState = new HoverState();
/** When true, clicking a token will pin its hover tooltip until dismissed. */
public boolean clickToPinEnabled = false;
// ==================== UI COMPONENTS ====================
private int cursorCounter;
private ITextChangeListener listener;
private static int LINE_NUMBER_GUTTER_WIDTH = 25;
// ==================== GUTTER ICONS ====================
/** Hover state for gutter icons - tracks which icon the mouse is over */
private MethodInfo hoveredGutterMethod = null;
// ==================== UNDO/REDO ====================
public List<UndoData> undoList = new ArrayList<>();
public List<UndoData> redoList = new ArrayList<>();
public boolean undoing = false;
// Atomic undo: group typing into word-based undo steps
private long lastTypingTime = 0;
private int lastTypingPos = -1;
// Clipboard tracking for line-copy paste behavior
private boolean lastCopyWasLine = false;
private String lastCopiedLineText = null;
// ==================== KEYS ====================
public static final ScriptEditorKeys KEYS = new ScriptEditorKeys();
public OverlayKeyPresetViewer KEYS_OVERLAY = new OverlayKeyPresetViewer(KEYS);
// ==================== SEARCH/REPLACE ====================
public static final SearchReplaceBar searchBar = new SearchReplaceBar();
// ==================== PASTE SETTINGS ====================
private static final int PASTE_TRAILING_WS_TRIM_THRESHOLD = 16;
// ==================== GO TO LINE ====================
private final GoToLineDialog goToLineDialog = new GoToLineDialog();
// ==================== RENAME REFACTOR ====================
private final RenameRefactorHandler renameHandler = new RenameRefactorHandler();
// ==================== AUTOCOMPLETE ====================
private final AutocompleteManager autocompleteManager = new AutocompleteManager();
// ==================== CONSTRUCTOR ====================
public GuiScriptTextArea(GuiScreen guiScreen, int id, int x, int y, int width, int height, String text) {
super(id, guiScreen, x, y, width, height, null);
init(x, y, width, height, text);
if (guiScreen instanceof GuiScriptInterface)
this.parent = (GuiScriptInterface) guiScreen;
}
public void init(int x, int y, int width, int height, String text) {
this.x = xPosition = x;
this.y = yPosition = y;
this.width = width;
this.height = height;
this.undoing = true;
this.setText(text);
this.undoing = false;
setCallbacks();
// Reset search-base tracking whenever the editor is (re)initialized
this.searchBaseY = 0;
this.searchBaseHeight = 0;
this.searchAppliedOffset = 0;
this.searchBaseInitialized = false;
KEYS_OVERLAY.openOnClick = true;
initGui();
initializeKeyBindings();
// Propagate click-to-pin option into hover state
hoverState.setClickToPinEnabled(clickToPinEnabled);
}
public void initGui() {
int endX = x + width, endY = y + height;
int xOffset = hasVerticalScrollbar() ? -8 : -2;
KEYS_OVERLAY.scale = 0.75f;
KEYS_OVERLAY.borderCol1 = KEYS_OVERLAY.borderCol2 = 0xFF3c3c3c;
int overlayWidth = 160;
KEYS_OVERLAY.initGui(x + (width - overlayWidth) / 2 + 5, y + height / 10, overlayWidth,
height - height / 5 - 10);
KEYS_OVERLAY.viewButton.scale = 0.45f;
KEYS_OVERLAY.viewButton.initGui(endX + xOffset, endY - 26);
// Dismiss autocomplete on resize to avoid positioning issues
if (autocompleteManager != null) {
autocompleteManager.dismiss();
}
// Initialize search bar (preserves state across initGui calls)
searchBar.initGui(x, y, width);
if (searchBar.isVisible()) { // If open
// Shift viewport down again using the bar's current height
searchBar.callback.resizeEditor(true, searchBar.getTotalHeight());
if (!active) // Focus search if opening another script tab & bar is open
searchBar.focus(false);
}
// Initialize Go To Line dialog
goToLineDialog.initGui(x, y, width);
}
public void setCallbacks() {
searchBar.setCallback(new SearchReplaceBar.SearchCallback() {
@Override
public String getText() {
return GuiScriptTextArea.this.text;
}
public String getHighlightedWord() {
return GuiScriptTextArea.this.highlightedWord;
}
@Override
public int getSelectionStart() {
return GuiScriptTextArea.this.selection.getStartSelection();
}
@Override
public int getSelectionEnd() {
return GuiScriptTextArea.this.selection.getEndSelection();
}
@Override
public void setText(String newText) {
GuiScriptTextArea.this.setText(newText);
}
@Override
public void scrollToPosition(int position) {
// Find line containing position and scroll to it
if (container == null || container.lines == null)
return;
// Calculate offset to account for search bar height
int searchBarOffset = searchBar.getTotalHeight();
int effectiveHeight = GuiScriptTextArea.this.height - searchBarOffset;
int visibleLines = effectiveHeight / container.lineHeight;
// Calculate how many lines the search bar covers
int linesHiddenBySRB = searchBarOffset > 0 ? (int) Math.ceil(
(double) searchBarOffset / container.lineHeight) : 0;
for (int i = 0; i < container.lines.size(); i++) {
LineData ld = container.lines.get(i);
if (position >= ld.start && position < ld.end) {
int visible = Math.max(1, visibleLines);
int effectiveVisible = Math.max(1, visible - bottomPaddingLines);
int maxScroll = Math.max(0, getPaddedLineCount() - visible);
int targetLine = i;
// If search bar is visible and would hide this line, scroll down so it's visible
// The target line should appear below the search bar, not under it
int currentScroll = scroll.getScrolledLine();
int firstVisibleLine = currentScroll + linesHiddenBySRB;
if (searchBarOffset > 0 && targetLine < firstVisibleLine) {
// Force scroll so target line appears just below the search bar
scroll.setTargetScroll(Math.max(0, targetLine - linesHiddenBySRB), maxScroll);
} else {
scroll.scrollToLine(targetLine, effectiveVisible, maxScroll);
}
break;
}
}
}
@Override
public void setSelection(int start, int end) {
selection.setSelection(start, end);
selection.setCursorPositionDirect(end);
}
@Override
public int getGutterWidth() {
return LINE_NUMBER_GUTTER_WIDTH;
}
@Override
public void unfocusMainEditor() {
// Save position but unfocus
active = false;
}
@Override
public void focusMainEditor() {
active = true;
searchBar.resetSelection();
}
@Override
public void onMatchesUpdated() {
// Called when matches change - could be used for UI updates
}
// Robust resize: use base editor bounds and apply the requested offset
public void resizeEditor(boolean open, int barHeight) {
int desiredOffset = Math.max(0, barHeight);
// Initialize base values if not set
if (!searchBaseInitialized) {
searchBaseY = GuiScriptTextArea.this.y;
searchBaseHeight = GuiScriptTextArea.this.height;
searchAppliedOffset = 0;
searchBaseInitialized = true;
}
int targetOffset = open ? desiredOffset : 0;
if (targetOffset == searchAppliedOffset)
return; // already in desired state
// Compute new bounds from base values (idempotent)
int newY = searchBaseY + targetOffset;
int newHeight = Math.max(12, searchBaseHeight - targetOffset);
GuiScriptTextArea.this.y = newY;
GuiScriptTextArea.this.height = newHeight;
searchAppliedOffset = targetOffset;
if (container != null)
container.visibleLines = Math.max(GuiScriptTextArea.this.height / container.lineHeight - 1, 1);
}
});
// Initialize Go To Line dialog with callback
goToLineDialog.setCallback(new GoToLineDialog.GoToLineCallback() {
@Override
public int getLineCount() {
return container != null ? container.linesCount : 0;
}
@Override
public int getColumnCount(int lineIndex) {
if (container == null || container.lines == null || lineIndex < 0 || lineIndex >= container.lines.size()) {
return 0;
}
LineData ld = container.lines.get(lineIndex);
return ld.end - ld.start;
}
@Override
public void goToLineColumn(int line, int column) {
if (container == null || container.lines == null)
return;
// Convert 1-indexed line to 0-indexed
int lineIdx = line - 1;
if (lineIdx < 0 || lineIdx >= container.lines.size())
return;
LineData ld = container.lines.get(lineIdx);
int lineLength = ld.end - ld.start;
// Convert 1-indexed column to 0-indexed, clamp to line length
// lineLength - 1 because the line's end is the start of the next line
int col = Math.max(0, Math.min(column - 1, lineLength - 1));
int position = ld.start + col;
// Set cursor position
selection.reset(position);
// Scroll to make the line visible
int visible = GuiScriptTextArea.this.height / (container != null ? container.lineHeight : 12);
int effectiveVisible = Math.max(1, visible - bottomPaddingLines);
int maxScroll = Math.max(0, getPaddedLineCount() - visible);
scroll.scrollToLine(lineIdx, effectiveVisible, maxScroll);
}
@Override
public void unfocusMainEditor() {
// Save position but unfocus
active = false;
}
@Override
public void focusMainEditor() {
active = true;
selection.markActivity();
}
@Override
public void onDialogClose() {
active = true;
selection.markActivity();
}
});
// Initialize Rename Refactor handler with callback
renameHandler.setCallback(new RenameRefactorHandler.RenameCallback() {
@Override
public String getText() {
return GuiScriptTextArea.this.text;
}
@Override
public void setText(String newText) {
GuiScriptTextArea.this.setText(newText);
}
@Override
public List<LineData> getLines() {
return container != null ? container.lines : new ArrayList<>();
}
@Override
public int getCursorPosition() {
return selection.getCursorPosition();
}
public SelectionState getSelectionState() {
return selection;
}
@Override
public void setCursorPosition(int pos) {
selection.reset(pos);
}
@Override
public void unfocusMainEditor() {
active = false;
}
@Override
public void focusMainEditor() {
active = true;
selection.markActivity();
}
@Override
public int getGutterWidth() {
return LINE_NUMBER_GUTTER_WIDTH;
}
@Override
public int getLineHeight() {
return container != null ? container.lineHeight : 12;
}
@Override
public int getScrolledLine() {
return scroll.getScrolledLine();
}
@Override
public double getFractionalOffset() {
return scroll.getFractionalOffset();
}
@Override
public void scrollToPosition(int pos) {
if (container == null || container.lines == null)
return;
for (int i = 0; i < container.lines.size(); i++) {
LineData ld = container.lines.get(i);
if (pos >= ld.start && pos < ld.end) {
int visible = Math.max(1, container.visibleLines);
int effectiveVisible = Math.max(1, visible - bottomPaddingLines);
int maxScroll = Math.max(0, getPaddedLineCount() - visible);
scroll.scrollToLine(i, effectiveVisible, maxScroll);
break;
}
}
}
@Override
public ScriptTextContainer getContainer() {
return container;
}
@Override
public void setTextWithoutUndo(String newText) {
// Set text without creating an undo entry (for live rename preview)
boolean wasUndoing = undoing;
undoing = true;
setText(newText);
undoing = wasUndoing;
}
@Override
public void pushUndoState(String textState, int cursor) {
// Push a specific text state to the undo list
if (!undoing) {
undoList.add(new UndoData(textState, cursor));
redoList.clear();
}
}
@Override
public int getViewportWidth() {
return width - LINE_NUMBER_GUTTER_WIDTH - 8; // Account for gutter and scrollbar
}
});
// Initialize Autocomplete Manager with callback
autocompleteManager.setInsertCallback(new AutocompleteManager.InsertCallback() {
@Override
public void insertText(String text, int startPosition) {
// Replace text from startPosition to current cursor
String fullText = GuiScriptTextArea.this.text;
int cursorPos = selection.getCursorPosition();
// Bounds check to prevent StringIndexOutOfBoundsException
int start = Math.max(0, Math.min(startPosition, fullText.length()));
int cursor = Math.max(start, Math.min(cursorPos, fullText.length()));
String before = fullText.substring(0, start);
String after = fullText.substring(cursor);
setText(before + text + after);
selection.reset(start + text.length());
scrollToCursor();
}
@Override
public void replaceTextRange(String text, int startPosition, int endPosition) {
// Replace text from startPosition to endPosition
String fullText = GuiScriptTextArea.this.text;
// Bounds check to prevent StringIndexOutOfBoundsException
int start = Math.max(0, Math.min(startPosition, fullText.length()));
int end = Math.max(start, Math.min(endPosition, fullText.length()));
String before = fullText.substring(0, start);
String after = fullText.substring(end);
setText(before + text + after);
selection.reset(start + text.length());
scrollToCursor();
}
@Override
public void addImport(String importPath) {
// Add import statement and sort all imports
addAndSortImport(importPath);
}
@Override
public int getCursorPosition() {
return selection.getCursorPosition();
}
@Override
public void setCursorPosition(int position) {
selection.reset(Math.max(0, Math.min(position, GuiScriptTextArea.this.text.length())));
scrollToCursor();
}
@Override
public String getText() {
return GuiScriptTextArea.this.text;
}
@Override
public int[] getCursorScreenPosition() {
// Calculate screen position of cursor for menu placement
int cursorLine = getCursorLineIndex();
int cursorCol = 0;
if (container != null && container.lines != null && cursorLine < container.lines.size()) {
LineData ld = container.lines.get(cursorLine);
int cursorOffset = selection.getCursorPosition() - ld.start;
ScriptLine sl = container.getDocument() != null ? container.getDocument().getLine(cursorLine) : null;
if (sl != null) {
cursorCol = sl.getRenderedWidth(0, Math.min(cursorOffset, ld.text.length()));
} else {
cursorCol = ClientProxy.Font.width(ld.text.substring(0, Math.min(cursorOffset, ld.text.length())));
}
}
int screenX = GuiScriptTextArea.this.x + LINE_NUMBER_GUTTER_WIDTH + 1 + cursorCol;
int lineY = cursorLine - scroll.getScrolledLine();
int screenY = GuiScriptTextArea.this.y + lineY * (container != null ? container.lineHeight : 12);
return new int[] { screenX, screenY };
}
@Override
public int[] getViewportDimensions() {
Minecraft mc = Minecraft.getMinecraft();
ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
return new int[] { sr.getScaledWidth(), sr.getScaledHeight() };
}
});
}
public boolean fullscreen() {
return GuiScriptInterface.isFullscreen;
}
// ==================== RENDERING ====================
public void drawTextBox(int xMouse, int yMouse) {
if (!visible)
return;
clampSelectionBounds();
// Dynamically calculate gutter width based on line count digits + icon space
if (container != null && container.linesCount > 0) {
int maxLineNum = container.linesCount;
String maxLineStr = "" + maxLineNum;
int digitWidth = ClientProxy.Font.width(maxLineStr);
LINE_NUMBER_GUTTER_WIDTH = digitWidth + 10 + GutterIconRenderer.ICON_GUTTER_WIDTH; // 10px padding + icon space
}
// Draw outer border around entire area
int offset = fullscreen() ? 2 : 1;
drawRect(x - offset, y - offset - searchBar.getTotalHeight(), x + width + offset, y + height + offset,
ScriptColorScheme.getBackgroundStyle().getBorderColor());
int searchHeight = searchBar.getTotalHeight();
// Draw line number gutter background
int viewportX = x + LINE_NUMBER_GUTTER_WIDTH;
drawRect(x, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, ScriptColorScheme.getBackgroundStyle().getGutterColor());
// Draw text viewport background (starts after gutter)
drawRect(x + LINE_NUMBER_GUTTER_WIDTH, y, x + width, y + height, ScriptColorScheme.getBackgroundStyle().getBackgroundColor());
// Draw separator line between gutter and text area
drawRect(x + LINE_NUMBER_GUTTER_WIDTH-1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, ScriptColorScheme.getBackgroundStyle().getGutterSeparatorColor());
// Enable scissor test to clip drawing to the TEXT viewport rectangle (excludes gutter)
GL11.glEnable(GL11.GL_SCISSOR_TEST);
scissorViewport();
container.visibleLines = (height / container.lineHeight);
int maxScroll = Math.max(0, getPaddedLineCount() - container.visibleLines);
// Handle mouse wheel scroll
// Take whatever the parent screen has left for this frame rather than polling the wheel
// again - the poll is destructive and the parent has already drained it.
int wheelDelta = listener instanceof GuiNPCInterface ? ((GuiNPCInterface) listener).mouseScroll : Mouse.getDWheel();
if (listener instanceof GuiNPCInterface) {
((GuiNPCInterface) listener).mouseScroll = 0;
// Intercept wheel scroll for tooltip (highest priority)
// Only consume the wheel event when the tooltip actually has content to scroll;
// otherwise let it fall through to the editor so non-scrollable tooltips don't eat input.
if (wheelDelta != 0 && hoverState.isTooltipVisible()) {
if (hoverState.isMouseOverTooltip(xMouse, yMouse)) {
hoverState.scrollTooltip(wheelDelta);
wheelDelta = 0;
} else {
hoverState.clearHover();
}
}
// Let autocomplete menu consume scroll first if visible
if (wheelDelta != 0 && autocompleteManager.isVisible() && autocompleteManager.mouseScrolled(xMouse, yMouse, wheelDelta)) {
// Autocomplete consumed the scroll
} else {
boolean canScroll = !KEYS_OVERLAY.isVisible() || KEYS_OVERLAY.isVisible() && !KEYS_OVERLAY.aboveOverlay;
if (wheelDelta != 0 && canScroll) {
int previousScrollLine = scroll.getScrolledLine();
scroll.applyWheelScroll(wheelDelta, maxScroll);
if (scroll.getScrolledLine() != previousScrollLine) {
hoverState.clearHover();
}
}
}
}
// Handle scrollbar dragging (delegated to ScrollState)
if (scroll.isClickScrolling()) {
int prevLine = scroll.getScrolledLine();
scroll.handleClickScrolling(yMouse, x, y, height, container.visibleLines, getPaddedLineCount(), maxScroll);
if (scroll.getScrolledLine() != prevLine) {
hoverState.clearHover();
}
}
// Handle tooltip scrollbar drag (update each frame; release when button lifted)
if (hoverState.isDraggingScrollbar()) {
if (Mouse.isButtonDown(0)) {
hoverState.updateScrollbarDrag(yMouse);
} else {
hoverState.releaseScrollbarDrag();
}
}
// Handle tooltip panel drag (update each frame; release when button lifted)
if (hoverState.isDraggingTooltip()) {
if (Mouse.isButtonDown(0)) {
hoverState.updateTooltipDrag(xMouse, yMouse);
} else {
hoverState.releaseTooltipDrag();
}
}
// Handle tooltip panel resize (update each frame; release when button lifted)
if (hoverState.isResizingTooltip()) {
if (Mouse.isButtonDown(0)) {
hoverState.updateTooltipResize(xMouse, yMouse);
} else {
hoverState.releaseTooltipResize();
}
}
// Handle autocomplete scrollbar drag
if (autocompleteManager.isDraggingScrollbarMenu()) {
if (Mouse.isButtonDown(0)) {
autocompleteManager.updateScrollbarDrag(yMouse);
} else {
autocompleteManager.releaseScrollbarDrag();
}
}
// Handle autocomplete panel drag
if (autocompleteManager.isDraggingPanel()) {
if (Mouse.isButtonDown(0)) {
autocompleteManager.updatePanelDrag(xMouse, yMouse);
} else {
autocompleteManager.releasePanelDrag();
}
}
// Handle autocomplete panel resize
if (autocompleteManager.isResizingPanel()) {
if (Mouse.isButtonDown(0)) {
autocompleteManager.updatePanelResize(xMouse, yMouse);
} else {
autocompleteManager.releasePanelResize();
}
}
// Update scroll animation
scroll.initializeIfNeeded(scroll.getScrolledLine());
scroll.update(maxScroll);
// Update hover state for token tooltips
updateHoverState(xMouse, yMouse);
// Handle click-dragging for selection
if (clicked) {
clicked = Mouse.isButtonDown(0);
int i = getSelectionPos(xMouse, yMouse);
if (i != selection.getCursorPosition()) {
if (doubleClicked || tripleClicked) {
selection.reset(selection.getCursorPosition());
doubleClicked = false;
tripleClicked = false;
}
setCursor(i, true);
}
} else if (doubleClicked || tripleClicked) {
doubleClicked = false;
tripleClicked = false;
}
// y += searchHeight;
// height -= searchHeight;
// Calculate braces next to cursor to highlight
int startBracket = 0, endBracket = 0;
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];
}
}
List<JavaTextContainer.LineData> list = new ArrayList<>(container.lines);
// Build brace spans: {origDepth, open line, close line, adjustedDepth}
List<int[]> braceSpans = BracketMatcher.computeBraceSpans(text, list);
// Always highlight unmatched braces (positions in text)
List<Integer> unmatchedBraces = BracketMatcher.findUnmatchedBracePositions(text);
// Determine which exact brace span (openLine/closeLine) to highlight indent guides for
int highlightedOpenLine = -1;
int highlightedCloseLine = -1;
if (startBracket != endBracket && startBracket >= 0) {
int bracketLineIdx = -1;
// Only consider curly braces for highlighting the indent guides
boolean isCurlyBracket = false;
char bc = text.charAt(startBracket);
if (text != null && startBracket >= 0 && startBracket < text.length()) {
if (bc == '{' || bc == '}') isCurlyBracket = true;
}
for (int li = 0; li < list.size(); li++) {
LineData ld = list.get(li);
if (startBracket >= ld.start && startBracket < ld.end) {
bracketLineIdx = li;
break;
}
}
if (bracketLineIdx >= 0 && isCurlyBracket) {
// Prefer a span that directly matches the bracket character's line:
// - if the bracket is an opening '{', prefer spans where openLine == bracketLineIdx
// - if the bracket is a closing '}', prefer spans where closeLine == bracketLineIdx
// If no exact match is found, fall back to the smallest enclosing span (innermost).
int bestSize = Integer.MAX_VALUE;
boolean foundExact = false;
char bracketChar = bc; // from earlier
for (int[] span : braceSpans) {
int openLine = span[1];
int closeLine = span[2];
if (bracketLineIdx >= openLine && bracketLineIdx <= closeLine) {
int size = closeLine - openLine;
boolean exactMatch = (bracketChar == '{' && openLine == bracketLineIdx) || (bracketChar == '}' && closeLine == bracketLineIdx);
if (exactMatch) {
// Prefer exact matches immediately (still choose smallest exact span)
if (!foundExact || size < bestSize) {
foundExact = true;
bestSize = size;
highlightedOpenLine = openLine;
highlightedCloseLine = closeLine;
}
} else if (!foundExact) {
// keep the smallest enclosing span as a fallback
if (size < bestSize) {
bestSize = size;
highlightedOpenLine = openLine;
highlightedCloseLine = closeLine;
}
}
}
}
}
}
highlightedWord = null;
if (selection.hasSelection()) {
Matcher m = container.regexWord.matcher(text);
while (m.find()) {
if (m.start() == selection.getStartSelection() && m.end() == selection.getEndSelection()) {
highlightedWord = text.substring(selection.getStartSelection(), selection.getEndSelection());
}
}
}
// Apply fractional GL translate for sub-pixel smooth scrolling
double fracOffset = scroll.getFractionalOffset();
float fracPixels = (float) (fracOffset * container.lineHeight);
GL11.glPushMatrix();
GL11.glTranslatef(0.0f, -fracPixels, 0.0f);
// Expand render range by one line above/below so partially-visible lines are drawn
int renderStart = Math.max(0, scroll.getScrolledLine() - 1);
// Compute the last line index to render, including the last partially-visible line if any.
// Adds the fractional scroll offset (fracOffset * lineHeight) to ensure the bottom line is drawn
// when only part of it is visible in the viewport.
int renderEnd = (int) Math.min(list.size() - 1,
scroll.getScrolledLine() + container.visibleLines + fracPixels + 1);
// Strings start drawing vertically this much into the line.
int stringYOffset = 2;
// Render LINE GUTTER numbers
for (int i = renderStart; i <= renderEnd; i++) {
int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight + stringYOffset;
String lineNum = "" + (i + 1);
int lineNumWidth = ClientProxy.Font.width(lineNum);
int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5 - GutterIconRenderer.ICON_GUTTER_WIDTH; // right-align before icon space
int lineNumY = posY + 1;
// Highlight current line number
int lineNumColor = ScriptColorScheme.getBackgroundStyle().getLineNumberColor();
if (active && isEnabled()) {
for (int li = 0; li < list.size(); li++) {
LineData ld = list.get(li);
if (selection.getCursorPosition() >= ld.start && selection.getCursorPosition() < ld.end || (li == list.size() - 1 && selection.getCursorPosition() == text.length())) {
if (li == i) {
lineNumColor = ScriptColorScheme.getBackgroundStyle().getLineNumberActiveColor();
break;
}
}
}
}
ClientProxy.Font.drawString(lineNum, lineNumX, lineNumY, lineNumColor);
}
// Render Viewport
for (int i = renderStart; i <= renderEnd; i++) {
LineData data = list.get(i);
ScriptLine scriptLine = container.getDocument().getLine(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 - scroll.getScrolledLine()) * container.lineHeight;
if (i >= renderStart && i <= renderEnd) {
//Highlight braces the cursor position is on
if (startBracket != endBracket) {
if (startBracket >= data.start && startBracket < data.end) {
int s = scriptLine.getRenderedWidth(0, startBracket - data.start);
int e = scriptLine.getRenderedWidth(0, startBracket - data.start + 1) + 1;
drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00);
}
if (endBracket >= data.start && endBracket < data.end) {
int s = scriptLine.getRenderedWidth(0, endBracket - data.start);
int e = scriptLine.getRenderedWidth(0, endBracket - data.start + 1) + 1;
drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00);
}
}
// Highlight unmatched braces in this line (always red)
if (unmatchedBraces != null && !unmatchedBraces.isEmpty()) {
for (int ubPos : unmatchedBraces) {
if (ubPos >= data.start && ubPos < data.end) {
int rel = ubPos - data.start;
int s = scriptLine.getRenderedWidth(0, rel);
int e = scriptLine.getRenderedWidth(0, rel + 1) + 1;
drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0xffcc0000);
}
}
}
//Highlight words
if (highlightedWord != null) {
Matcher m = container.regexWord.matcher(line);
while (m.find()) {
if (line.substring(m.start(), m.end()).equals(highlightedWord)) {
int s = scriptLine.getRenderedWidth(0, m.start());
int e = scriptLine.getRenderedWidth(0, m.end()) + 1;
drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x99004c00);
}
}
}
// Highlight search matches
if (searchBar.isVisible()) {
List<int[]> searchMatches = searchBar.getMatches();
int currentMatchIdx = searchBar.getCurrentMatchIndex();
for (int mi = 0; mi < searchMatches.size(); mi++) {
int[] match = searchMatches.get(mi);
// Check if match overlaps with this line
if (match[1] > data.start && match[0] < data.end) {
int matchStart = Math.max(match[0] - data.start, 0);
int matchEnd = Math.min(match[1] - data.start, line.length());
if (matchStart < matchEnd) {
int s = scriptLine.getRenderedWidth(0, matchStart);
int e = scriptLine.getRenderedWidth(0, matchEnd) + 1;
boolean isExcluded = searchBar.isMatchExcluded(mi);
// Current match gets brighter highlight, others get dimmer
int highlightColor = (mi == currentMatchIdx) ? 0xBB4488ff : 0x662266aa;
if (isExcluded) {
highlightColor = 0x33666666; // Dimmer for excluded matches
}
int highlightX = x + LINE_NUMBER_GUTTER_WIDTH + 1 + s;
int highlightEndX = x + LINE_NUMBER_GUTTER_WIDTH + 1 + e;
drawRect(highlightX, posY, highlightEndX, posY + container.lineHeight, highlightColor);
// Draw strikethrough line for excluded matches
if (isExcluded) {
int strikeY = posY + container.lineHeight / 2;
drawRect(highlightX, strikeY, highlightEndX, strikeY + 1, 0xFFaa4444);
}
}
}
}
}
// Highlight rename refactor occurrences
if (renameHandler.isActive()) {
List<int[]> renameOccurrences = renameHandler.getOccurrences();
for (int[] occ : renameOccurrences) {
// Check if occurrence overlaps with this line
if (occ[1] >= data.start && occ[0] <= data.end) {
int occStart = Math.max(occ[0] - data.start, 0);
int occEnd = Math.min(occ[1] - data.start, line.length());
// Handle empty word case - draw 1 pixel wide box
boolean isEmpty = (occ[0] == occ[1]);
if (occStart <= occEnd) { // Changed from < to <= to handle empty case
int s = scriptLine.getRenderedWidth(0, occStart);
int e = isEmpty ? s + 2 : scriptLine.getRenderedWidth(
0, occEnd) + 1; // 2px wide for empty
int occX = x + LINE_NUMBER_GUTTER_WIDTH + s;
int occEndX = x + LINE_NUMBER_GUTTER_WIDTH + 2 + e;
boolean isPrimary = renameHandler.isPrimaryOccurrence(occ[0]);
// Draw background highlight
int bgColor = isPrimary ? 0x55335577 : 0x33224466;
drawRect(occX, posY, occEndX, posY + container.lineHeight, bgColor);
// Draw white border for primary occurrence (IntelliJ-like)
if (isPrimary) {
int borderColor = 0xDDFFFFFF;
//RED BG
drawRect(occX, posY, occEndX, posY + container.lineHeight, 0x33ff0000);
// Top border
drawRect(occX, posY, occEndX, posY + 1, borderColor);
// Bottom border
drawRect(occX, posY + container.lineHeight - 1, occEndX,
posY + container.lineHeight, borderColor);
// Left border
drawRect(occX, posY, occX + 1, posY + container.lineHeight, borderColor);
// Right border
drawRect(occEndX - 1, posY, occEndX, posY + container.lineHeight, borderColor);
// Draw cursor inside the primary occurrence
if (renameHandler.shouldShowCursor()) {
int cursorInWord = renameHandler.getCursorInWord();
String currentWord = renameHandler.getCurrentWord();
if (currentWord != null && cursorInWord >= 0 && cursorInWord <= currentWord.length()) {
String beforeCursor = currentWord.substring(0,
Math.min(cursorInWord, currentWord.length()));
int cursorX = occX + scriptLine.getRenderedWidth(occStart, occStart + beforeCursor.length());
// drawRect(cursorX, posY + 1, cursorX + 1, posY + container.lineHeight - 1,
// 0xFFFFFFFF);
}
}
}
}
}
}
}
// Highlight the current line (light gray) under any selection
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 (selection.hasSelection() && selection.getEndSelection() > data.start && selection.getStartSelection() <= data.end) {
if (selection.getStartSelection() < data.end) {
int s = scriptLine.getRenderedWidth(
0, Math.max(selection.getStartSelection() - data.start, 0));
int e = scriptLine.getRenderedWidth(
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, 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];
int openLine = span[1];
int closeLine = span[2];
int depth = span.length > 3 ? span[3] : originalDepth;
// Skip top-level (depth 1) using the original depth to avoid hiding nested guides when adjusted
if (originalDepth <= 1)
continue;
int startLine = openLine + 1; // start under the opening brace
int endLine = closeLine - 1; // stop before the closing brace
if (startLine > endLine)
continue;
if (endLine < visStart || startLine > visEnd)
continue;
int drawStart = Math.max(startLine, visStart);
int drawEnd = Math.min(endLine, visEnd);
// Compute horizontal position: 4 spaces per indent level, minus a tiny left offset
int safeDepth = Math.max(1, depth);
int spaces = (safeDepth - 1) * 4;
StringBuilder sb = new StringBuilder();
for (int k = 0; k < spaces; k++)
sb.append(' ');
int px = ClientProxy.Font.width(sb.toString());
int gx = x + LINE_NUMBER_GUTTER_WIDTH + 4 + px - 2; // shift left ~2px for the IntelliJ feel
boolean highlighted = (openLine == highlightedOpenLine && closeLine == highlightedCloseLine);
int guideColor = highlighted ? 0x9933cc00 : 0x33FFFFFF;
int topY = y + (drawStart - scroll.getScrolledLine()) * container.lineHeight;
int bottomY = y + (endLine - scroll.getScrolledLine() + 1) * container.lineHeight;
if(highlighted)
bottomY-=2;
drawRect(gx, topY, gx + 1, bottomY, guideColor);
}
}
int yPos = posY + stringYOffset;
//data.drawString(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0);
//scriptLine.drawString(x+LINE_NUMBER_GUTTER_WIDTH + 1, yPos, 0xFFe0e0e0);
scriptLine.drawStringHex(x + LINE_NUMBER_GUTTER_WIDTH + 1, yPos);
// Draw cursor: pause blinking while user is active recently
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 + scriptLine.getRenderedWidth(
0, Math.min(selection.getCursorPosition() - data.start, line.length()));
drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, ScriptColorScheme.getBackgroundStyle().getCaretColor());
}
}
}
// Render gutter icons for method override/implements
if (container != null && container.getDocument() != null) {
hoveredGutterMethod = GutterIconRenderer.renderIcons(container.lineHeight,
x + LINE_NUMBER_GUTTER_WIDTH - GutterIconRenderer.ICON_GUTTER_WIDTH + 1, y, renderStart, renderEnd,
scroll.getScrolledLine(), stringYOffset, container.getDocument().getAllMethods(), container.lines,
xMouse, yMouse, fracPixels);
}
GL11.glPopMatrix();
GL11.glDisable(GL11.GL_SCISSOR_TEST);
if (hasVerticalScrollbar()) {
Minecraft.getMinecraft().renderEngine.bindTexture(GuiCustomScroll.resource);
int effLines = Math.max(1, getPaddedLineCount());
int sbSize = Math.max((int) (1f * (container.visibleLines) / effLines * height), 2);
int posX = x + width - 6;
double linesCount = (double) effLines;
int posY = (int) (y + 1f * scroll.getScrollPos() / linesCount * (height - 4)) + 1;
drawRect(posX, posY, posX + 5, posY + sbSize + 2, ScriptColorScheme.getBackgroundStyle().getScrollbarColor());
}
if (parent != null)
parent.fullscreenButton.draw(xMouse, yMouse);
searchBar.ownerGui = parent;
searchBar.draw(xMouse, yMouse);
// Draw go to line dialog (overlays everything)
goToLineDialog.draw(xMouse, yMouse);
KEYS_OVERLAY.draw(xMouse, yMouse, wheelDelta);
autocompleteManager.getMenu().ownerGui = parent;
autocompleteManager.draw(xMouse, yMouse);
// Draw hover tooltips (on top of everything)
if (hoverState.isTooltipVisible()) {
int xOffset = hasVerticalScrollbar() ? -8 : -2;
int viewportWidth = width - LINE_NUMBER_GUTTER_WIDTH;
int viewportY = y;
int viewportHeight = height;
TokenHoverRenderer.render(hoverState, viewportX, viewportWidth+xOffset, viewportY, viewportHeight,parent);
}
// Draw gutter icon tooltip
if (hoveredGutterMethod != null) {
GutterIconRenderer.renderTooltip(hoveredGutterMethod, xMouse, yMouse, x, width, y, height);
}
}
private void scissorViewport() {
Minecraft mc = Minecraft.getMinecraft();
ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
int scaleFactor = sr.getScaleFactor();
double panX = parent != null ? parent.getPanX() : 0;
double panY = parent != null ? parent.getPanY() : 0;
int scissorX = (int)((this.x - panX) * scaleFactor);
int scissorY = (int)((sr.getScaledHeight() - (this.y - panY + this.height)) * scaleFactor);
int scissorW = this.width * scaleFactor;
int scissorH = this.height * scaleFactor;
GL11.glScissor(scissorX, scissorY, scissorW, scissorH);
}
// ==================== SELECTION & CURSOR POSITION ====================
// Get cursor position from mouse coordinates
private int getSelectionPos(int xMouse, int yMouse) {
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).
// Use a double here (no integer rounding) so clicks that land on partially
// visible lines (fractional positions) correctly hit that line.
double fracPixels = scroll.getFractionalOffset() * container.lineHeight;
double yMouseD = yMouse + fracPixels;
ArrayList list = new ArrayList(this.container.lines);
for (int i = 0; i < list.size(); ++i) {
LineData data = (LineData) list.get(i);
//+1 to account for the fractional line
if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines +1) {
double yPos = (i - scroll.getScrolledLine()) * this.container.lineHeight;
if (yMouseD >= yPos && yMouseD < yPos + this.container.lineHeight) {
ScriptLine scriptLine = container.getDocument() != null ? container.getDocument().getLine(i) : null;
int lineWidth = 0;
char[] chars = data.text.toCharArray();
for (int j = 1; j <= chars.length; ++j) {
int w = scriptLine != null
? scriptLine.getRenderedWidth(0, j)
: ClientProxy.Font.width(data.text.substring(0, j));
if (xMouse < lineWidth + (w - lineWidth) / 2) {
return data.start + j - 1;
}
lineWidth = w;
}
// Place cursor after the last visible character of the line.
// `data.end - 1` previously pointed at the newline for non-last lines
// which made clicks land on the newline rather than after the text.
// Use data.start + chars.length to return the position directly
// after the line's characters, clamped to the total text length.
int posAfterChars = data.start + chars.length;
return Math.min(posAfterChars, text.length());
}
}
}
return this.container.text.length();
}
// Find which line the cursor is on (0-indexed)
private int getCursorLineIndex() {
return selection.getCursorLineIndex(container.lines, text != null ? text.length() : 0);
}
/**
* Get the token at a specific screen position (mouse coordinates).
* Also returns the token's screen position and dimensions for tooltip placement.
*
* @param xMouse Screen X coordinate
* @param yMouse Screen Y coordinate
* @return Array of [Token, tokenScreenX, tokenScreenY, tokenWidth] or null if no token
*/
private Object[] getTokenAtScreenPosition(int xMouse, int yMouse) {
if (container == null || !(container instanceof ScriptTextContainer)) {
return null;
}
ScriptTextContainer scriptContainer = (ScriptTextContainer) container;
// Check if mouse is within the text viewport
int viewportX = x + LINE_NUMBER_GUTTER_WIDTH + 1;
if (xMouse < viewportX || xMouse > x + width || yMouse < y || yMouse > y + height) {
return null;
}
// Adjust mouse position relative to text area
int relativeY = yMouse - y;
// Account for fractional scrolling
double fracOffset = scroll.getFractionalOffset();
double fracPixels = fracOffset * container.lineHeight;
double adjustedY = relativeY + fracPixels;
// Find which line the mouse is over
int lineIdx = scroll.getScrolledLine() + (int)(adjustedY / container.lineHeight);
if (lineIdx < 0 || lineIdx >= container.lines.size()) {
return null;
}
ScriptLine lineData = container.getDocument().getLine(lineIdx);
String lineText = lineData.getText();
int lineStart = lineData.getGlobalStart();
// Get the token at this position
int globalMouseX = getSelectionPos(xMouse,yMouse);
Token token = lineData.getTokenAt(globalMouseX, (t) -> t.getType() != TokenType.DEFAULT); // Ignore default tokens i.e. whitespaces
if (token == null)
return null;
// Calculate token's screen position
int tokenLocalStart = token.getGlobalStart() - lineStart;
int tokenLocalEnd = token.getGlobalEnd() - lineStart;
tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length()));
tokenLocalEnd = Math.max(0, Math.min(tokenLocalEnd, lineText.length()));
int tokenScreenX = viewportX + lineData.getRenderedWidth(0, tokenLocalStart);
int tokenScreenY = y + (lineIdx - scroll.getScrolledLine()) * container.lineHeight - (int)fracPixels;
int tokenWidth = lineData.getRenderedWidth(tokenLocalStart, tokenLocalEnd);
return new Object[] { token, tokenScreenX, tokenScreenY, tokenWidth };
}
/**
* Update hover state based on current mouse position.
* Called every frame from drawTextBox.
*/
private void updateHoverState(int xMouse, int yMouse) {
// Keep tooltip alive and skip token re-evaluation while it is being dragged
if (hoverState.isDraggingTooltip()) {
hoverState.setLastMousePosition(xMouse, yMouse);
return;
}
// Keep tooltip alive while resizing (mouse may be outside the panel bounds)
if (hoverState.isResizingTooltip()) {
hoverState.setLastMousePosition(xMouse, yMouse);
return;
}
// Tooltip has been dragged or resized — keep it pinned until an explicit outside click
if (hoverState.hasOverriddenPosition() || hoverState.hasOverriddenSize()) {
hoverState.setLastMousePosition(xMouse, yMouse);
return;
}
// If mouse is currently over the tooltip panel, keep it visible — do not clear or re-evaluate tokens
if (hoverState.isTooltipVisible() && hoverState.isMouseOverTooltip(xMouse, yMouse)) {
hoverState.setLastMousePosition(xMouse, yMouse);
return;
}
// Don't show tooltips when not active, clicking, or when overlays are visible
if (!isEnabled() || clicked || searchBar.isVisible() ||
goToLineDialog.isVisible() || KEYS_OVERLAY.isVisible() || renameHandler.isActive() || autocompleteManager.isVisible()) {
hoverState.clearHover();
return;
}
// Get token at current mouse position
Object[] tokenInfo = getTokenAtScreenPosition(xMouse, yMouse);
if (tokenInfo != null) {
Token token = (Token) tokenInfo[0];
int tokenScreenX = (Integer) tokenInfo[1];
int tokenScreenY = (Integer) tokenInfo[2];
int tokenWidth = (Integer) tokenInfo[3];
hoverState.update(xMouse, yMouse, token, tokenScreenX, tokenScreenY, tokenWidth);
} else {
// If mouse is over the tooltip box, keep tooltip visible — do not clear
if (hoverState.isMouseOverTooltip(xMouse, yMouse)) {
hoverState.setLastMousePosition(xMouse, yMouse);
return;
}
hoverState.update(xMouse, yMouse, null, 0, 0, 0);
}
}
// Scroll viewport to keep cursor visible (minimal adjustment, like IntelliJ)
// Only scrolls if cursor is outside the visible area
private void scrollToCursor() {
if (container == null || container.lines == null || container.lines.isEmpty()) return;
int lineIdx = getCursorLineIndex();
int visible = Math.max(1, container.visibleLines);
int effectiveVisible = Math.max(1, visible - bottomPaddingLines);
int maxScroll = Math.max(0, getPaddedLineCount() - visible);
scroll.scrollToLine(lineIdx, effectiveVisible, maxScroll);
}
// ==================== KEY BINDINGS INITIALIZATION ====================
/**
* Initialize key bindings for editor shortcuts using ScriptEditorKeys.
* Centralized active/enabled checking ensures shortcuts only fire when appropriate.
*/
private void initializeKeyBindings() {
// Helper: execute action only if text area is active and enabled
Supplier<Boolean> isActive = () -> active && isEnabled() && !KEYS_OVERLAY.isVisible();
Supplier<Boolean> openBoxes = () -> !KEYS_OVERLAY.isVisible();
// CUT: Copy selection to clipboard and delete it. If no selection, cut the current sentence.
KEYS.CUT.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
if (selection.hasSelection()) {
NoppesStringUtils.setClipboardContents(selection.getSelectedText(text));
String s = getSelectionBeforeText();
setText(s + getSelectionAfterText());
selection.reset(s.length());
scrollToCursor();
return;
}
// No selection: cut the current sentence (heuristic based on .!? or newline)
if (text == null || text.isEmpty())
return;
int cursor = selection.getCursorPosition();
int start = cursor;
int pos = cursor - 1;
while (pos >= 0) {
char ch = text.charAt(pos);
if (ch == '.' || ch == '!' || ch == '?' || ch == '\n') {
start = pos + 1;
break;
}
pos--;
}
while (start < cursor && Character.isWhitespace(text.charAt(start))) start++;
int end = cursor;
pos = cursor;
while (pos < text.length()) {
char ch = text.charAt(pos);
if (ch == '.' || ch == '!' || ch == '?' || ch == '\n') {
end = pos + 1;
break;
}
pos++;
}
if (end == cursor) end = text.length();
while (end > start && Character.isWhitespace(text.charAt(end - 1))) end--;
if (start >= end) {
// fallback: cut whole current line
int ls = text.lastIndexOf('\n', Math.max(0, cursor - 1));
start = ls == -1 ? 0 : ls + 1;
int le = text.indexOf('\n', cursor);
end = le == -1 ? text.length() : le;
}
if (start < end) {
String cut = text.substring(start, end);
NoppesStringUtils.setClipboardContents(cut);
setText(text.substring(0, start) + text.substring(end));
selection.reset(start);
scrollToCursor();
}
});
// COPY: Copy selection to clipboard (or entire line if no selection)
KEYS.COPY.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
if (selection.hasSelection()) {
NoppesStringUtils.setClipboardContents(selection.getSelectedText(text));
lastCopyWasLine = false;
lastCopiedLineText = null;
} else {
// Copy entire current line (including newline)
int cursor = selection.getCursorPosition();
LineData targetLine = null;
for (LineData line : container.lines) {
if (cursor >= line.start && cursor <= line.end) {
targetLine = line;
break;
}
}
if (targetLine != null) {
int safeStart = Math.max(0, Math.min(targetLine.start, text.length()));
int safeEnd = Math.max(safeStart, Math.min(targetLine.end, text.length()));
String lineText = text.substring(safeStart, safeEnd);
NoppesStringUtils.setClipboardContents(lineText);
lastCopyWasLine = true;
lastCopiedLineText = lineText;
}
}
});
// PASTE: Insert clipboard contents at caret
KEYS.PASTE.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
String clipboardRaw = NoppesStringUtils.getClipboardContents();
if (clipboardRaw == null)
clipboardRaw = "";
boolean isLinePaste = lastCopyWasLine && lastCopiedLineText != null && clipboardRaw.equals(lastCopiedLineText);
String clipboard = FormatHelper.sanitizeClipboard(clipboardRaw, PASTE_TRAILING_WS_TRIM_THRESHOLD);
if (selection.hasSelection()) {
addText(clipboard);
lastCopyWasLine = false;
lastCopiedLineText = null;
scrollToCursor();
return;
}
if (isLinePaste && container != null && container.lines != null) {
LineData currentLine = selection.findCurrentLine(container.lines);
if (currentLine != null) {
int insertPos = Math.max(0, Math.min(currentLine.end, text.length()));
String insertText = clipboard;
// Ensure insertion happens on the line below
if (insertPos > 0 && text.charAt(insertPos - 1) != '\n') {
insertText = "\n" + insertText;
}
// Ensure the inserted line doesn't merge with the following line
if (insertPos < text.length() && !insertText.endsWith("\n")) {
insertText = insertText + "\n";
}
String newText = text.substring(0, insertPos) + insertText + text.substring(insertPos);
setText(newText);
int newCursor = insertPos + (insertText.startsWith("\n") ? 1 : 0);
selection.reset(Math.min(newCursor, newText.length()));
scrollToCursor();
return;
}
}
addText(clipboard);
scrollToCursor();
});
// UNDO: Restore last edit from undo list
// Works in search bar.
KEYS.UNDO.setTask(e -> {
if ((!e.isPress() && !e.isHold()) || !openBoxes.get())
return;
if (searchBar.hasFocus()) {
searchBar.undo();
} else {
if (undoList.isEmpty())
return;
undoing = true;
redoList.add(new UndoData(this.text, selection.getCursorPosition()));
UndoData data = undoList.remove(undoList.size() - 1);
setText(data.text);
selection.reset(data.cursorPosition);
undoing = false;
scrollToCursor();
searchBar.updateMatches();
if (!active)
active = true;
}
});
// REDO: Restore last undone edit from redo list
// Works in search bar.
KEYS.REDO.setTask(e -> {
if ((!e.isPress() && !e.isHold()) || !openBoxes.get())
return;
if (searchBar.hasFocus()) {
searchBar.redo();
} else {
if (redoList.isEmpty())
return;
undoing = true;
undoList.add(new UndoData(this.text, selection.getCursorPosition()));
UndoData data = redoList.remove(redoList.size() - 1);
setText(data.text);
selection.reset(data.cursorPosition);
undoing = false;
scrollToCursor();
searchBar.updateMatches();
if (!active)
active = true;
}
});
// FORMAT: Format/indent code
KEYS.FORMAT.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
formatText();
});
// TOGGLE_COMMENT: Toggle comment for selection or current line
KEYS.TOGGLE_COMMENT.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
if (selection.hasSelection())
toggleCommentSelection();
else
toggleCommentLineAtCursor();
});
// DUPLICATE: Duplicate selection or current line
KEYS.DUPLICATE.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
if (selection.hasSelection()) {
// Multi-line selection duplication
LineData firstLine = null, lastLine = null;
for (LineData line : container.lines) {
if (line.end > selection.getStartSelection() && line.start < selection.getEndSelection()) {
if (firstLine == null)
firstLine = line;
lastLine = line;
}
}
if (firstLine != null && lastLine != null) {
String selectedText = text.substring(firstLine.start, lastLine.end);
int savedStart = selection.getStartSelection();
int savedEnd = selection.getEndSelection();
int insertAt = lastLine.end;
setText(text.substring(0, insertAt) + selectedText + text.substring(insertAt));
selection.setStartSelection(savedStart);
selection.setEndSelection(savedEnd);
selection.setCursorPositionDirect(savedEnd);
}
} else {
// Duplicate current line
for (LineData line : container.lines) {
if (selection.getCursorPosition() >= line.start && selection.getCursorPosition() <= line.end) {
int safeStart = Math.max(0, Math.min(line.start, text.length()));
int safeEnd = Math.max(safeStart, Math.min(line.end, text.length()));
String lineText = text.substring(safeStart, safeEnd);
String insertText = lineText.endsWith("\n") ? lineText : "\n" + lineText;
int insertionPoint = Math.min(line.end, text.length());
setText(text.substring(0, insertionPoint) + insertText + text.substring(insertionPoint));
int newCursor = insertionPoint + insertText.length() - (insertText.endsWith("\n") ? 1 : 0);
selection.reset(Math.max(0, Math.min(newCursor, this.text.length())));
break;
}
}
}
});
// DELETE_LINE: Delete the current line
KEYS.DELETE_LINE.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
if (text == null || text.isEmpty())
return;
int cursor = selection.getCursorPosition();
LineData targetLine = null;
int targetIndex = -1;
for (LineData line : container.lines) {
if (cursor >= line.start && cursor <= line.end) {
targetLine = line;
targetIndex = container.lines.indexOf(line);
break;
}
}
if (targetLine == null)
return;
int start = Math.max(0, Math.min(targetLine.start, text.length()));
int end = Math.max(start, Math.min(targetLine.end, text.length()));
String newText = text.substring(0, start) + text.substring(end);
setText(newText);
int newCursor = 0;
if (targetIndex > 0 && targetIndex <= container.lines.size() - 1) {
LineData previousLine = container.lines.get(targetIndex - 1);
int prevStart = Math.max(0, Math.min(previousLine.start, newText.length()));
int prevEnd = Math.max(prevStart, Math.min(previousLine.end, newText.length()));
int prevLen = Math.max(0, prevEnd - prevStart);
int offsetInLine = Math.max(0, Math.min(cursor - targetLine.start, prevLen));
newCursor = Math.min(prevStart + offsetInLine, newText.length());
}
selection.reset(Math.min(newCursor, newText.length()));
scrollToCursor();
});
// MOVE_LINE_UP: Move current line up
KEYS.MOVE_LINE_UP.setThrottleInterval(50).setTask(e -> {
if ((!e.isPress() && !e.isHold()) || !isActive.get())
return;
if (text == null || text.isEmpty())
return;
int tLen = text.length();
int cursor = Math.max(0, Math.min(selection.getCursorPosition(), tLen));
// Work on real newline-delimited lines, not wrapped LineData.
int currStart = text.lastIndexOf('\n', Math.max(0, cursor - 1));
currStart = currStart == -1 ? 0 : (currStart + 1);
if (currStart == 0)
return; // Can't move first line up
int currEnd = text.indexOf('\n', cursor);
currEnd = currEnd == -1 ? tLen : (currEnd + 1);
int prevEnd = currStart;
int prevStart = text.lastIndexOf('\n', Math.max(0, prevEnd - 2));
prevStart = prevStart == -1 ? 0 : (prevStart + 1);
String previousText = text.substring(prevStart, prevEnd);
String currentText = text.substring(currStart, currEnd);
// If current line is the last line without a trailing newline, but it is being moved
// into the middle, ensure it ends with '\n' by transferring the '\n' from the previous line.
if (!currentText.endsWith("\n") && previousText.endsWith("\n") && !previousText.isEmpty()) {
currentText = currentText + "\n";
previousText = previousText.substring(0, previousText.length() - 1);
}
String before = text.substring(0, prevStart);
String after = text.substring(currEnd);
String newText = before + currentText + previousText + after;
setText(newText);
int currentContentLen = currentText.endsWith("\n") ? Math.max(0, currentText.length() - 1) : currentText.length();
int offsetInLine = Math.max(0, Math.min(cursor - currStart, currentContentLen));
int newCursor = Math.min(prevStart + offsetInLine, newText.length());
selection.reset(newCursor);
scrollToCursor();
});
// MOVE_LINE_DOWN: Move current line down
KEYS.MOVE_LINE_DOWN.setThrottleInterval(50).setTask(e -> {
if ((!e.isPress() && !e.isHold()) || !isActive.get())
return;
if (text == null || text.isEmpty())
return;
int tLen = text.length();
int cursor = Math.max(0, Math.min(selection.getCursorPosition(), tLen));
// Work on real newline-delimited lines, not wrapped LineData.
int currStart = text.lastIndexOf('\n', Math.max(0, cursor - 1));
currStart = currStart == -1 ? 0 : (currStart + 1);
int currEnd = text.indexOf('\n', cursor);
currEnd = currEnd == -1 ? tLen : (currEnd + 1);
if (currEnd >= tLen)
return; // Can't move last line down
int nextStart = currEnd;
int nextEnd = text.indexOf('\n', nextStart);
nextEnd = nextEnd == -1 ? tLen : (nextEnd + 1);
String currentText = text.substring(currStart, currEnd);
String nextText = text.substring(nextStart, nextEnd);
// If the next line is the last line and doesn't end with '\n' (common when it contains
// only indentation spaces), swapping can merge whitespace onto the moved line.
// Fix by transferring the '\n' from currentText to nextText.
if (!nextText.endsWith("\n") && currentText.endsWith("\n") && !currentText.isEmpty()) {
currentText = currentText.substring(0, currentText.length() - 1);
nextText = nextText + "\n";
}
String before = text.substring(0, currStart);
String after = text.substring(nextEnd);
String newText = before + nextText + currentText + after;
setText(newText);
int currentContentLen = currentText.endsWith("\n") ? Math.max(0, currentText.length() - 1) : currentText.length();
int offsetInLine = Math.max(0, Math.min(cursor - currStart, currentContentLen));
int newCursor = Math.min(currStart + nextText.length() + offsetInLine, newText.length());
selection.reset(newCursor);
scrollToCursor();
});
// Check if can open just for SearchReplaceBar and GoToLine
// SEARCH: Open search bar (Ctrl+R)
// Works in search bar.
KEYS.SEARCH.setTask(e -> {
if (!e.isPress() || !openBoxes.get())
return;
unfocusAll();
searchBar.openSearch();
});
// SEARCH_REPLACE: Open search+replace bar (Ctrl+Shift+R)
// Works in search bar.
KEYS.SEARCH_REPLACE.setTask(e -> {
if (!e.isPress() || !openBoxes.get())
return;
unfocusAll();
searchBar.openSearchReplace();
});
// GO_TO_LINE: Open go to line dialog (Ctrl+G)
// Works in search bar.
KEYS.GO_TO_LINE.setTask(e -> {
if (!e.isPress() || !openBoxes.get())
return;
unfocusAll();
goToLineDialog.toggle();
});
// RENAME: Start rename refactoring (Shift+F6)
KEYS.RENAME.setTask(e -> {
if (!e.isPress() || !openBoxes.get())
return;
if (!renameHandler.isActive()) {
unfocusAll();
active = true;
renameHandler.startRename();
}
});
// AUTOCOMPLETE: Trigger autocomplete (Ctrl+Space)
KEYS.AUTOCOMPLETE.setTask(e -> {
if (!e.isPress() || !isActive.get())
return;
autocompleteManager.triggerExplicit();
});
}
public void unfocusAll() {
if (searchBar.hasFocus()) searchBar.unfocus();
if (goToLineDialog.hasFocus()) goToLineDialog.unfocus();
if (renameHandler.isActive())
renameHandler.cancel();
if (autocompleteManager.isVisible())
autocompleteManager.dismiss();
}
// ==================== KEYBOARD INPUT HANDLING ====================
/**
* Handles keyboard input for the text area, delegating to specialized handlers
* for different types of input: navigation, deletion, shortcuts, and character input.
*/
@Override
public boolean textboxKeyTyped(char c, int i) {
if (KEYS_OVERLAY.keyTyped(c, i))
return true;
// Handle rename refactor input first if active
if (renameHandler.isActive() && renameHandler.keyTyped(c, i))
return true;
// Handle Go To Line dialog input first if it has focus
if (goToLineDialog.isVisible() &&goToLineDialog.keyTyped(c, i))
return true;
// Handle search bar input first if it has focus
if (searchBar.isVisible() && searchBar.keyTyped(c, i))
return true;
// Handle autocomplete navigation keys first when visible
if (autocompleteManager.isVisible()) {
if (autocompleteManager.keyPressed(i)) {
return true;
}
}
// Dismiss pinned/resized tooltip on Escape
if (i == Keyboard.KEY_ESCAPE && hoverState.isTooltipVisible()
&& (hoverState.hasOverriddenPosition() || hoverState.hasOverriddenSize())) {
hoverState.clearHover();
return true;
}
// Ignore if any global keys bound to this code are currently pressed
if (KEYS.hasMatchingKeyPressed(i))
return false;
if (!active)
return false;
if (this.isKeyComboCtrlA(i)) {
selection.selectAll(text.length());
return true;
}
if (!isEnabled()) return false;
if (handleNavigationKeys(i)) return true;
if (handleInsertionKeys(i)) return true;
if (handleDeletionKeys(i)) return true;
if (handleCharacterInput(c)) return true;
return true;
}
/**
* Handles cursor navigation keys (arrows) with support for word-jumping (Ctrl)
* and selection (Shift). Updates cursor position and scrolls viewport if needed.
*/
private boolean handleNavigationKeys(int i) {
// LEFT ARROW: move cursor left; with Ctrl -> jump by word
if (i == Keyboard.KEY_LEFT) {
int j = 1; // default: move one character
if (isCtrlKeyDown()) {
int pos = selection.getCursorPosition();
int g = pos;
java.util.function.IntPredicate isWordChar = ch -> Character.isLetterOrDigit(ch) || ch == '_';
if (pos > 0) {
char left = text.charAt(pos - 1);
if (Character.isWhitespace(left)) {
while (g - 1 >= 0 && Character.isWhitespace(text.charAt(g - 1))) g--;
} else if (isWordChar.test(left)) {
while (g - 1 >= 0 && isWordChar.test(text.charAt(g - 1))) g--;
} else {
while (g - 1 >= 0 && !Character.isWhitespace(text.charAt(g - 1)) && !isWordChar.test(text.charAt(g - 1))) g--;
}
}
j = Math.max(1, pos - g);
}
int newPos = Math.max(selection.getCursorPosition() - j, 0);
// If Shift is held, extend selection; otherwise place caret.
setCursor(newPos, GuiScreen.isShiftKeyDown());
// Notify autocomplete of cursor movement
if (autocompleteManager.isVisible()) {
autocompleteManager.onCursorMove(text, newPos);
}
return true;
}
// RIGHT ARROW: move cursor right; with Ctrl -> jump to next word start
if (i == Keyboard.KEY_RIGHT) {
int j = 1; // default: move one character
if (isCtrlKeyDown()) {
int pos = selection.getCursorPosition();
int end = pos;
java.util.function.IntPredicate isWordChar = ch -> Character.isLetterOrDigit(ch) || ch == '_';
if (pos < text.length()) {
char first = text.charAt(pos);
if (Character.isWhitespace(first)) {
while (end < text.length() && Character.isWhitespace(text.charAt(end))) end++;
} else if (isWordChar.test(first)) {
while (end < text.length() && isWordChar.test(text.charAt(end))) end++;
} else {
while (end < text.length() && !Character.isWhitespace(text.charAt(end)) && !isWordChar.test(text.charAt(end))) end++;
}
}
j = Math.max(1, end - pos);
}
int newPos = Math.min(selection.getCursorPosition() + j, text.length());
setCursor(newPos, GuiScreen.isShiftKeyDown());
// Notify autocomplete of cursor movement
if (autocompleteManager.isVisible()) {
autocompleteManager.onCursorMove(text, newPos);
}
return true;
}
// UP/DOWN: logical cursor movement across lines while preserving
// column where possible. After moving, ensure the caret remains visible
// by adjusting the scroll if necessary.
if (i == Keyboard.KEY_UP) {
setCursor(cursorUp(), GuiScreen.isShiftKeyDown());
scrollToCursor();
return true;
}
if (i == Keyboard.KEY_DOWN) {
setCursor(cursorDown(), GuiScreen.isShiftKeyDown());
scrollToCursor();
return true;
}
return false; // not a navigation key
}
/**
* Handles insertion-related keys such as Tab indentation and Enter behavior.
*/
private boolean handleInsertionKeys(int i) {
// TAB: indent or unindent depending on Shift
if (i == Keyboard.KEY_TAB) {
boolean shift = isShiftKeyDown();
if (shift) {
handleShiftTab();
} else {
handleTab();
}
scrollToCursor();
return true;
}
// RETURN/ENTER: special handling for /** javadoc stub and opening brace '{'
if (i == Keyboard.KEY_RETURN) {
int cursorPos = selection.getCursorPosition();
// JSDoc / comment Enter behavior summary:
// - If the caret is at (or after spaces/tabs following) "/**", Enter generates a JSDoc stub.
// - If an inline closer exists ("/** */"), expand to a multi-line block without duplicating "*/".
// - If already inside an existing JSDoc with "*" lines, insert a new " * " line (IDE-style).
// - If the caret is on a "*" line inside an unclosed JSDoc block, Enter inserts a new " * " line.
// - Multi-line selections are not special-cased (avoid surprising mass edits).
if (selection.hasSelection() && text != null && selection.getSelectedText(text).indexOf('\n') != -1) {
addText(Character.toString('\n') + getAutoIndentForEnter());
scrollToCursor();
return true;
}
// Check for /** javadoc stub auto-generation
String before = getSelectionBeforeText();
int beforeTrimEnd = before.length();
while (beforeTrimEnd > 0) {
char ch = before.charAt(beforeTrimEnd - 1);
if (ch == ' ' || ch == '\t') {
beforeTrimEnd--;
} else {
break;
}
}
if (before.substring(0, beforeTrimEnd).endsWith("/**")) {
// Derive indentation from the logical source line (not soft-wrapped LineData).
String indent = getLogicalLineIndentAt(Math.max(0, Math.min(cursorPos, text != null ? text.length() : 0)));
// Look ahead for a function declaration to generate smart JSDoc
String after = getSelectionAfterText();
String afterTrimmed = after;
int wsLen = 0;
while (wsLen < afterTrimmed.length() && (afterTrimmed.charAt(wsLen) == ' ' || afterTrimmed.charAt(wsLen) == '\t')) {
wsLen++;
}
if (wsLen < afterTrimmed.length() && afterTrimmed.charAt(wsLen) == '\n') {
int idx = wsLen + 1;
while (idx < afterTrimmed.length() && (afterTrimmed.charAt(idx) == ' ' || afterTrimmed.charAt(idx) == '\t')) {
idx++;
}
if (afterTrimmed.startsWith("*/", idx)) {
// Inline "/** */" -> expand to multi-line and keep the existing closer.
String restAfterCloser = afterTrimmed.substring(Math.min(idx + 2, afterTrimmed.length()));
String expanded = "\n" + indent + " * " + "\n" + indent + " */" + restAfterCloser;
setText(before + expanded, true);
int newCursorPos = before.length() + 1 + indent.length() + 3; // +1 for \n, +3 for " * "
selection.reset(newCursorPos);
scrollToCursor();
return true;
}
if (idx < afterTrimmed.length() && afterTrimmed.charAt(idx) == '*') {
int closerPos = afterTrimmed.indexOf("*/", idx);
if (closerPos != -1) {
// Already inside a multi-line JSDoc -> insert a new " * " line.
addText("\n" + indent + " * ");
int newCursorPos = before.length() + 1 + indent.length() + 3; // +1 for \n, +3 for " * "
selection.reset(newCursorPos);
scrollToCursor();
return true;
}
}
}
if (afterTrimmed.substring(wsLen).startsWith("*/")) {
// Inline "/** */" (same line) -> expand to multi-line and keep the existing closer.
String restAfterCloser = afterTrimmed.substring(wsLen + 2);
String expanded = "\n" + indent + " * " + "\n" + indent + " */" + restAfterCloser;
setText(before + expanded, true);
int newCursorPos = before.length() + 1 + indent.length() + 3; // +1 for \n, +3 for " * "
selection.reset(newCursorPos);
scrollToCursor();
return true;
}
// Look ahead for a function declaration to generate smart JSDoc
String javadocStub = generateJSDocStub(after, indent);
addText(javadocStub);
// Position cursor after " * " on the description line
int newCursorPos = before.length() + 1 + indent.length() + 3; // +1 for \n, +3 for " * "
selection.reset(newCursorPos);
scrollToCursor();
return true;
}
if (text != null && !text.isEmpty()) {
int openPos = text.lastIndexOf("/**", Math.max(0, cursorPos - 1));
if (openPos != -1) {
int closeBetween = text.indexOf("*/", openPos + 3);
if (closeBetween == -1 || closeBetween >= cursorPos) {
int lineStart = text.lastIndexOf('\n', Math.max(0, cursorPos - 1)) + 1;
int lineEnd = text.indexOf('\n', lineStart);
if (lineEnd == -1) {
lineEnd = text.length();
}
String line = text.substring(lineStart, lineEnd);
String trimmed = line.trim();
if (trimmed.startsWith("*") && !trimmed.startsWith("*/")) {
// Continuation line inside an unclosed JSDoc block.
int openLineStart = text.lastIndexOf('\n', Math.max(0, openPos - 1)) + 1;
int openLineEnd = text.indexOf('\n', openLineStart);
if (openLineEnd == -1) {
openLineEnd = text.length();
}
String openLine = text.substring(openLineStart, openLineEnd);
String baseIndent = openLine.substring(0, IndentHelper.getLineIndent(openLine));
addText("\n" + baseIndent + " * ");
int newCursorPos = cursorPos + 1 + baseIndent.length() + 3;
selection.reset(Math.min(newCursorPos, this.text.length()));
scrollToCursor();
return true;
}
}
}
}
int prevNonWs = cursorPos - 1;
while (prevNonWs >= 0 && prevNonWs < (text != null ? text.length() : 0) && Character.isWhitespace(
text.charAt(prevNonWs))) {
prevNonWs--;
}
if (prevNonWs >= 0 && cursorPos <= (text != null ? text.length() : 0) && text.charAt(prevNonWs) == '{') {
String indent = getLogicalLineIndentAt(prevNonWs);
String childIndent = indent + " ";
String after = getSelectionAfterText();
// Check if there's non-whitespace content on the current line after the cursor.
// If so, just add a newline + child indent without inserting a closing brace
// (the user is typing inside an existing expression).
int firstNewline = after.indexOf('\n');
String leadingSegment = firstNewline == -1 ? after : after.substring(0, firstNewline);
if (leadingSegment.trim().length() > 0) {
addText("\n" + childIndent);
scrollToCursor();
return true;
}
// Use BracketMatcher to determine if the opening '{' already has a
// properly matched closing '}' at the correct indent. Pure indent-based
// heuristics (scanning the 'after' substring) are unreliable for lambdas
// and nested callbacks; BracketMatcher uses stack-based pairing on the
// full text, then we verify the close brace sits at the same indent as
// the open brace's line — ensuring parent-scoped braces aren't mistaken
// for our own.
boolean hasMatchingClose = false;
try {
int openLineIdx = -1;
int bracePos = prevNonWs;
for (int li = 0; li < this.container.lines.size(); li++) {
LineData ld = this.container.lines.get(li);
if (bracePos >= ld.start && bracePos < ld.end) {
openLineIdx = li;
break;
}
}
if (openLineIdx >= 0) {
List<int[]> spans = BracketMatcher.computeBraceSpans(text, this.container.lines);
for (int[] span : spans) {
if (span[1] == openLineIdx) {
int closeLineIdx = span[2];
if (closeLineIdx < this.container.lines.size()) {
int closeIndent = IndentHelper.getLineIndent(
this.container.lines.get(closeLineIdx).text);
// <= handles multi-line declarations where '{' sits on a
// continuation line with higher indent than the closing '}'
if (closeIndent <= indent.length()) {
hasMatchingClose = true;
}
}
break;
}
}
}
} catch (Exception ex) {
hasMatchingClose = false;
}
if (hasMatchingClose) {
addText("\n" + childIndent);
scrollToCursor();
} else if (isLambdaOpening(prevNonWs, text)) {
int parenPos = findLambdaParentParen(prevNonWs, text);
if (parenPos >= 0) {
String parentIndent = getParentIndentForLambda(parenPos, container.lines);
String insert = "\n" + childIndent + "\n" + parentIndent + "});";
setText(before + insert + after);
selection.reset(before.length() + 1 + childIndent.length());
scrollToCursor();
} else {
String insert = "\n" + childIndent + "\n" + indent + "}";
setText(before + insert + after);
selection.reset(before.length() + 1 + childIndent.length());
scrollToCursor();
}
} else {
String insert = "\n" + childIndent + "\n" + indent + "}";
setText(before + insert + after);
int newCursor = before.length() + 1 + childIndent.length();
selection.reset(newCursor);
scrollToCursor();
}
} else {
addText(Character.toString('\n') + getAutoIndentForEnter());
scrollToCursor();
}
return true;
}
return false;
}
/**
* Generates a JSDoc stub for a function declaration.
* Looks at the text after the cursor to find function signature and generates
* appropriate @param and @return tags.
*
* @param after Text after the /** to search for function
* @param indent Current line indentation
* @return The JSDoc stub string including newlines
*/
private String generateJSDocStub(String after, String indent) {
StringBuilder stub = new StringBuilder();
stub.append("\n").append(indent).append(" * "); // Description line
// Try to find a function/method declaration or field declaration following the JSDoc
// Skip whitespace and newlines
String trimmed = after.replaceFirst("^[\\s\\n\\r]*", "");
// First, try to match function/method pattern
java.util.regex.Pattern funcPattern = java.util.regex.Pattern.compile(
// Group 1: Optional return type (Java) or 'function' keyword (JS)
"^(?:(\\w+(?:<[^>]+>)?|function)\\s+)?" +
// Group 2: Method/function name
"(\\w+)\\s*" +
// Group 3: Parameters inside parentheses
"\\(([^)]*)\\)"
);
java.util.regex.Matcher funcMatcher = funcPattern.matcher(trimmed);
if (funcMatcher.find()) {
// Handle function/method
String returnOrKeyword = funcMatcher.group(1);
String funcName = funcMatcher.group(2);
String paramsStr = funcMatcher.group(3) != null ? funcMatcher.group(3).trim() : "";
// Determine if this is JavaScript (function keyword or no return type)
boolean isJS = "function".equals(returnOrKeyword) || returnOrKeyword == null ||
(container != null && container.getDocument() != null && container.getDocument().isJavaScript());
// Parse parameters
if (!paramsStr.isEmpty()) {
String[] params = paramsStr.split(",");
for (String param : params) {
param = param.trim();
if (param.isEmpty()) continue;
String paramName;
String paramType = "any";
// Split on whitespace to get type and name
String[] parts = param.split("\\s+");
if (parts.length >= 2) {
// Java style: Type name
paramType = parts[parts.length - 2];
paramName = parts[parts.length - 1];
} else {
// JS style: just name
paramName = parts[0];
}
// Remove any trailing array brackets or varargs
paramName = paramName.replaceAll("[\\[\\]\\.]", "");
if (isJS) {
stub.append("\n").append(indent).append(" * @param {").append(paramType).append("} ").append(paramName);
} else {
stub.append("\n").append(indent).append(" * @param ").append(paramName);
}
}
}
// Add @return/@returns tag if applicable
if (returnOrKeyword != null && !"function".equals(returnOrKeyword) && !"void".equals(returnOrKeyword)) {
if (isJS) {
stub.append("\n").append(indent).append(" * @returns {").append(returnOrKeyword).append("}");
} else {
stub.append("\n").append(indent).append(" * @return");
}
} else if (isJS) {
// For JS functions without explicit return type, add empty @returns
stub.append("\n").append(indent).append(" * @returns {any}");
}
} else {
// Try to match field declaration: [modifiers] Type name or var/let/const name
java.util.regex.Pattern fieldPattern = java.util.regex.Pattern.compile(
"^(?:(?:public|private|protected|static|final|var|let|const)\\s+)*" + // Optional modifiers
"(\\w+(?:<[^>]+>)?)\\s+" + // Type (Group 1)
"(\\w+)" + // Field name (Group 2)
"(?:\\s*=|\\s*;)" // Followed by = or ;
);
java.util.regex.Matcher fieldMatcher = fieldPattern.matcher(trimmed);
if (fieldMatcher.find()) {
String fieldType = fieldMatcher.group(1);
// Check if this is JavaScript
boolean isJS = container != null && container.getDocument() != null && container.getDocument().isJavaScript();
if (isJS && fieldType != null) {
// For JS fields, add @type annotation
stub.append("\n").append(indent).append(" * @type {").append("any").append("}");
}
}
}
// Close the JSDoc block
stub.append("\n").append(indent).append(" */");
return stub.toString();
}
/**
* Handles deletion keys: Delete, Backspace, and Ctrl+Backspace.
* Includes smart backspace behavior for indentation-aware line merging,
* auto-pair deletion for brackets/quotes, and word-level deletion.
*/
private boolean handleDeletionKeys(int i) {
// DELETE key: remove the character under the cursor if no selection,
// otherwise remove the selected region's tail.
if (i == Keyboard.KEY_DELETE) {
String s = getSelectionAfterText();
if (!s.isEmpty() && !selection.hasSelection())
// remove single character after caret when nothing is selected
s = s.substring(1);
setText(getSelectionBeforeText() + s, true); // Use atomic undo
// Keep caret at same start selection
selection.reset(selection.getStartSelection());
return true;
}
// CTRL+BACKSPACE: delete to previous word or whitespace boundary.
if (isKeyComboCtrlBackspace(i)) {
String before = getSelectionBeforeText();
if (selection.getStartSelection() > 0 && !selection.hasSelection()) {
int pos = selection.getCursorPosition();
int g = pos;
// Helper: treat letters, digits and underscore as word characters
java.util.function.IntPredicate isWordChar = ch -> Character.isLetterOrDigit(ch) || ch == '_';
// If caret is after whitespace, delete contiguous whitespace first
char left = before.charAt(pos - 1);
if (Character.isWhitespace(left)) {
while (g - 1 >= 0 && Character.isWhitespace(before.charAt(g - 1)))
g--;
} else if (isWordChar.test(left)) {
// Delete contiguous word characters (letters/digits/_)
while (g - 1 >= 0 && isWordChar.test(before.charAt(g - 1)))
g--;
} else {
// Delete contiguous non-word, non-whitespace characters (punctuation)
while (g - 1 >= 0 && !Character.isWhitespace(before.charAt(g - 1)) && !isWordChar.test(before.charAt(g - 1)))
g--;
}
before = before.substring(0, g);
selection.setStartSelection(selection.getStartSelection() - (pos - g));
}
setText(before + getSelectionAfterText(), true); // Use atomic undo
selection.reset(selection.getStartSelection());
return true;
}
// BACKSPACE: complex handling with a few cases:
// 1) If a selection exists, delete it
// 2) If at start, nothing to do
// 3) Smart indent-aware merge with previous line when caret is at/near expected indent
// 4) Auto-pair deletion (remove both opening and closing chars when deleting an opener)
// 5) Fallback: delete a single char to the left
if (i == Keyboard.KEY_BACK) {
// 1) selection deletion
if (selection.hasSelection()) {
String s = getSelectionBeforeText();
setText(s + getSelectionAfterText(), true); // Use atomic undo
selection.reset(selection.getStartSelection());
scrollToCursor();
return true;
}
// 2) nothing to delete
if (selection.getStartSelection() <= 0) {
return true;
}
// If the current line is whitespace-only, remove it by deleting the
// preceding newline and the line's content, but preserve any trailing
// newline so the following line (e.g. a closing brace) stays in place.
LineData currCheck = selection.findCurrentLine(container.lines);
if (currCheck != null && currCheck.text.trim().length() == 0) {
int removeStart = ValueUtil.clamp(currCheck.start - 1, 0, text.length());
int contentEnd = Math.min(currCheck.start + currCheck.text.length(), text.length());
String before = text.substring(0, removeStart);
String after = text.substring(contentEnd);
setText(before + after, true);
int newCursor = Math.max(0, removeStart);
selection.reset(newCursor);
scrollToCursor();
return true;
}
// 3) indent-aware merge: find current line and compute expected indent
LineData curr = selection.findCurrentLine(container.lines);
if (curr != null && curr.start > 0) {
int col = selection.getCursorPosition() - curr.start;
int actualIndent = IndentHelper.getLineIndent(curr.text);
int expectedIndent = IndentHelper.getExpectedIndent(curr, container.lines);
// Trigger smart merge only when caret is at or before the expected indent.
if (col <= expectedIndent) {
boolean lineHasContent = curr.text.trim().length() > 0;
int newlinePos = curr.start - 1; // index of newline before this line
if (!lineHasContent) {
// Empty or whitespace-only line: remove it including its trailing newline
int removeEnd = text.indexOf('\n', curr.start);
if (removeEnd == -1) {
removeEnd = text.length();
} else {
removeEnd = removeEnd + 1; // include the newline in removal
}
String before = text.substring(0, curr.start);
String after = removeEnd <= text.length() ? text.substring(removeEnd) : "";
setText(before + after, true); // Use atomic undo
// Place caret at end of previous line
int newCursor = Math.max(0, curr.start - 1);
selection.reset(newCursor);
scrollToCursor();
return true;
} else {
// Merge current line content with the previous line preserving spacing
int contentStart = curr.start + actualIndent;
String before = newlinePos >= 0 ? text.substring(0, newlinePos) : "";
String content = contentStart <= text.length() ? text.substring(contentStart) : "";
// Decide whether a space is needed between concatenated fragments.
String spacer = "";
if (before.length() > 0 && content.length() > 0) {
char lastChar = before.charAt(before.length() - 1);
char firstChar = content.charAt(0);
// Avoid adding space when punctuation/brackets are adjacent
if (!Character.isWhitespace(lastChar) &&
lastChar != '{' && lastChar != '(' && lastChar != '[' &&
firstChar != '}' && firstChar != ')' && firstChar != ']' &&
firstChar != ';' && firstChar != ',' && firstChar != '.' &&
firstChar != '\n') {
spacer = " ";
}
}
setText(before + spacer + content, true); // Use atomic undo
int newCursor = before.length() + spacer.length();
selection.reset(newCursor);
scrollToCursor();
return true;
}
}
}
// 4) Auto-pair deletion: when deleting an opener and a matching closer follows,
// remove both so the pair is cleaned up in one backspace.
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, selection.getStartSelection() - 1);
String after = selection.getStartSelection() + 1 < text.length() ? text.substring(
selection.getStartSelection() + 1) : "";
setText(before + after, true); // Use atomic undo
selection.setStartSelection(selection.getStartSelection() - 1);
selection.reset(selection.getStartSelection());
scrollToCursor();
return true;
}
}
// 5) Normal single-character backspace
String s = getSelectionBeforeText();
s = s.substring(0, s.length() - 1);
selection.setStartSelection(selection.getStartSelection() - 1);
setText(s + getSelectionAfterText(), true); // Use atomic undo
selection.reset(selection.getStartSelection());
scrollToCursor();
// Notify autocomplete of deletion
autocompleteManager.onDeleteKey(text, selection.getCursorPosition());
return true;
}
return false;
}
/**
* Handles keyboard shortcuts: clipboard operations (cut/copy/paste),
* undo/redo, tab indentation, code formatting, enter with brace handling,
* comment toggling, and line duplication.
*/
private boolean handleShortcutKeys(int i) {
// CTRL+X: Cut
if (this.isKeyComboCtrlX(i)) {
if (selection.hasSelection()) {
// Copy selected text into clipboard, then remove the selection
NoppesStringUtils.setClipboardContents(selection.getSelectedText(text));
String s = getSelectionBeforeText();
setText(s + getSelectionAfterText());
selection.reset(s.length());
scrollToCursor();
}
return true;
}
// CTRL+C: Copy
if (this.isKeyComboCtrlC(i)) {
if (selection.hasSelection()) {
NoppesStringUtils.setClipboardContents(selection.getSelectedText(text));
}
return true;
}
// CTRL+V: Paste (insert clipboard contents at caret)
if (this.isKeyComboCtrlV(i)) {
addText(NoppesStringUtils.getClipboardContents());
scrollToCursor();
return true;
}
// UNDO (Ctrl+Z): restore last entry from undoList and push current state to redoList
if (i == Keyboard.KEY_Z && isCtrlKeyDown()) {
if (undoList.isEmpty())
return false; // nothing to undo
undoing = true;
redoList.add(new UndoData(this.text, selection.getCursorPosition()));
UndoData data = undoList.remove(undoList.size() - 1);
setText(data.text);
selection.reset(data.cursorPosition);
undoing = false;
scrollToCursor();
return true;
}
// REDO (Ctrl+Y): opposite of undo
if (i == Keyboard.KEY_Y && isCtrlKeyDown()) {
if (redoList.isEmpty())
return false;
undoing = true;
undoList.add(new UndoData(this.text, selection.getCursorPosition()));
UndoData data = redoList.remove(redoList.size() - 1);
setText(data.text);
selection.reset(data.cursorPosition);
undoing = false;
scrollToCursor();
return true;
}
// CTRL+F: format the text according to IndentHelper rules
if (i == Keyboard.KEY_F && isCtrlKeyDown()) {
formatText();
return true;
}
// CTRL+/ : toggle comment for selection or current line
if (i == Keyboard.KEY_SLASH && isCtrlKeyDown()) {
if (selection.hasSelection()) {
toggleCommentSelection();
} else {
toggleCommentLineAtCursor();
}
return true;
}
// CTRL+D : duplicate selection or current line
if (i == Keyboard.KEY_D && isCtrlKeyDown()) {
if (selection.hasSelection()) {
// Multi-line selection duplication: find first and last covered lines,
// then insert the whole block after the last line without adding extra newline.
LineData firstLine = null, lastLine = null;
for (LineData line : container.lines) {
if (line.end > selection.getStartSelection() && line.start < selection.getEndSelection()) {
if (firstLine == null) firstLine = line;
lastLine = line;
}
}
if (firstLine != null && lastLine != null) {
String selectedText = text.substring(firstLine.start, lastLine.end);
String insertText = selectedText;
int savedStart = selection.getStartSelection();
int savedEnd = selection.getEndSelection();
int insertAt = lastLine.end;
setText(text.substring(0, insertAt) + insertText + text.substring(insertAt));
// Restore prior selection / cursor positions
selection.setStartSelection(savedStart);
selection.setEndSelection(savedEnd);
selection.setCursorPositionDirect(savedEnd);
return true;
}
} else {
// Duplicate current line when nothing is selected
for (LineData line : container.lines) {
if (selection.getCursorPosition() >= line.start && selection.getCursorPosition() <= line.end) {
int lineStart = line.start, lineEnd = line.end;
String lineText = text.substring(lineStart, lineEnd);
// If the line already ends with a newline, reuse it; otherwise
// prefix a newline so duplicate appears after current line.
String insertText;
if (lineText.endsWith("\n")) {
insertText = lineText;
} else {
insertText = "\n" + lineText;
}
int insertionPoint = lineEnd;
setText(text.substring(0, insertionPoint) + insertText + text.substring(insertionPoint));
int newCursor = insertionPoint + insertText.length() - (insertText.endsWith("\n") ? 1 : 0);
selection.reset(Math.max(0, Math.min(newCursor, this.text.length())));
return true;
}
}
}
return true;
}
return false;
}
/**
* Handles printable character input with auto-pairing for quotes and brackets,
* and smart skipping over existing closing characters.
*/
private boolean handleCharacterInput(char c) {
if (ChatAllowedCharacters.isAllowedCharacter(c)) {
String before = getSelectionBeforeText();
String after = getSelectionAfterText();
int cursorPos = selection.getCursorPosition();
// Diamond/generic angle bracket handling
if (c == '<') {
if (!container.getDocument().isExcludedInclusive(cursorPos) && isGenericContext(before)) {
if (hasUnmatchedCloserAhead(after, '<', '>')) {
// There's already an unmatched '>' ahead — just insert '<'
setText(before + "<" + after, true);
selection.reset(before.length() + 1);
} else {
setText(before + "<>" + after, true);
selection.reset(before.length() + 1);
}
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
}
if (c == '>') {
if (after.length() > 0 && after.charAt(0) == '>') {
if (shouldSkipAngleBracketClose(before)) {
selection.reset(before.length() + 1);
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
}
}
if ((c == ')' || c == ']' || c == '}') && after.length() > 0 && after.charAt(0) == c) {
selection.reset(before.length() + 1);
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
if (c == '"' || c == '\'') {
if (after.length() > 0 && after.charAt(0) == c) {
// Move caret forward by one (skip over existing closer)
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
// Auto-pair only when it looks like we're starting a new string. If the current
// logical line already has an unclosed quote of this type, treat the typed quote
// as a closer to avoid producing triple quotes.
boolean allowAutoPair = !container.getDocument().isExcludedInclusive(cursorPos)
&& !isLikelyClosingQuote(before, c);
if (allowAutoPair) {
if (c == '"') {
setText(before + "\"\"" + after, true);
} else {
setText(before + "''" + after, true);
}
selection.reset(before.length() + 1);
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
addText(Character.toString(c));
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
// Auto-pair insertion: when opening a quote/brace/bracket is typed,
// insert a matching closer and place caret between the pair.
// But only if the current position is not excluded (e.g., inside a comment or string)
if (!container.getDocument().isExcludedInclusive(cursorPos)) {
if (c == '[') {
if (hasUnmatchedCloserAhead(after, '[', ']')) {
setText(before + c + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
setText(before + "[]" + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
if (c == '(') {
if (hasUnmatchedCloserAhead(after, '(', ')')) {
setText(before + c + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
setText(before + "()" + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
if (c == '{') {
if (hasUnmatchedCloserAhead(after, '{', '}')) {
setText(before + c + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
}
}
// Default insertion for printable characters: insert at caret (replacing selection)
addText(Character.toString(c));
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
return false;
}
private boolean isLikelyClosingQuote(String before, char quote) {
if (before == null || before.isEmpty()) {
return false;
}
int lineStart = before.lastIndexOf('\n');
int start = lineStart == -1 ? 0 : lineStart + 1;
int count = 0;
for (int i = start; i < before.length(); i++) {
if (before.charAt(i) == quote && !isEscaped(before, i)) {
count++;
}
}
return (count % 2) == 1;
}
private boolean isEscaped(String s, int index) {
int backslashes = 0;
for (int i = index - 1; i >= 0 && s.charAt(i) == '\\'; i--) {
backslashes++;
}
return (backslashes % 2) == 1;
}
private boolean isGenericContext(String before) {
if (before == null || before.isEmpty()) return false;
int i = before.length() - 1;
while (i >= 0 && (before.charAt(i) == ' ' || before.charAt(i) == '\t')) {
i--;
}
if (i < 0) return false;
char last = before.charAt(i);
if (last == '>' || last == ')' || last == ']') return true;
if (last == ',') return true;
if (Character.isJavaIdentifierPart(last)) {
int end = i + 1;
while (i >= 0 && Character.isJavaIdentifierPart(before.charAt(i))) {
i--;
}
String word = before.substring(i + 1, end);
if (isComparisonKeyword(word)) return false;
if (isNumericLiteral(word)) return false;
return true;
}
return false;
}
private boolean isComparisonKeyword(String word) {
return "if".equals(word) || "while".equals(word) || "for".equals(word)
|| "return".equals(word) || "else".equals(word) || "case".equals(word)
|| "assert".equals(word) || "throw".equals(word) || "true".equals(word)
|| "false".equals(word) || "null".equals(word);
}
private boolean isNumericLiteral(String word) {
if (word.isEmpty()) return false;
char first = word.charAt(0);
return first >= '0' && first <= '9';
}
private boolean shouldSkipAngleBracketClose(String before) {
if (before == null || before.isEmpty()) return false;
int depth = 0;
boolean inLineComment = false;
boolean inBlockComment = false;
boolean inString = false;
boolean escape = false;
char stringDelimiter = 0;
for (int i = 0; i < before.length(); i++) {
char c = before.charAt(i);
char next = i + 1 < before.length() ? before.charAt(i + 1) : 0;
if (inString) {
if (escape) { escape = false; }
else if (c == '\\') { escape = true; }
else if (c == stringDelimiter || c == '\n') { inString = false; }
continue;
}
if (inBlockComment) {
if (c == '*' && next == '/') { inBlockComment = false; i++; }
continue;
}
if (inLineComment) {
if (c == '\n') { inLineComment = false; }
continue;
}
if (c == '/' && next == '/') { inLineComment = true; i++; continue; }
if (c == '/' && next == '*') { inBlockComment = true; i++; continue; }
if (c == '"' || c == '\'') { inString = true; stringDelimiter = c; escape = false; continue; }
if (c == '<') depth++;
else if (c == '>') depth--;
}
return depth > 0;
}
private boolean hasUnmatchedCloserAhead(String after, char opener, char closer) {
int balance = 0;
int len = after.length();
for (int idx = 0; idx < len; idx++) {
char ch = after.charAt(idx);
if (ch == '\n') break;
// Bail out at string / comment boundaries to avoid false matches
if (ch == '"' || ch == '\'') return false;
if (ch == '/' && idx + 1 < len) {
char next = after.charAt(idx + 1);
if (next == '/' || next == '*') return false;
}
if (ch == opener) {
balance++;
} else if (ch == closer) {
if (balance == 0) return true;
balance--;
}
}
return false;
}
private String getLogicalLineIndentAt(int pos) {
if (text == null || text.isEmpty()) {
return "";
}
int safePos = Math.max(0, Math.min(pos, text.length()));
int lineStart = text.lastIndexOf('\n', Math.max(0, safePos - 1)) + 1;
int p = lineStart;
while (p < text.length()) {
char ch = text.charAt(p);
if (ch == ' ' || ch == '\t') {
p++;
} else {
break;
}
}
return text.substring(lineStart, p);
}
private boolean isLambdaOpening(int bracePos, String fullText) {
if (fullText == null || bracePos <= 1) {
return false;
}
int scan = bracePos - 1;
while (scan >= 0 && (fullText.charAt(scan) == ' ' || fullText.charAt(scan) == '\t')) {
scan--;
}
if (scan < 1) {
return false;
}
char c1 = fullText.charAt(scan - 1);
char c2 = fullText.charAt(scan);
return (c1 == '-' && c2 == '>') || (c1 == '=' && c2 == '>');
}
// Reverse-scans from bracePos to find the unmatched '(' that this lambda is an
// argument of. Properly handles nested parens/braces, strings, and comments.
// Returns the position of the enclosing '(' or -1 if not found.
private int findLambdaParentParen(int bracePos, String fullText) {
if (fullText == null || bracePos <= 0) {
return -1;
}
int parenDepth = 0;
int braceDepth = 0;
boolean inString = false;
boolean inBlockComment = false;
char stringDelim = 0;
for (int i = bracePos - 1; i >= 0; i--) {
char c = fullText.charAt(i);
char prev = i > 0 ? fullText.charAt(i - 1) : 0;
// Reverse block-comment: entered at '*/', exits at '/*'
if (inBlockComment) {
if (c == '*' && prev == '/') {
inBlockComment = false;
i--;
}
continue;
}
if (c == '/' && prev == '*') {
inBlockComment = true;
i--;
continue;
}
if (inString) {
if (c == stringDelim) {
int bs = 0;
for (int j = i - 1; j >= 0 && fullText.charAt(j) == '\\'; j--) {
bs++;
}
if (bs % 2 == 0) {
inString = false;
}
}
continue;
}
if (c == '"' || c == '\'' || c == '`') {
inString = true;
stringDelim = c;
continue;
}
// For line comments: when scanning backwards and hitting a newline,
// check if the preceding line contains a // comment. If so, skip
// backwards past it to avoid counting brackets inside comments.
if (c == '\n') {
int lineStart = fullText.lastIndexOf('\n', i - 1) + 1;
int commentIdx = findLineCommentStart(fullText, lineStart, i);
if (commentIdx >= 0) {
i = commentIdx;
}
continue;
}
if (c == '}') {
braceDepth++;
} else if (c == '{') {
if (braceDepth > 0) {
braceDepth--;
}
if (braceDepth == 0) {
return -1;
}
} else if (c == ')') {
parenDepth++;
} else if (c == '(') {
if (parenDepth > 0) {
parenDepth--;
} else {
return i;
}
}
}
return -1;
}
private int findLineCommentStart(String text, int lineStart, int lineEnd) {
boolean inStr = false;
char strDelim = 0;
for (int i = lineStart; i < lineEnd; i++) {
char c = text.charAt(i);
if (inStr) {
if (c == strDelim && (i == 0 || text.charAt(i - 1) != '\\')) {
inStr = false;
}
continue;
}
if (c == '"' || c == '\'' || c == '`') {
inStr = true;
strDelim = c;
continue;
}
if (c == '/' && i + 1 < lineEnd && text.charAt(i + 1) == '/') {
return i;
}
}
return -1;
}
private String getParentIndentForLambda(int parenPos, List<LineData> lines) {
return getLogicalLineIndentAt(parenPos);
}
private boolean isShiftKeyDown() {
return Keyboard.isKeyDown(42) || Keyboard.isKeyDown(54);
}
// ==================== COMMENT TOGGLING ====================
// Uses CommentHandler helper for comment operations
private void toggleCommentSelection() {
CommentHandler.SelectionToggleResult result = CommentHandler.toggleCommentSelection(
text, container.lines, selection.getStartSelection(), selection.getEndSelection());
setText(result.newText);
selection.setStartSelection(result.newStartSelection);
selection.setEndSelection(result.newEndSelection);
}
private void toggleCommentLineAtCursor() {
CommentHandler.SingleLineToggleResult result = CommentHandler.toggleCommentAtCursor(
text, container.lines, selection.getCursorPosition());
setText(result.newText);
setCursor(result.newCursorPosition, false);
}
public boolean closeOnEsc(){
return !KEYS_OVERLAY.isVisible() && !searchBar.isVisible() && !goToLineDialog.isVisible()
&& !renameHandler.isActive() && !autocompleteManager.isVisible()
&& !(hoverState.isTooltipVisible() && (hoverState.hasOverriddenPosition() || hoverState.hasOverriddenSize()));
}
// ==================== KEYBOARD MODIFIERS ====================
private boolean isAltKeyDown() {
return Keyboard.isKeyDown(56) || Keyboard.isKeyDown(184);
}
private boolean isKeyComboCtrlX(int keyID) {
return keyID == 45 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
private boolean isKeyComboCtrlBackspace(int keyID) {
return keyID == Keyboard.KEY_BACK && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
private boolean isKeyComboCtrlV(int keyID) {
return keyID == 47 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
private boolean isKeyComboCtrlC(int keyID) {
return keyID == 46 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
private boolean isKeyComboCtrlA(int keyID) {
return keyID == 30 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
private String getIndentCurrentLine() {
for (LineData data : this.container.lines) {
if (selection.getCursorPosition() > data.start && selection.getCursorPosition() <= data.end) {
int i;
for (i = 0; i < data.text.length() && data.text.charAt(i) == ' '; ++i) {
}
return data.text.substring(0, i);
}
}
return "";
}
private String getAutoIndentForEnter() {
LineData currentLine = selection.findCurrentLine(container.lines);
if (currentLine == null) {
return "";
}
return IndentHelper.getAutoIndentForEnter(currentLine.text, selection.getCursorPosition() - currentLine.start);
}
// ==================== TEXT FORMATTING ====================
private int getTabSize() {
return IndentHelper.TAB_SIZE;
}
private String repeatSpace(int count) {
return IndentHelper.spaces(count);
}
private void formatText() {
int viewportPixels = this.width - LINE_NUMBER_GUTTER_WIDTH - 10;
int avgCharWidth = Math.max(1, ClientProxy.Font.width("abcdefghijklmnopqrstuvwxyz") / 26);
int viewportChars = (int) (viewportPixels * 0.8f / avgCharWidth);
int maxLineLength = Math.max(60, Math.min(viewportChars, 120));
IndentHelper.FormatResult result = IndentHelper.formatText(text, selection.getCursorPosition(), maxLineLength);
setText(result.text);
selection.reset(Math.max(0, Math.min(result.cursorPosition, this.text.length())));
}
// ==================== TAB HANDLING ====================
private void handleTab() {
LineData currentLine = selection.findCurrentLine(container.lines);
if (currentLine == null) {
addText(" ");
return;
}
int tab = getTabSize();
int indentLen = IndentHelper.getLineIndent(currentLine.text);
int textStartPos = currentLine.start + indentLen;
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 (selection.getCursorPosition() == textStartPos) {
targetIndent = ((indentLen / tab) + 1) * tab;
} else {
int remainder = indentLen % tab;
if (remainder == 0) {
targetIndent = indentLen + tab; // already aligned -> next level
} else {
int down = indentLen - remainder;
int up = indentLen + (tab - remainder);
int distDown = remainder;
int distUp = tab - remainder;
if (distUp < distDown) targetIndent = up;
else if (distUp > distDown) targetIndent = down;
else targetIndent = up; // tie -> forward
}
}
if (targetIndent < 0) targetIndent = 0;
String newIndent = repeatSpace(targetIndent);
String rest = currentLine.text.substring(indentLen);
String before = text.substring(0, currentLine.start);
int contentEnd = Math.min(currentLine.start + currentLine.text.length(), text.length());
int sepEnd = Math.min(currentLine.end, text.length());
String sep = contentEnd < sepEnd ? text.substring(contentEnd, sepEnd) : "";
String after = text.substring(sepEnd);
setText(before + newIndent + rest + sep + after);
int newCursor = currentLine.start + targetIndent;
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 = selection.getCursorPosition() - currentLine.start;
int targetColumn = ((column / tab) + 1) * tab;
int toInsert = Math.max(0, targetColumn - column);
if (toInsert > 0) {
String spaces = repeatSpace(toInsert);
addText(spaces);
}
}
}
private void handleShiftTab() {
LineData currentLine = selection.findCurrentLine(container.lines);
if (currentLine == null)
return;
int tab = getTabSize();
int indentLen = IndentHelper.getLineIndent(currentLine.text);
if (indentLen == 0)
return;
int targetIndent = Math.max(0, ((indentLen - 1) / tab) * tab);
int removed = indentLen - targetIndent;
String newIndent = repeatSpace(targetIndent);
String rest = currentLine.text.substring(indentLen);
String before = text.substring(0, currentLine.start);
int contentEnd = Math.min(currentLine.start + currentLine.text.length(), text.length());
int sepEnd = Math.min(currentLine.end, text.length());
String sep = contentEnd < sepEnd ? text.substring(contentEnd, sepEnd) : "";
String after = text.substring(sepEnd);
setText(before + newIndent + rest + sep + after);
int newCursor = Math.max(currentLine.start, selection.getCursorPosition() - removed);
selection.reset(Math.min(newCursor, this.text.length()));
}
// ==================== CURSOR MANAGEMENT ====================
private void setCursor(int i, boolean select) {
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(), true); // Use atomic undo for typing
selection.afterTextInsert(insertPos + s.length());
}
private int cursorUp() {
return CursorNavigation.cursorUp(selection.getCursorPosition(), container.lines, text);
}
private int cursorDown() {
return CursorNavigation.cursorDown(selection.getCursorPosition(), container.lines, text);
}
public String getSelectionBeforeText() {
return selection.getTextBefore(text);
}
public String getSelectionAfterText() {
return selection.getTextAfter(text);
}
// ==================== MOUSE HANDLING ====================
public void mouseClicked(int xMouse, int yMouse, int mouseButton) {
// HIGHEST PRIORITY: tooltip panel absorbs all clicks — no click-through to panels beneath it
if (hoverState.isTooltipVisible() && hoverState.isMouseOverTooltipPanel(xMouse, yMouse)) {
if (mouseButton == 0) {
if (hoverState.isMouseOverResizeHandle(xMouse, yMouse)) {
hoverState.startTooltipResize(xMouse, yMouse);
} else if (hoverState.isMouseOverScrollbarThumb(xMouse, yMouse)) {
hoverState.startScrollbarDrag(yMouse);
} else {
hoverState.startTooltipDrag(xMouse, yMouse);
}
}
return; // consume — nothing below receives this click
}
// M1 outside a dragged/resized tooltip — dismiss it and fall through to editor
if (mouseButton == 0 && (hoverState.hasOverriddenPosition() || hoverState.hasOverriddenSize())
&& !hoverState.isMouseOverTooltipPanel(xMouse, yMouse)) {
hoverState.clearHover();
}
// Check autocomplete menu clicks first
if (autocompleteManager.isVisible() && autocompleteManager.mouseClicked(xMouse, yMouse, mouseButton)) {
return;
}
// Dismiss autocomplete if clicking elsewhere
if (autocompleteManager.isVisible()) {
autocompleteManager.dismiss();
}
if (parent != null && parent.fullscreenButton.mouseClicked(xMouse, yMouse, mouseButton))
return;
// Check go to line dialog clicks first
if (goToLineDialog.isVisible() && goToLineDialog.mouseClicked(xMouse, yMouse, mouseButton)) {
return;
}
// Check search bar clicks first
if (searchBar.isVisible() && searchBar.mouseClicked(xMouse, yMouse, mouseButton)) {
return;
}
// If search bar is visible but click was outside it, unfocus the search bar
if (searchBar.isVisible()) {
searchBar.unfocus();
}
// Let the overlay consume clicks (it returns true when it handled the event)
if (KEYS_OVERLAY.mouseClicked(xMouse, yMouse, mouseButton))
return;
// Determine whether click occurred inside the text area bounds
this.active = xMouse >= this.x && xMouse < this.x + this.width && yMouse >= this.y && yMouse < this.y + this.height;
if (this.active) {
// Ctrl+Click: Go to definition
if (mouseButton == 0 && isCtrlKeyDown()) {
Object[] tokenInfo = getTokenAtScreenPosition(xMouse, yMouse);
if (tokenInfo != null) {
Token token = (Token) tokenInfo[0];
int targetOffset = -1;
// Check if token has method info with declaration
if (token.getMethodInfo() != null) {
MethodInfo methodInfo = token.getMethodInfo();
if (methodInfo.getNameOffset() >= 0) {
targetOffset = methodInfo.getNameOffset();
}
}
// Check if token has method call info with resolved declaration
else if (token.getMethodCallInfo() != null) {
MethodCallInfo callInfo = token.getMethodCallInfo();
MethodInfo resolvedMethod = callInfo.getResolvedMethod();
if (resolvedMethod != null && resolvedMethod.getNameOffset() >= 0) {
targetOffset = resolvedMethod.getNameOffset();
}
}
// Check if token has field info with declaration
else if (token.getFieldInfo() != null) {
FieldInfo fieldInfo = token.getFieldInfo();
if (fieldInfo.getDeclarationOffset() >= 0) {
targetOffset = fieldInfo.getDeclarationOffset();
}
}
// Check if token has field access info with resolved declaration
else if (token.getFieldAccessInfo() != null) {
FieldAccessInfo accessInfo = token.getFieldAccessInfo();
FieldInfo resolvedField = accessInfo.getResolvedField();
if (resolvedField != null && resolvedField.getDeclarationOffset() >= 0) {
targetOffset = resolvedField.getDeclarationOffset();
}
}
// Check if token is a script-defined type
else if (token.getTypeInfo() != null && token.getTypeInfo() instanceof ScriptTypeInfo) {
ScriptTypeInfo scriptType =
(ScriptTypeInfo) token.getTypeInfo();
if (scriptType.getDeclarationOffset() >= 0) {
targetOffset = scriptType.getDeclarationOffset();
}
}
// Jump to definition if found
if (targetOffset >= 0) {
selection.reset(targetOffset);
scrollToCursor();
this.clicked = false;
activeTextfield = this;
return; // Consume the event
}
}
}
// Compute logical click position in text
int clickPos = this.getSelectionPos(xMouse, yMouse);
// Check if rename refactoring is active and click is in the rename box
if (renameHandler.isActive() && renameHandler.handleClick(clickPos)) {
// Click was handled by rename handler - don't reset selection or do other click handling
this.clicked = false;
activeTextfield = this;
return;
}
// Normal click handling - reset selection/caret
selection.reset(clickPos);
selection.markActivity();
// Prepare click state (left button starts most interactions)
this.clicked = mouseButton == 0;
this.doubleClicked = false;
this.tripleClicked = false;
long time = System.currentTimeMillis();
// Prefer delegating scrollbar-start logic to ScrollState. If the click
// is on the scrollbar area and the scrollbar can be dragged, we start
// click-scrolling mode and cancel the normal text click-drag behavior.
if (this.clicked && getPaddedLineCount() * this.container.lineHeight > this.height && xMouse > this.x + this.width - 8) {
// We consumed the mouse-down as a scrollbar drag start
this.clicked = false;
scroll.startScrollbarDrag(yMouse,this.y,this.height, getPaddedLineCount());
} else {
// Handle double/triple click selection counting
if (time - this.lastClicked < 300L) {
this.clickCount++;
} else {
this.clickCount = 1;
}
if (this.clickCount == 2) {
// Double-click: select the word under the caret using the container's word regex
this.doubleClicked = true;
selection.selectWordAtCursor(this.text, this.container.regexWord);
// Prevent subsequent mouse-drag handling in the render loop from
// treating this double-click as a normal click-drag which would
// immediately reset and extend the selection. Clearing `clicked`
// keeps the double-click selection stable.
this.clicked = false;
} else if (this.clickCount >= 3) {
// Triple-click: select the entire logical line that contains the caret
this.tripleClicked = true;
selection.selectLineAtCursor(container.lines);
// Same as double-click: clear `clicked` to avoid accidental drag-extension
this.clicked = false;
this.clickCount = 0;
}
}
this.lastClicked = time;
activeTextfield = this;
// Click-to-pin handling: if enabled, clicking a token will pin/unpin its tooltip
if (mouseButton == 0 && hoverState.isClickToPinEnabled()) {
Object[] tokenInfo = getTokenAtScreenPosition(xMouse, yMouse);
if (tokenInfo != null) {
Token clickedToken = (Token) tokenInfo[0];
int tokenScreenX = (Integer) tokenInfo[1];
int tokenScreenY = (Integer) tokenInfo[2];
int tokenWidth = (Integer) tokenInfo[3];
// If already pinned on same token, unpin; otherwise pin this token
if (hoverState.isPinned() && hoverState.getHoveredToken() == clickedToken) {
hoverState.unpin();
} else {
hoverState.pinToken(clickedToken, tokenScreenX, tokenScreenY, tokenWidth);
}
} else {
// Clicked outside any token -> unpin any pinned tooltip
if (hoverState.isPinned()) {
hoverState.unpin();
}
}
}
}
}
// Called from GuiScreen.updateScreen()
public void updateCursorCounter() {
// Only process KeyPresets if search bar and go-to-line dialog don't have focus
// This prevents COPY, PASTE, UNDO, etc. from firing when typing in dialogs
KEYS.tick();
searchBar.updateCursor();
goToLineDialog.updateCursor();
renameHandler.updateCursor();
++this.cursorCounter;
}
// ==================== TEXT MANAGEMENT ====================
public void setText(String text) {
setText(text, false);
}
/**
* Set text with optional atomic undo support.
*
* @param text The new text content
* @param allowAtomic If true, allows grouping consecutive typing into single undo steps
*/
private void setText(String text, boolean allowAtomic) {
if (text == null) {
return;
}
text = text.replace("\r", "");
text = text.replace("\t", " ");
// preserve trailing newlines here — JavaTextContainer.init will
// ignore an extra empty final split when rendering lines.
if (this.text == null || !this.text.equals(text)) {
if (this.listener != null) {
this.listener.textUpdate(text);
}
// Atomic undo logic: group consecutive typing into word-based undo steps
if (!this.undoing && allowAtomic && !undoList.isEmpty()) {
long now = System.currentTimeMillis();
int cursorPos = selection.getCursorPosition();
// Check if we should merge with the previous undo entry
boolean shouldMerge =
(now - lastTypingTime < 2000) && // Within 2 seconds
Math.abs(cursorPos - lastTypingPos) <= 1; // Contiguous edit
// If typing a space or newline, break the undo group
if (shouldMerge && text.length() > 0 && cursorPos > 0 && cursorPos <= text.length()) {
char lastChar = text.charAt(cursorPos - 1);
if (Character.isWhitespace(lastChar)) {
shouldMerge = false;
}
}
if (shouldMerge) {
// Don't add a new undo entry - just update the text
// The existing undo entry will remain unchanged
} else {
// Add new undo entry
this.undoList.add(new GuiScriptTextArea.UndoData(this.text, selection.getCursorPosition()));
this.redoList.clear();
}
lastTypingTime = now;
lastTypingPos = cursorPos;
} else if (!this.undoing) {
// Normal undo entry (non-atomic)
this.undoList.add(new GuiScriptTextArea.UndoData(this.text, selection.getCursorPosition()));
this.redoList.clear();
// Reset atomic undo tracking
lastTypingTime = System.currentTimeMillis();
lastTypingPos = selection.getCursorPosition();
}
this.text = text;
//this.container = new TextContainer(text);
if (this.container == null)
this.container = new ScriptTextContainer(text);
this.container.init(text, this.width, this.height);
if (this.enableCodeHighlighting)
this.container.formatCodeText();
// Ensure scroll state stays in bounds after text change
int maxScroll = Math.max(0, getPaddedLineCount() - this.container.visibleLines);
scroll.clampToBounds(maxScroll);
selection.clamp(this.text.length());
// Consider text changes user activity to pause caret blinking briefly
selection.markActivity();
searchBar.updateMatches();
// Update autocomplete manager with current container
autocompleteManager.setContainer(this.container);
}
}
public String getText() {
return this.text;
}
public boolean isEnabled() {
return this.enabled && this.visible;
}
public boolean hasVerticalScrollbar() {
return this.container != null && this.container.visibleLines < getPaddedLineCount();
}
public void enableCodeHighlighting() {
this.enableCodeHighlighting = true;
this.container.formatCodeText();
}
/**
* Set the scripting language for syntax highlighting and type inference.
* @param language The language name (e.g., "ECMAScript", "Groovy")
*/
public void setLanguage(String language) {
if (this.container != null) {
this.container.setLanguage(language);
if (this.enableCodeHighlighting) {
this.container.formatCodeText();
}
}
}
/**
* Get the current scripting language.
*/
public String getLanguage() {
return this.container != null ? this.container.getLanguage() : "ECMAScript";
}
/**
* Set the script context (NPC, PLAYER, BLOCK, ITEM, etc.).
* This determines which hooks and event types are available for autocomplete.
*
* @param context The script context
*/
public void setScriptContext(ScriptContext context) {
if (this.container != null) {
this.container.setScriptContext(context);
}
}
/**
* Get the current script context.
*
* @return The script context (NPC, PLAYER, BLOCK, ITEM, etc.)
*/
public ScriptContext getScriptContext() {
return this.container != null ? this.container.getScriptContext() : ScriptContext.GLOBAL;
}
public ScriptTextContainer getContainer() {
return this.container;
}
/**
* Add implicit imports that should be resolved without explicit import statements.
* Used for JaninoScript default imports and hook parameter types.
*
* @param patterns Array of import patterns to add (wildcard packages like "noppes.npcs.api.*" or FQ class names)
*/
public void addImplicitImports(String... patterns) {
if (this.container != null) {
this.container.addImplicitImports(patterns);
}
}
public void setListener(ITextChangeListener listener) {
this.listener = listener;
}
private void clampSelectionBounds() {
selection.clamp(text != null ? text.length() : 0);
}
// ==================== AUTOCOMPLETE VISIBILITY ====================
/**
* Check if a click position is within the bounds of the autocomplete menu.
* Returns false if autocomplete is not visible.
*/
public boolean isPointOnAutocompleteMenu(int mouseX, int mouseY) {
if (autocompleteManager == null || !autocompleteManager.isVisible()) {
return false;
}
AutocompleteMenu menu = autocompleteManager.getMenu();
if (menu == null) {
return false;
}
int menuX = menu.getX();
int menuY = menu.getY();
int menuWidth = menu.getWidth();
int menuHeight = menu.getHeight();
return mouseX >= menuX && mouseX <= menuX + menuWidth &&
mouseY >= menuY && mouseY <= menuY + menuHeight;
}
// ==================== AUTO-IMPORT ====================
/**
* Add an import statement and sort all imports.
*/
private void addAndSortImport(String importPath) {
String currentText = this.text;
int savedCursorPos = selection.getCursorPosition();
// Find all existing imports
java.util.regex.Pattern importPattern = java.util.regex.Pattern.compile(
"(?m)^\\s*import\\s+(?:static\\s+)?([A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_*][A-Za-z0-9_]*)*)\\s*;\\s*$"
);
java.util.regex.Matcher matcher = importPattern.matcher(currentText);
java.util.List<ImportEntry> imports = new java.util.ArrayList<>();
int firstImportStart = -1;
int lastImportEnd = -1;
while (matcher.find()) {
String importStatement = matcher.group(0);
String importPathFound = matcher.group(1).replaceAll("\\s+", "");
if (firstImportStart == -1) {
firstImportStart = matcher.start();
}
lastImportEnd = matcher.end();
// Skip if this is the import we're trying to add
if (!importPathFound.equals(importPath)) {
imports.add(new ImportEntry(importPathFound, importStatement.trim()));
}
}
// Add the new import
imports.add(new ImportEntry(importPath, "import " + importPath + ";"));
// Sort imports
java.util.Collections.sort(imports, new java.util.Comparator<ImportEntry>() {
@Override
public int compare(ImportEntry a, ImportEntry b) {
// Sort order: java.*, javax.*, then others alphabetically
boolean aIsJava = a.path.startsWith("java.");
boolean aIsJavax = a.path.startsWith("javax.");
boolean bIsJava = b.path.startsWith("java.");
boolean bIsJavax = b.path.startsWith("javax.");
if (aIsJava && !bIsJava) return -1;
if (!aIsJava && bIsJava) return 1;
if (aIsJavax && !bIsJavax && !bIsJava) return -1;
if (!aIsJavax && bIsJavax && !aIsJava) return 1;
return a.path.compareTo(b.path);
}
});
// Build the new import block
StringBuilder importBlock = new StringBuilder();
String prevPackage = "";
for (ImportEntry entry : imports) {
// Add blank line between different top-level packages
String topPackage = entry.path.contains(".") ?
entry.path.substring(0, entry.path.indexOf('.')) : entry.path;
if (!prevPackage.isEmpty() && !topPackage.equals(prevPackage)) {
importBlock.append("\n");
}
importBlock.append(entry.statement).append("\n");
prevPackage = topPackage;
}
// Determine where to insert/replace imports
String newText;
int cursorAdjustment = 0;
if (firstImportStart != -1) {
// Replace existing import block
String before = currentText.substring(0, firstImportStart);
String after = currentText.substring(lastImportEnd);
newText = before + importBlock.toString() + after;
// Adjust cursor if it's after the import block
int newImportEnd = firstImportStart + importBlock.length();
if (savedCursorPos >= lastImportEnd) {
cursorAdjustment = newImportEnd - lastImportEnd;
}
} else {
// No existing imports - add at top after package statement (if any)
java.util.regex.Pattern packagePattern = java.util.regex.Pattern.compile(
"(?m)^\\s*package\\s+[A-Za-z_][A-Za-z0-9_.]*\\s*;\\s*$"
);
java.util.regex.Matcher pkgMatcher = packagePattern.matcher(currentText);
int insertPos = 0;
if (pkgMatcher.find()) {
insertPos = pkgMatcher.end();
// Add blank line after package
newText = currentText.substring(0, insertPos) + "\n" +
importBlock.toString() + "\n" + currentText.substring(insertPos);
cursorAdjustment = importBlock.length() + 2; // +2 for the newlines
} else {
// Insert at very beginning
newText = importBlock.toString() + "\n" + currentText;
cursorAdjustment = importBlock.length() + 1;
}
}
// Apply the changes
setText(newText);
selection.reset(savedCursorPos + cursorAdjustment);
scrollToCursor();
}
public void formatCodeText() {
if (this.enableCodeHighlighting && this.container != null) {
this.container.formatCodeText();
}
}
/**
* Helper class for tracking imports during sorting.
*/
private static class ImportEntry {
String path;
String statement;
ImportEntry(String path, String statement) {
this.path = path;
this.statement = statement;
}
}
// ==================== INNER CLASSES ====================
public static class UndoData {
public String text;
public int cursorPosition;
public UndoData(String text, int cursorPosition) {
this.text = text;
this.cursorPosition = cursorPosition;
}
}
}