Files
CustomNPC-Plus/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java
T

3170 lines
139 KiB
Java
Raw Normal View History

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;
2025-12-31 18:53:28 +02:00
import noppes.npcs.client.gui.util.script.interpreter.method.MethodInfo;
2025-12-28 20:33:39 +02:00
import noppes.npcs.client.gui.util.script.interpreter.token.Token;
import noppes.npcs.client.gui.util.script.interpreter.token.TokenType;
2025-12-31 19:19:06 +02:00
import noppes.npcs.client.gui.util.script.interpreter.hover.GutterIconRenderer;
2025-12-26 01:24:40 +02:00
import noppes.npcs.client.gui.util.script.interpreter.hover.HoverState;
import noppes.npcs.client.gui.util.script.interpreter.hover.TokenHoverRenderer;
2026-01-03 14:23:35 +02:00
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;
2025-12-22 03:39:22 +02:00
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;
2025-12-19 18:25:32 +02:00
import java.util.function.Supplier;
import java.util.regex.Matcher;
2025-12-16 18:33:55 +02:00
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();
2025-12-26 01:24:40 +02:00
private final HoverState hoverState = new HoverState();
2025-12-26 17:28:28 +02:00
/** 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;
2025-12-31 18:53:28 +02:00
// ==================== 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();
2025-12-19 22:38:26 +02:00
// ==================== GO TO LINE ====================
private final GoToLineDialog goToLineDialog = new GoToLineDialog();
2025-12-21 00:49:34 +02:00
// ==================== RENAME REFACTOR ====================
private final RenameRefactorHandler renameHandler = new RenameRefactorHandler();
2026-01-03 14:23:35 +02:00
// ==================== 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;
2025-12-22 13:43:58 +02:00
KEYS_OVERLAY.openOnClick = true;
initGui();
initializeKeyBindings();
2025-12-26 17:28:28 +02:00
// 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();
2025-12-20 19:51:19 +02:00
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
}
2025-12-20 19:50:28 +02:00
// 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);
2025-12-20 19:50:28 +02:00
}
});
2025-12-19 22:38:26 +02:00
// Initialize Go To Line dialog with callback
goToLineDialog.setCallback(new GoToLineDialog.GoToLineCallback() {
2025-12-19 22:38:26 +02:00
@Override
public int getLineCount() {
return container != null ? container.linesCount : 0;
}
2025-12-19 22:38:26 +02:00
@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;
}
2025-12-19 22:38:26 +02:00
@Override
public void goToLineColumn(int line, int column) {
if (container == null || container.lines == null)
return;
2025-12-19 22:38:26 +02:00
// Convert 1-indexed line to 0-indexed
int lineIdx = line - 1;
if (lineIdx < 0 || lineIdx >= container.lines.size())
return;
2025-12-19 22:38:26 +02:00
LineData ld = container.lines.get(lineIdx);
int lineLength = ld.end - ld.start;
2025-12-19 22:38:26 +02:00
// 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));
2025-12-19 22:38:26 +02:00
int position = ld.start + col;
2025-12-19 22:38:26 +02:00
// Set cursor position
selection.reset(position);
2025-12-19 22:38:26 +02:00
// Scroll to make the line visible
2025-12-20 19:51:19 +02:00
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);
2025-12-19 22:38:26 +02:00
}
@Override
public void unfocusMainEditor() {
// Save position but unfocus
active = false;
}
@Override
public void focusMainEditor() {
active = true;
selection.markActivity();
}
2025-12-19 22:38:26 +02:00
@Override
public void onDialogClose() {
active = true;
selection.markActivity();
}
});
2025-12-21 00:49:34 +02:00
// 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() {
2025-12-21 00:49:34 +02:00
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) {
2025-12-21 00:49:34 +02:00
// Push a specific text state to the undo list
if (!undoing) {
undoList.add(new UndoData(textState, cursor));
2025-12-21 00:49:34 +02:00
redoList.clear();
}
}
@Override
public int getViewportWidth() {
return width - LINE_NUMBER_GUTTER_WIDTH - 8; // Account for gutter and scrollbar
}
});
2026-01-03 14:23:35 +02:00
// 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
2026-01-04 23:33:56 +02:00
String fullText = GuiScriptTextArea.this.text;
2026-01-03 14:23:35 +02:00
int cursorPos = selection.getCursorPosition();
2026-01-04 23:33:56 +02:00
// 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);
2026-01-03 14:23:35 +02:00
setText(before + text + after);
2026-01-04 23:33:56 +02:00
selection.reset(start + text.length());
2026-01-03 14:23:35 +02:00
scrollToCursor();
}
@Override
public void replaceTextRange(String text, int startPosition, int endPosition) {
// Replace text from startPosition to endPosition
2026-01-04 23:33:56 +02:00
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);
2026-01-04 23:33:56 +02:00
selection.reset(start + text.length());
scrollToCursor();
}
@Override
public void addImport(String importPath) {
// Add import statement and sort all imports
addAndSortImport(importPath);
}
2026-01-03 14:23:35 +02:00
@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();
}
2026-01-03 14:23:35 +02:00
@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);
String lineText = ld.text;
int cursorOffset = selection.getCursorPosition() - ld.start;
cursorCol = ClientProxy.Font.width(lineText.substring(0, Math.min(cursorOffset, lineText.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;
}
2026-01-14 00:30:25 +02:00
// ==================== RENDERING ====================
2025-03-13 17:14:45 -04:00
public void drawTextBox(int xMouse, int yMouse) {
if (!visible)
return;
2025-10-15 06:28:43 -04:00
clampSelectionBounds();
2025-12-31 18:53:28 +02:00
// Dynamically calculate gutter width based on line count digits + icon space
if (container != null && container.linesCount > 0) {
int maxLineNum = container.linesCount;
String maxLineStr = String.valueOf(maxLineNum);
int digitWidth = ClientProxy.Font.width(maxLineStr);
2025-12-31 19:19:06 +02:00
LINE_NUMBER_GUTTER_WIDTH = digitWidth + 10 + GutterIconRenderer.ICON_GUTTER_WIDTH; // 10px padding + icon space
}
2025-12-18 14:05:39 +02:00
// Draw outer border around entire area
int offset = fullscreen() ? 2 : 1;
drawRect(x - offset, y - offset - searchBar.getTotalHeight(), x + width + offset, y + height + offset,
0xffa0a0a0);
int searchHeight = searchBar.getTotalHeight();
2025-12-18 14:05:39 +02:00
// Draw line number gutter background
2025-12-26 17:29:36 +02:00
int viewportX = x + LINE_NUMBER_GUTTER_WIDTH;
2025-12-18 14:05:39 +02:00
drawRect(x, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff000000);
// Draw text viewport background (starts after gutter)
drawRect(x + LINE_NUMBER_GUTTER_WIDTH, y, x + width, y + height, 0xff000000);
// Draw separator line between gutter and text area
2025-12-18 14:48:14 +02:00
drawRect(x + LINE_NUMBER_GUTTER_WIDTH-1, y, x + LINE_NUMBER_GUTTER_WIDTH, y + height, 0xff3c3f41);
2025-12-18 14:05:39 +02:00
// Enable scissor test to clip drawing to the TEXT viewport rectangle (excludes gutter)
GL11.glEnable(GL11.GL_SCISSOR_TEST);
2025-12-18 22:37:40 +02:00
scissorViewport();
container.visibleLines = (height / container.lineHeight);
int maxScroll = Math.max(0, getPaddedLineCount() - container.visibleLines);
// Handle mouse wheel scroll
int wheelDelta = ((GuiNPCInterface) listener).mouseScroll = Mouse.getDWheel();
if (listener instanceof GuiNPCInterface) {
((GuiNPCInterface) listener).mouseScroll = wheelDelta;
// 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)
scroll.applyWheelScroll(wheelDelta, maxScroll);
}
}
2025-12-18 22:37:40 +02:00
// Handle scrollbar dragging (delegated to ScrollState)
if (scroll.isClickScrolling())
scroll.handleClickScrolling(yMouse, x, y, height, container.visibleLines, getPaddedLineCount(), maxScroll);
2025-12-18 22:37:40 +02:00
// Update scroll animation
scroll.initializeIfNeeded(scroll.getScrolledLine());
scroll.update(maxScroll);
2025-12-26 01:24:40 +02:00
// 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];
}
}
2025-12-16 18:33:55 +02:00
List<JavaTextContainer.LineData> list = new ArrayList<>(container.lines);
2025-12-20 14:18:35 +02:00
2025-12-17 20:06:47 +02:00
// Build brace spans: {origDepth, open line, close line, adjustedDepth}
List<int[]> braceSpans = BracketMatcher.computeBraceSpans(text, list);
2025-12-20 14:18:35 +02:00
// 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);
2025-03-13 17:14:45 -04:00
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
2025-12-18 14:05:39 +02:00
for (int i = renderStart; i <= renderEnd; i++) {
int posY = y + (i - scroll.getScrolledLine()) * container.lineHeight + stringYOffset;
2025-12-18 14:05:39 +02:00
String lineNum = String.valueOf(i + 1);
int lineNumWidth = ClientProxy.Font.width(lineNum);
2025-12-31 19:19:06 +02:00
int lineNumX = x + LINE_NUMBER_GUTTER_WIDTH - lineNumWidth - 5 - GutterIconRenderer.ICON_GUTTER_WIDTH; // right-align before icon space
2025-12-18 14:05:39 +02:00
int lineNumY = posY + 1;
// Highlight current line number
int lineNumColor = 0xFF606366;
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())) {
2025-12-18 14:05:39 +02:00
if (li == i) {
lineNumColor = 0xFFb9c7d6;
2025-12-18 14:05:39 +02:00
break;
}
}
}
}
ClientProxy.Font.drawString(lineNum, lineNumX, lineNumY, lineNumColor);
}
2025-12-31 18:53:28 +02:00
2025-12-18 22:37:40 +02:00
// 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 = ClientProxy.Font.width(line.substring(0, startBracket - data.start));
int e = ClientProxy.Font.width(line.substring(0, startBracket - data.start + 1)) + 1;
2025-12-18 14:05:39 +02:00
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 = ClientProxy.Font.width(line.substring(0, endBracket - data.start));
int e = ClientProxy.Font.width(line.substring(0, endBracket - data.start + 1)) + 1;
2025-12-18 14:05:39 +02:00
drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00);
}
}
2025-12-20 14:18:35 +02:00
// 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 = ClientProxy.Font.width(line.substring(0, rel));
int e = ClientProxy.Font.width(line.substring(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);
2025-03-13 17:14:45 -04:00
while (m.find()) {
if (line.substring(m.start(), m.end()).equals(highlightedWord)) {
int s = ClientProxy.Font.width(line.substring(0, m.start()));
int e = ClientProxy.Font.width(line.substring(0, m.end())) + 1;
2025-12-18 14:05:39 +02:00
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 = ClientProxy.Font.width(line.substring(0, matchStart));
int e = ClientProxy.Font.width(line.substring(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);
}
}
}
}
}
2025-12-21 00:49:34 +02:00
// 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) {
2025-12-21 00:49:34 +02:00
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
2025-12-21 00:49:34 +02:00
int s = ClientProxy.Font.width(line.substring(0, occStart));
int e = isEmpty ? s + 2 : ClientProxy.Font.width(
line.substring(0, occEnd)) + 1; // 2px wide for empty
int occX = x + LINE_NUMBER_GUTTER_WIDTH + s;
int occEndX = x + LINE_NUMBER_GUTTER_WIDTH + 2 + e;
2025-12-21 00:49:34 +02:00
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);
2025-12-21 00:49:34 +02:00
// 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 + ClientProxy.Font.width(beforeCursor);
// drawRect(cursorX, posY + 1, cursorX + 1, posY + container.lineHeight - 1,
// 0xFFFFFFFF);
2025-12-21 00:49:34 +02:00
}
}
}
}
}
}
}
// 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()))) {
2025-12-18 14:05:39 +02:00
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 = ClientProxy.Font.width(
line.substring(0, Math.max(selection.getStartSelection() - data.start, 0)));
int e = ClientProxy.Font.width(
line.substring(0, Math.min(selection.getEndSelection() - data.start, w))) + 1;
2025-12-18 14:05:39 +02:00
drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x992172ff);
}
}
2025-12-17 18:13:29 +02:00
// 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);
2025-12-17 18:13:29 +02:00
for (int[] span : braceSpans) {
2025-12-17 20:06:47 +02:00
int originalDepth = span[0];
2025-12-17 18:13:29 +02:00
int openLine = span[1];
int closeLine = span[2];
2025-12-17 20:06:47 +02:00
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;
2025-12-17 18:13:29 +02:00
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
2025-12-17 20:06:47 +02:00
int safeDepth = Math.max(1, depth);
int spaces = (safeDepth - 1) * 4;
2025-12-17 18:13:29 +02:00
StringBuilder sb = new StringBuilder();
for (int k = 0; k < spaces; k++)
sb.append(' ');
int px = ClientProxy.Font.width(sb.toString());
2025-12-18 14:05:39 +02:00
int gx = x + LINE_NUMBER_GUTTER_WIDTH + 4 + px - 2; // shift left ~2px for the IntelliJ feel
2025-12-17 18:13:29 +02:00
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);
2025-12-17 18:13:29 +02:00
}
}
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()))) {
2025-12-18 14:05:39 +02:00
int posX = x + LINE_NUMBER_GUTTER_WIDTH + ClientProxy.Font.width(
line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length())));
2025-12-19 18:15:37 +02:00
drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, 0xffffffff);
}
}
}
2025-12-31 18:53:28 +02:00
2025-12-31 19:19:06 +02:00
// 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);
}
2025-12-31 18:53:28 +02:00
GL11.glPopMatrix();
2025-12-18 22:37:40 +02:00
GL11.glDisable(GL11.GL_SCISSOR_TEST);
2025-12-18 22:37:40 +02:00
2025-03-13 17:14:45 -04:00
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, 0xFFe0e0e0);
}
if (parent != null)
parent.fullscreenButton.draw(xMouse, yMouse);
// Draw search/replace bar (overlays viewport)
searchBar.draw(xMouse, yMouse);
2025-12-19 22:38:26 +02:00
// Draw go to line dialog (overlays everything)
goToLineDialog.draw(xMouse, yMouse);
2026-01-03 14:23:35 +02:00
2026-01-04 00:40:46 +02:00
KEYS_OVERLAY.draw(xMouse, yMouse, wheelDelta);
2026-01-03 14:23:35 +02:00
// Draw autocomplete menu (overlays code area)
autocompleteManager.draw(xMouse, yMouse);
2025-12-31 19:19:06 +02:00
// Draw hover tooltips (on top of everything)
2025-12-26 01:24:40 +02:00
if (hoverState.isTooltipVisible()) {
2025-12-26 17:29:36 +02:00
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);
2025-12-26 01:24:40 +02:00
}
2025-12-31 19:19:06 +02:00
// Draw gutter icon tooltip
if (hoveredGutterMethod != null) {
GutterIconRenderer.renderTooltip(hoveredGutterMethod, xMouse, yMouse, x, width, y, height);
}
}
2025-12-17 18:13:29 +02:00
2025-12-18 22:37:40 +02:00
private void scissorViewport() {
Minecraft mc = Minecraft.getMinecraft();
ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
int scaleFactor = sr.getScaleFactor();
int scissorX = (this.x) * scaleFactor;
int scissorY = (sr.getScaledHeight() - (this.y + this.height)) * scaleFactor;
int scissorW = (this.width) * scaleFactor;
int scissorH = this.height * scaleFactor;
GL11.glScissor(scissorX, scissorY, scissorW, scissorH);
}
2025-12-31 18:53:28 +02:00
// ==================== SELECTION & CURSOR POSITION ====================
2025-12-18 22:37:40 +02:00
// Get cursor position from mouse coordinates
private int getSelectionPos(int xMouse, int yMouse) {
2025-12-18 14:05:39 +02:00
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);
2025-03-13 17:14:45 -04:00
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) {
int lineWidth = 0;
char[] chars = data.text.toCharArray();
2025-03-13 17:14:45 -04:00
for (int j = 1; j <= chars.length; ++j) {
int w = ClientProxy.Font.width(data.text.substring(0, j));
2025-03-13 17:14:45 -04:00
if (xMouse < lineWidth + (w - lineWidth) / 2) {
return data.start + j - 1;
}
lineWidth = w;
}
2025-12-17 15:13:38 +02:00
// 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);
}
2025-12-26 01:24:40 +02:00
/**
* 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
2025-12-26 18:12:02 +02:00
int viewportX = x + LINE_NUMBER_GUTTER_WIDTH + 1;
if (xMouse < viewportX || xMouse > x + width || yMouse < y || yMouse > y + height) {
2025-12-26 01:24:40 +02:00
return null;
}
// Adjust mouse position relative to text area
int relativeY = yMouse - y;
2025-12-26 18:12:02 +02:00
2025-12-26 01:24:40 +02:00
// 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;
}
2025-12-26 18:12:02 +02:00
ScriptLine lineData = container.getDocument().getLine(lineIdx);
String lineText = lineData.getText();
int lineStart = lineData.getGlobalStart();
2025-12-26 01:24:40 +02:00
// Get the token at this position
2025-12-26 18:12:02 +02:00
int globalMouseX = getSelectionPos(xMouse,yMouse);
Token token = lineData.getTokenAt(globalMouseX, (t) -> t.getType() != TokenType.DEFAULT); // Ignore default tokens i.e. whitespaces
2025-12-26 18:12:02 +02:00
if (token == null)
2025-12-26 01:24:40 +02:00
return null;
// Calculate token's screen position
2025-12-26 18:12:02 +02:00
int tokenLocalStart = token.getGlobalStart() - lineStart;
int tokenLocalEnd = token.getGlobalEnd() - lineStart;
2025-12-26 01:24:40 +02:00
tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length()));
tokenLocalEnd = Math.max(0, Math.min(tokenLocalEnd, lineText.length()));
2025-12-26 18:12:02 +02:00
int tokenScreenX = viewportX + ClientProxy.Font.width(lineText.substring(0, tokenLocalStart));
2025-12-26 01:24:40 +02:00
int tokenScreenY = y + (lineIdx - scroll.getScrolledLine()) * container.lineHeight - (int)fracPixels;
int tokenWidth = ClientProxy.Font.width(lineText.substring(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) {
// 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()) {
2025-12-26 01:24:40 +02:00
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 {
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
2025-12-22 13:51:57 +02:00
Supplier<Boolean> isActive = () -> active && isEnabled() && !KEYS_OVERLAY.isVisible();
Supplier<Boolean> openBoxes = () -> !KEYS_OVERLAY.isVisible();
2025-12-19 18:38:11 +02:00
// CUT: Copy selection to clipboard and delete it. If no selection, cut the current sentence.
KEYS.CUT.setTask(e -> {
2025-12-19 18:25:32 +02:00
if (!e.isPress() || !isActive.get())
return;
2025-12-19 18:25:32 +02:00
if (selection.hasSelection()) {
NoppesStringUtils.setClipboardContents(selection.getSelectedText(text));
String s = getSelectionBeforeText();
setText(s + getSelectionAfterText());
selection.reset(s.length());
scrollToCursor();
2025-12-19 18:38:11 +02:00
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();
2025-12-19 18:25:32 +02:00
}
});
// COPY: Copy selection to clipboard (or entire line if no selection)
KEYS.COPY.setTask(e -> {
2025-12-19 18:25:32 +02:00
if (!e.isPress() || !isActive.get())
return;
2025-12-19 18:25:32 +02:00
if (selection.hasSelection()) {
2025-12-19 18:25:32 +02:00
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 -> {
2025-12-19 18:25:32 +02:00
if (!e.isPress() || !isActive.get())
return;
2025-12-19 18:25:32 +02:00
String clipboard = NoppesStringUtils.getClipboardContents();
if (clipboard == null)
clipboard = "";
if (selection.hasSelection()) {
addText(clipboard);
lastCopyWasLine = false;
lastCopiedLineText = null;
scrollToCursor();
return;
}
boolean isLinePaste = lastCopyWasLine && lastCopiedLineText != null && clipboard.equals(lastCopiedLineText);
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);
2025-12-19 18:25:32 +02:00
scrollToCursor();
});
// UNDO: Restore last edit from undo list
// Works in search bar.
KEYS.UNDO.setTask(e -> {
if ((!e.isPress() && !e.isHold()) || !openBoxes.get())
return;
2025-12-19 18:25:32 +02:00
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;
2025-12-19 18:25:32 +02:00
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 -> {
2025-12-19 18:25:32 +02:00
if (!e.isPress() || !isActive.get())
return;
2025-12-19 18:25:32 +02:00
formatText();
});
// TOGGLE_COMMENT: Toggle comment for selection or current line
KEYS.TOGGLE_COMMENT.setTask(e -> {
2025-12-19 18:25:32 +02:00
if (!e.isPress() || !isActive.get())
return;
2025-12-19 18:25:32 +02:00
if (selection.hasSelection())
toggleCommentSelection();
else
toggleCommentLineAtCursor();
});
// DUPLICATE: Duplicate selection or current line
KEYS.DUPLICATE.setTask(e -> {
2025-12-19 18:25:32 +02:00
if (!e.isPress() || !isActive.get())
return;
2025-12-19 18:25:32 +02:00
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;
}
2025-12-19 18:25:32 +02:00
}
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) {
2025-12-22 03:34:21 +02:00
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);
2025-12-19 18:25:32 +02:00
String insertText = lineText.endsWith("\n") ? lineText : "\n" + lineText;
2025-12-22 03:34:21 +02:00
int insertionPoint = Math.min(line.end, text.length());
2025-12-19 18:25:32 +02:00
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;
}
}
2025-12-19 18:25:32 +02:00
}
});
2025-12-20 19:50:28 +02:00
// 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();
});
2025-12-20 19:50:28 +02:00
// Check if can open just for SearchReplaceBar and GoToLine
// SEARCH: Open search bar (Ctrl+R)
// Works in search bar.
KEYS.SEARCH.setTask(e -> {
2025-12-20 19:50:28 +02:00
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 -> {
2025-12-20 19:50:28 +02:00
if (!e.isPress() || !openBoxes.get())
return;
unfocusAll();
searchBar.openSearchReplace();
});
2025-12-19 22:38:26 +02:00
// GO_TO_LINE: Open go to line dialog (Ctrl+G)
// Works in search bar.
2025-12-19 22:38:26 +02:00
KEYS.GO_TO_LINE.setTask(e -> {
2025-12-20 19:50:28 +02:00
if (!e.isPress() || !openBoxes.get())
2025-12-19 22:38:26 +02:00
return;
unfocusAll();
2025-12-19 22:38:26 +02:00
goToLineDialog.toggle();
});
2025-12-21 00:49:34 +02:00
// RENAME: Start rename refactoring (Shift+F6)
KEYS.RENAME.setTask(e -> {
if (!e.isPress() || !openBoxes.get())
return;
2025-12-21 00:49:34 +02:00
if (!renameHandler.isActive()) {
unfocusAll();
active = true;
2025-12-21 00:49:34 +02:00
renameHandler.startRename();
}
});
2026-01-03 14:23:35 +02:00
// 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();
2025-12-21 00:49:34 +02:00
if (renameHandler.isActive())
renameHandler.cancel();
2026-01-03 14:23:35 +02:00
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) {
2025-12-22 13:43:58 +02:00
if (KEYS_OVERLAY.keyTyped(c, i))
return true;
2025-12-21 00:49:34 +02:00
// Handle rename refactor input first if active
if (renameHandler.isActive() && renameHandler.keyTyped(c, i))
return true;
2025-12-19 22:38:26 +02:00
// Handle Go To Line dialog input first if it has focus
if (goToLineDialog.isVisible() &&goToLineDialog.keyTyped(c, i))
2025-12-19 22:38:26 +02:00
return true;
// Handle search bar input first if it has focus
if (searchBar.isVisible() && searchBar.keyTyped(c, i))
return true;
2026-01-03 14:23:35 +02:00
// Handle autocomplete navigation keys first when visible
if (autocompleteManager.isVisible()) {
if (autocompleteManager.keyPressed(i)) {
return true;
}
}
2025-12-22 13:51:57 +02:00
// Ignore if any global keys bound to this code are currently pressed
if (KEYS.hasMatchingKeyPressed(i))
return false;
2025-12-22 13:51:57 +02:00
if (!active)
return false;
2025-03-13 17:14:45 -04:00
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
2025-03-13 17:14:45 -04:00
if (i == Keyboard.KEY_LEFT) {
int j = 1; // default: move one character
2025-03-13 17:14:45 -04:00
if (isCtrlKeyDown()) {
2025-12-25 11:35:39 +02:00
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--;
}
}
2025-12-25 11:35:39 +02:00
j = Math.max(1, pos - g);
}
int newPos = Math.max(selection.getCursorPosition() - j, 0);
// If Shift is held, extend selection; otherwise place caret.
2025-12-17 14:33:17 +02:00
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
2025-03-13 17:14:45 -04:00
if (i == Keyboard.KEY_RIGHT) {
int j = 1; // default: move one character
2025-03-13 17:14:45 -04:00
if (isCtrlKeyDown()) {
2025-12-25 11:35:39 +02:00
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++;
2025-12-19 18:38:11 +02:00
} else {
2025-12-25 11:35:39 +02:00
while (end < text.length() && !Character.isWhitespace(text.charAt(end)) && !isWordChar.test(text.charAt(end))) end++;
2025-12-19 18:38:11 +02:00
}
}
2025-12-25 11:35:39 +02:00
j = Math.max(1, end - pos);
}
int newPos = Math.min(selection.getCursorPosition() + j, text.length());
2025-12-17 14:33:17 +02:00
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.
2025-03-13 17:14:45 -04:00
if (i == Keyboard.KEY_UP) {
setCursor(cursorUp(), GuiScreen.isShiftKeyDown());
scrollToCursor();
return true;
}
2025-03-13 17:14:45 -04:00
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;
}
2025-12-27 15:17:29 +02:00
// RETURN/ENTER: special handling for /** javadoc stub and opening brace '{'
if (i == Keyboard.KEY_RETURN) {
int cursorPos = selection.getCursorPosition();
2025-12-27 15:17:29 +02:00
// Check for /** javadoc stub auto-generation
String before = getSelectionBeforeText();
if (before.endsWith("/**")) {
// Find current line to get indent level
String indent = "";
for (LineData ld : this.container.lines) {
if (cursorPos >= ld.start && cursorPos <= ld.end) {
indent = ld.text.substring(0, IndentHelper.getLineIndent(ld.text));
break;
}
}
// Look ahead for a function declaration to generate smart JSDoc
String after = getSelectionAfterText();
String javadocStub = generateJSDocStub(after, indent);
2025-12-27 15:17:29 +02:00
addText(javadocStub);
// Position cursor after " * " on the description line
2025-12-27 15:17:29 +02:00
int newCursorPos = before.length() + 1 + indent.length() + 3; // +1 for \n, +3 for " * "
selection.reset(newCursorPos);
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 = "";
for (LineData ld : this.container.lines) {
if (prevNonWs >= ld.start && prevNonWs < ld.end) {
indent = ld.text.substring(0, IndentHelper.getLineIndent(ld.text));
break;
}
}
if (indent == null)
indent = "";
String childIndent = indent + " ";
String after = getSelectionAfterText();
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;
}
boolean hasMatchingCloseSameIndent = 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) {
int spanOpen = span[1];
int spanClose = span[2];
if (spanOpen == openLineIdx) {
int closeIndent = IndentHelper.getLineIndent(this.container.lines.get(spanClose).text);
if (closeIndent == indent.length()) {
hasMatchingCloseSameIndent = true;
}
break;
}
}
}
} catch (Exception ex) {
hasMatchingCloseSameIndent = false;
}
if (hasMatchingCloseSameIndent) {
addText("\n" + childIndent);
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.
2025-03-13 17:14:45 -04:00
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.
2025-03-13 17:14:45 -04:00
if (isKeyComboCtrlBackspace(i)) {
2025-12-25 11:31:42 +02:00
String before = getSelectionBeforeText();
if (selection.getStartSelection() > 0 && !selection.hasSelection()) {
2025-12-25 11:31:42 +02:00
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--;
2025-03-13 17:14:45 -04:00
} else {
2025-12-25 11:31:42 +02:00
// 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--;
2023-07-30 15:47:03 -04:00
}
2025-12-25 11:31:42 +02:00
before = before.substring(0, g);
selection.setStartSelection(selection.getStartSelection() - (pos - g));
2023-07-30 15:47:03 -04:00
}
setText(before + getSelectionAfterText(), true); // Use atomic undo
selection.reset(selection.getStartSelection());
2023-07-30 15:47:03 -04:00
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
2025-03-13 17:14:45 -04:00
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, delete the whole line
// (including the trailing newline if present). This makes Backspace
// intuitive on blank/indented lines outside any recognized scope.
LineData currCheck = selection.findCurrentLine(container.lines);
if (currCheck != null && currCheck.text.trim().length() == 0) {
2025-12-22 03:39:22 +02:00
int removeEnd = text.indexOf('\n', currCheck.start - 1);
if (removeEnd == -1) {
removeEnd = text.length();
} else {
removeEnd = removeEnd + 1; // include the newline
}
2025-12-22 03:39:22 +02:00
String before = text.substring(0, ValueUtil.clamp(currCheck.start - 1, 0, text.length()));
String after = removeEnd <= text.length() ? text.substring(removeEnd) : "";
setText(before + after, true); // Use atomic undo
int newCursor = Math.max(0, currCheck.start - 1);
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;
2025-12-22 03:11:45 +02:00
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 = " ";
}
2025-12-17 20:06:47 +02:00
}
setText(before + spacer + content, true); // Use atomic undo
int newCursor = before.length() + spacer.length();
selection.reset(newCursor);
scrollToCursor();
2025-12-17 20:06:47 +02:00
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();
2026-01-03 14:23:35 +02:00
// 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
2025-03-13 17:14:45 -04:00
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
2025-03-13 17:14:45 -04:00
if (this.isKeyComboCtrlC(i)) {
if (selection.hasSelection()) {
NoppesStringUtils.setClipboardContents(selection.getSelectedText(text));
}
return true;
}
// CTRL+V: Paste (insert clipboard contents at caret)
2025-03-13 17:14:45 -04:00
if (this.isKeyComboCtrlV(i)) {
addText(NoppesStringUtils.getClipboardContents());
scrollToCursor();
return true;
}
// UNDO (Ctrl+Z): restore last entry from undoList and push current state to redoList
2025-03-13 17:14:45 -04:00
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
2025-03-13 17:14:45 -04:00
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
2025-12-17 20:06:47 +02:00
if (i == Keyboard.KEY_F && isCtrlKeyDown()) {
formatText();
return true;
}
// CTRL+/ : toggle comment for selection or current line
2025-12-17 09:53:32 +02:00
if (i == Keyboard.KEY_SLASH && isCtrlKeyDown()) {
if (selection.hasSelection()) {
toggleCommentSelection();
} else {
toggleCommentLineAtCursor();
2025-12-17 09:53:32 +02:00
}
return true;
2025-12-17 09:53:32 +02:00
}
// CTRL+D : duplicate selection or current line
2025-12-17 09:53:32 +02:00
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);
2025-12-17 09:53:32 +02:00
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;
}
}
2025-12-17 09:53:32 +02:00
}
return true;
2025-12-17 09:53:32 +02:00
}
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();
// If the user types a closing character and that same closer is
// already immediately after the caret, move caret past it instead
// of inserting another closer. This prevents duplicate closers
// when the editor auto-inserts pairs.
if ((c == ')' || c == ']' || c == '"' || c == '\'' ) && after.length() > 0 && after.charAt(0) == c) {
// Move caret forward by one (skip over existing closer)
selection.reset(before.length() + 1);
scrollToCursor();
2026-01-03 14:23:35 +02:00
// Notify autocomplete of the character
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().isExcluded(cursorPos)) {
if (c == '"') {
setText(before + "\"\"" + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
if (c == '\'') {
setText(before + "''" + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
if (c == '[') {
setText(before + "[]" + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
if (c == '(') {
setText(before + "()" + after, true);
selection.reset(before.length() + 1);
scrollToCursor();
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
}
// Default insertion for printable characters: insert at caret (replacing selection)
addText(Character.toString(c));
scrollToCursor();
2026-01-03 14:23:35 +02:00
// Notify autocomplete of the character
autocompleteManager.onCharTyped(c, text, cursorPos);
return true;
}
return false;
}
2025-03-13 17:14:45 -04:00
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(){
2026-01-03 14:23:35 +02:00
return !KEYS_OVERLAY.isVisible() && !searchBar.isVisible() && !goToLineDialog.isVisible() && !renameHandler.isActive() && !autocompleteManager.isVisible();
}
// ==================== KEYBOARD MODIFIERS ====================
2025-03-13 17:14:45 -04:00
private boolean isAltKeyDown() {
return Keyboard.isKeyDown(56) || Keyboard.isKeyDown(184);
}
2025-03-13 17:14:45 -04:00
private boolean isKeyComboCtrlX(int keyID) {
return keyID == 45 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
2025-03-13 17:14:45 -04:00
private boolean isKeyComboCtrlBackspace(int keyID) {
2023-07-30 15:47:03 -04:00
return keyID == Keyboard.KEY_BACK && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
2025-03-13 17:14:45 -04:00
private boolean isKeyComboCtrlV(int keyID) {
return keyID == 47 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
2025-03-13 17:14:45 -04:00
private boolean isKeyComboCtrlC(int keyID) {
return keyID == 46 && isCtrlKeyDown() && !isShiftKeyDown() && !isAltKeyDown();
}
2025-03-13 17:14:45 -04:00
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 "";
}
2025-12-17 18:35:26 +02:00
private String getAutoIndentForEnter() {
LineData currentLine = selection.findCurrentLine(container.lines);
2025-12-17 18:35:26 +02:00
if (currentLine == null) {
return "";
}
return IndentHelper.getAutoIndentForEnter(currentLine.text, selection.getCursorPosition() - currentLine.start);
2025-12-17 23:51:28 +02:00
}
// ==================== TEXT FORMATTING ====================
2025-12-17 20:06:47 +02:00
private int getTabSize() {
return IndentHelper.TAB_SIZE;
2025-12-17 20:06:47 +02:00
}
private String repeatSpace(int count) {
return IndentHelper.spaces(count);
2025-12-17 20:06:47 +02:00
}
private void formatText() {
2025-12-21 00:49:34 +02:00
// Calculate viewport width for line wrapping (account for gutter and scrollbar)
int viewportWidth = this.width - LINE_NUMBER_GUTTER_WIDTH - 10;
IndentHelper.FormatResult result = IndentHelper.formatText(text, selection.getCursorPosition(), viewportWidth);
setText(result.text);
selection.reset(Math.max(0, Math.min(result.cursorPosition, this.text.length())));
2025-12-17 20:06:47 +02:00
}
// ==================== TAB HANDLING ====================
2025-12-17 20:06:47 +02:00
private void handleTab() {
LineData currentLine = selection.findCurrentLine(container.lines);
2025-12-17 20:06:47 +02:00
if (currentLine == null) {
addText(" ");
return;
}
int tab = getTabSize();
int indentLen = IndentHelper.getLineIndent(currentLine.text);
2025-12-17 20:06:47 +02:00
int textStartPos = currentLine.start + indentLen;
if (selection.getCursorPosition() <= textStartPos) {
2025-12-17 20:06:47 +02:00
// 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) {
2025-12-17 20:06:47 +02:00
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()));
2025-12-17 20:06:47 +02:00
} 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;
2025-12-17 20:06:47 +02:00
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);
2025-12-17 20:06:47 +02:00
if (currentLine == null)
return;
int tab = getTabSize();
int indentLen = IndentHelper.getLineIndent(currentLine.text);
2025-12-17 20:06:47 +02:00
int textStartPos = currentLine.start + indentLen;
if (selection.getCursorPosition() <= textStartPos) {
2025-12-17 20:06:47 +02:00
// Cursor before any text: reduce leading indent to previous tab stop
int targetIndent = Math.max(0, ((indentLen - 1) / tab) * tab);
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()));
2025-12-17 20:06:47 +02:00
} else {
// Cursor after start of text: remove up to previous tab stop worth of spaces immediately before cursor
int column = selection.getCursorPosition() - currentLine.start;
2025-12-17 20:06:47 +02:00
int mod = column % tab;
int toRemove = mod == 0 ? tab : mod;
int removed = 0;
int pos = selection.getCursorPosition() - 1;
2025-12-17 20:06:47 +02:00
while (pos >= currentLine.start && removed < toRemove && text.charAt(pos) == ' ') {
pos--;
removed++;
}
if (removed > 0) {
int removeStart = pos + 1;
String before = text.substring(0, removeStart);
String after = text.substring(selection.getCursorPosition());
2025-12-17 20:06:47 +02:00
setText(before + after);
int newCursor = removeStart;
selection.reset(Math.min(newCursor, this.text.length()));
2025-12-17 20:06:47 +02:00
}
}
}
// ==================== CURSOR MANAGEMENT ====================
2025-12-17 20:06:47 +02:00
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) {
2026-01-03 14:23:35 +02:00
// 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;
2026-01-03 14:23:35 +02:00
2025-12-19 22:38:26 +02:00
// 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();
}
2025-12-22 13:43:58 +02:00
// Let the overlay consume clicks (it returns true when it handled the event)
if (KEYS_OVERLAY.mouseClicked(xMouse, yMouse, mouseButton))
2025-12-19 18:12:37 +02:00
return;
2025-12-18 23:16:08 +02:00
// 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;
2025-12-19 18:12:37 +02:00
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
}
}
}
2025-12-21 00:49:34 +02:00
// Compute logical click position in text
int clickPos = this.getSelectionPos(xMouse, yMouse);
2025-12-21 00:49:34 +02:00
// 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();
2025-12-18 23:16:08 +02:00
// Prepare click state (left button starts most interactions)
this.clicked = mouseButton == 0;
this.doubleClicked = false;
this.tripleClicked = false;
long time = System.currentTimeMillis();
2025-12-18 23:16:08 +02:00
// 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) {
2025-12-18 23:16:08 +02:00
// We consumed the mouse-down as a scrollbar drag start
this.clicked = false;
scroll.startScrollbarDrag(yMouse,this.y,this.height, getPaddedLineCount());
} else {
2025-12-18 23:16:08 +02:00
// Handle double/triple click selection counting
if (time - this.lastClicked < 300L) {
this.clickCount++;
} else {
this.clickCount = 1;
}
if (this.clickCount == 2) {
2025-12-18 23:16:08 +02:00
// Double-click: select the word under the caret using the container's word regex
this.doubleClicked = true;
2025-12-18 23:16:08 +02:00
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) {
2025-12-18 23:16:08 +02:00
// Triple-click: select the entire logical line that contains the caret
this.tripleClicked = true;
2025-12-18 23:16:08 +02:00
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;
2025-12-26 17:28:28 +02:00
// 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() {
2025-12-19 22:38:26 +02:00
// 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();
2025-12-21 00:49:34 +02:00
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.
2025-03-13 17:14:45 -04:00
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;
2025-12-16 18:33:55 +02:00
//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());
2025-10-15 06:28:43 -04:00
// Consider text changes user activity to pause caret blinking briefly
selection.markActivity();
searchBar.updateMatches();
2026-01-03 14:23:35 +02:00
// 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();
}
2026-01-03 05:06:41 +02:00
/**
* 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;
}
2025-10-15 06:28:43 -04:00
private void clampSelectionBounds() {
selection.clamp(text != null ? text.length() : 0);
2025-10-15 06:28:43 -04:00
}
// ==================== 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;
}
}
}