From b9b1b0725a526f565fb771a985bd2d7612af92cb Mon Sep 17 00:00:00 2001 From: bigguy345 Date: Thu, 5 Mar 2026 00:34:02 +0200 Subject: [PATCH] JSDoc hover tooltip improvements: fix scissor clip, remove trailing whitespace, balance code block padding, support multi-resolution wrapping, add tooltip panning - Trim trailing blank lines from JSDoc descriptions to remove extra bottom space - Add tooltip panning: drag M1 anywhere on tooltip panel body to reposition it freely --- .../client/gui/util/GuiScriptTextArea.java | 32 +++++ .../script/interpreter/hover/HoverState.java | 56 ++++++++ .../interpreter/hover/TokenHoverInfo.java | 120 +++++++++++++++++- .../interpreter/hover/TokenHoverRenderer.java | 100 ++++++++++++--- 4 files changed, 284 insertions(+), 24 deletions(-) diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java index 0e6231c97..4b9675b1a 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -660,6 +660,14 @@ public class GuiScriptTextArea extends GuiNpcTextField { hoverState.releaseScrollbarDrag(); } } + // Handle tooltip panel drag (update each frame; release when button lifted) + if (hoverState.isDraggingTooltip()) { + if (Mouse.isButtonDown(0)) { + hoverState.updateTooltipDrag(xMouse, yMouse); + } else { + hoverState.releaseTooltipDrag(); + } + } // Update scroll animation scroll.initializeIfNeeded(scroll.getScrolledLine()); @@ -1195,8 +1203,19 @@ public class GuiScriptTextArea extends GuiNpcTextField { * Called every frame from drawTextBox. */ private void updateHoverState(int xMouse, int yMouse) { + // Keep tooltip alive and skip token re-evaluation while it is being dragged + if (hoverState.isDraggingTooltip()) { + hoverState.setLastMousePosition(xMouse, yMouse); + return; + } + // Tooltip was dragged to a custom position — keep it pinned until an explicit click dismisses it + if (hoverState.hasOverriddenPosition()) { + hoverState.setLastMousePosition(xMouse, yMouse); + return; + } // If mouse is currently over the tooltip panel, keep it visible — do not clear or re-evaluate tokens if (hoverState.isTooltipVisible() && hoverState.isMouseOverTooltip(xMouse, yMouse)) { + hoverState.setLastMousePosition(xMouse, yMouse); return; } // Don't show tooltips when not active, clicking, or when overlays are visible @@ -1219,6 +1238,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { } else { // If mouse is over the tooltip box, keep tooltip visible — do not clear if (hoverState.isMouseOverTooltip(xMouse, yMouse)) { + hoverState.setLastMousePosition(xMouse, yMouse); return; } hoverState.update(xMouse, yMouse, null, 0, 0, 0); @@ -2908,6 +2928,18 @@ public class GuiScriptTextArea extends GuiNpcTextField { hoverState.startScrollbarDrag(yMouse); return; } + // Check tooltip panel drag — M1 anywhere on the panel body (not the scrollbar thumb) + if (mouseButton == 0 && hoverState.isTooltipVisible() + && hoverState.isMouseOverTooltipPanel(xMouse, yMouse) + && !hoverState.isMouseOverScrollbarThumb(xMouse, yMouse)) { + hoverState.startTooltipDrag(xMouse, yMouse); + return; + } + // M1 outside a drag-pinned tooltip — dismiss it and let the click fall through to the editor + if (mouseButton == 0 && hoverState.hasOverriddenPosition() + && !hoverState.isMouseOverTooltipPanel(xMouse, yMouse)) { + hoverState.clearHover(); + } // Determine whether click occurred inside the text area bounds this.active = xMouse >= this.x && xMouse < this.x + this.width && yMouse >= this.y && yMouse < this.y + this.height; if (this.active) { diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java index 18e50291d..54dfef473 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/HoverState.java @@ -59,6 +59,12 @@ public class HoverState { private int dragStartMouseY; private int dragStartScrollOffset; + /** Tooltip panel drag state */ + private boolean isDraggingTooltip; + private int tooltipDragOffsetX, tooltipDragOffsetY; + private boolean tooltipPositionOverridden; + private int overriddenTooltipX, overriddenTooltipY; + /** Smooth scroll: target offset (pixels), current is animated toward it each frame */ private float targetScrollOffset; /** Vertical scroll offset for tooltip content (pixels) */ @@ -224,6 +230,9 @@ public class HoverState { hoverInfo = null; tooltipBoxX = tooltipBoxY = tooltipBoxWidth = tooltipBoxHeight = 0; tooltipPanelX = tooltipPanelY = tooltipPanelW = tooltipPanelH = 0; + isDraggingScrollbar = false; + isDraggingTooltip = false; + tooltipPositionOverridden = false; tooltipScrollOffsetF = 0; targetScrollOffset = 0; tooltipMaxScroll = 0; @@ -399,6 +408,49 @@ public class HoverState { isDraggingScrollbar = false; } + // ==================== TOOLTIP PANEL DRAG ==================== + + /** Record the actual rendered tooltip panel rect (used for drag hit-testing). */ + public void setTooltipPanel(int x, int y, int w, int h) { + tooltipPanelX = x; + tooltipPanelY = y; + tooltipPanelW = w; + tooltipPanelH = h; + } + + public boolean isMouseOverTooltipPanel(int mx, int my) { + if (!tooltipVisible || tooltipPanelW <= 0) return false; + return mx >= tooltipPanelX && mx <= tooltipPanelX + tooltipPanelW + && my >= tooltipPanelY && my <= tooltipPanelY + tooltipPanelH; + } + + public int getTooltipPanelX() { return tooltipPanelX; } + public int getTooltipPanelY() { return tooltipPanelY; } + + /** Begin dragging the tooltip; offsets are from mouse to tooltip top-left. */ + public void startTooltipDrag(int mouseX, int mouseY) { + isDraggingTooltip = true; + tooltipDragOffsetX = mouseX - tooltipPanelX; + tooltipDragOffsetY = mouseY - tooltipPanelY; + } + + /** Called every frame while M1 is held to move the tooltip. */ + public void updateTooltipDrag(int mouseX, int mouseY) { + if (!isDraggingTooltip) return; + overriddenTooltipX = mouseX - tooltipDragOffsetX; + overriddenTooltipY = mouseY - tooltipDragOffsetY; + tooltipPositionOverridden = true; + } + + public void releaseTooltipDrag() { + isDraggingTooltip = false; + } + + public boolean isDraggingTooltip() { return isDraggingTooltip; } + public boolean hasOverriddenPosition() { return tooltipPositionOverridden; } + public int getOverriddenTooltipX() { return overriddenTooltipX; } + public int getOverriddenTooltipY() { return overriddenTooltipY; } + public boolean isDraggingScrollbar() { return isDraggingScrollbar; } @@ -440,6 +492,10 @@ public class HoverState { public int getLastMouseY() { return lastMouseY; } + public void setLastMousePosition(int x, int y) { + lastMouseX = x; + lastMouseY = y; + } /** * Get the progress (0.0 to 1.0) of the hover delay. diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java index 85490ca11..3f404adfe 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverInfo.java @@ -75,6 +75,11 @@ public class TokenHoverInfo { public static class DocumentationLine { public final List segments; + /** True for every line (including empty ones) inside a ``` code fence */ + public boolean isCodeLine = false; + /** True only for the FIRST line of each code block (triggers background draw) */ + public boolean isCodeBlockFirst = false; + public DocumentationLine() { this.segments = new ArrayList<>(); } @@ -1195,20 +1200,125 @@ public class TokenHoverInfo { * - blank lines → empty DocumentationLine (spacer) * - normal text → default color */ + /** + * Simple JavaScript syntax tokenizer. + * Breaks a single code line into colored TextSegments. + * Handles: // comments, "strings", 'strings', keywords, numbers, identifiers. + */ + private static List tokenizeCodeLine(String line) { + List tokens = new ArrayList<>(); + if (line == null || line.isEmpty()) return tokens; + + // JS keywords + java.util.Set keywords = new java.util.HashSet<>(java.util.Arrays.asList( + "var","let","const","function","return","if","else","while","for","do", + "break","continue","new","this","null","undefined","true","false", + "typeof","instanceof","void","delete","in","of","class","extends", + "import","export","from","try","catch","finally","throw","switch","case", + "default","with","debugger","yield","async","await" + )); + + int i = 0; + int len = line.length(); + + while (i < len) { + char c = line.charAt(i); + + // Line comment + if (c == '/' && i + 1 < len && line.charAt(i + 1) == '/') { + tokens.add(new TextSegment(line.substring(i), 0x6A9955)); // muted green-gray + break; + } + + // String literal + if (c == '"' || c == '\'') { + char quote = c; + int start = i++; + while (i < len && line.charAt(i) != quote) { + if (line.charAt(i) == '\\') i++; // skip escape + i++; + } + if (i < len) i++; // consume closing quote + tokens.add(new TextSegment(line.substring(start, i), TextSegment.COLOR_STRING)); + continue; + } + + // Number + if (Character.isDigit(c)) { + int start = i++; + while (i < len && (Character.isDigit(line.charAt(i)) || line.charAt(i) == '.' || line.charAt(i) == 'x' || line.charAt(i) == 'X' || (line.charAt(i) >= 'a' && line.charAt(i) <= 'f') || (line.charAt(i) >= 'A' && line.charAt(i) <= 'F'))) i++; + tokens.add(new TextSegment(line.substring(start, i), 0x6897BB)); + continue; + } + + // Identifier or keyword + if (Character.isLetter(c) || c == '_' || c == '$') { + int start = i++; + while (i < len && (Character.isLetterOrDigit(line.charAt(i)) || line.charAt(i) == '_' || line.charAt(i) == '$')) i++; + String word = line.substring(start, i); + int color = keywords.contains(word) ? TextSegment.COLOR_KEYWORD : TextSegment.COLOR_DEFAULT; + tokens.add(new TextSegment(word, color)); + continue; + } + + // Anything else (operators, punctuation, spaces) — group consecutive non-identifier chars + int start = i++; + while (i < len) { + char nc = line.charAt(i); + if (Character.isLetterOrDigit(nc) || nc == '_' || nc == '$' || nc == '"' || nc == '\'' || nc == '/' ) break; + i++; + } + tokens.add(new TextSegment(line.substring(start, i), TextSegment.COLOR_DEFAULT)); + } + + return tokens; + } + + private static boolean isCodeOperatorChar(char c) { + return "=<>!+-*/%&|^~,;:([{".indexOf(c) >= 0; + } + private void addDescriptionAsMarkdown(String description) { if (description == null || description.isEmpty()) return; String[] lines = description.split("\n", -1); boolean inCodeFence = false; + boolean nextIsFirst = false; for (String line : lines) { DocumentationLine docLine = new DocumentationLine(); if (line.trim().startsWith("```")) { // Toggle code fence — suppress the fence marker, emit empty spacer inCodeFence = !inCodeFence; + if (inCodeFence) { + nextIsFirst = true; + } jsDocLines.add(new DocumentationLine()); } else if (inCodeFence) { - // Code content — green - docLine.addSegment(line, TextSegment.COLOR_STRING); - jsDocLines.add(docLine); + if (line.trim().isEmpty()) { + // Empty line inside code fence — keep as code line + DocumentationLine emptyCode = new DocumentationLine(); + emptyCode.isCodeLine = true; + if (nextIsFirst) { + emptyCode.isCodeBlockFirst = true; + nextIsFirst = false; + } + jsDocLines.add(emptyCode); + } else { + // Code content — syntax highlighted + List codeTokens = tokenizeCodeLine(line); + if (codeTokens.isEmpty()) { + docLine.addSegment(line, TextSegment.COLOR_STRING); + } else { + for (TextSegment tok : codeTokens) { + docLine.segments.add(tok); + } + } + docLine.isCodeLine = true; + if (nextIsFirst) { + docLine.isCodeBlockFirst = true; + nextIsFirst = false; + } + jsDocLines.add(docLine); + } } else if (line.trim().startsWith("###")) { // H3 heading — yellow (strip ### prefix, keep emoji if present) String text = line.trim().substring(3).trim(); @@ -1228,6 +1338,10 @@ public class TokenHoverInfo { jsDocLines.add(docLine); } } + // Strip trailing blank lines left by description ending with \n + while (!jsDocLines.isEmpty() && jsDocLines.get(jsDocLines.size() - 1).isEmpty() && !jsDocLines.get(jsDocLines.size() - 1).isCodeLine) { + jsDocLines.remove(jsDocLines.size() - 1); + } } /** diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java index 8d4ae5075..890d17e2b 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/hover/TokenHoverRenderer.java @@ -66,6 +66,15 @@ public class TokenHoverRenderer { /** Documentation text color */ private static final int DOC_COLOR = 0xFFA9B7C6; + /** Background fill for ``` code blocks */ + private static final int CODE_BG_COLOR = 0xFF1E1F22; + /** Left accent bar color for code blocks */ + private static final int CODE_ACCENT_COLOR = 0xFF4FC1FF; + /** Horizontal padding inside code block */ + private static final int CODE_INDENT = 10; + /** Vertical padding above/below code block content */ + private static final int CODE_VPAD = 6; + // ==================== RENDERING ==================== /** @@ -91,15 +100,19 @@ public class TokenHoverRenderer { // Cap visible height at 60% of viewport int totalContentHeight = calculateContentHeight(info, contentWidth); int maxVisibleContentHeight = (int)(viewportHeight * 0.60f); + if (totalContentHeight > maxVisibleContentHeight) { + // Scrollbar will appear, reducing effective wrap width by 6px — re-measure for accurate height + totalContentHeight = calculateContentHeight(info, contentWidth - 6); + } int visibleContentHeight = Math.min(totalContentHeight, maxVisibleContentHeight); // Box dimensions — use visibleContentHeight so positioning matches rendering int boxWidth = contentWidth + PADDING * 2; int boxHeight = visibleContentHeight + PADDING * 2; - // Position the tooltip - int tooltipX = tokenX; - int tooltipY = tokenY + lineHeight + VERTICAL_OFFSET; + // Position the tooltip — use dragged position if user has panned the panel + int tooltipX = hoverState.hasOverriddenPosition() ? hoverState.getOverriddenTooltipX() : tokenX; + int tooltipY = hoverState.hasOverriddenPosition() ? hoverState.getOverriddenTooltipY() : tokenY + lineHeight + VERTICAL_OFFSET; // Clamp X position to viewport int rightBound = viewportX + viewportWidth; @@ -119,18 +132,28 @@ public class TokenHoverRenderer { // Clamp Y position to viewport int bottomBound = viewportY + viewportHeight; if (tooltipY + boxHeight > bottomBound) { - // Try rendering above the token - tooltipY = tokenY - boxHeight - VERTICAL_OFFSET; + if (hoverState.hasOverriddenPosition()) { + tooltipY = bottomBound - boxHeight; // clamp dragged panel to bottom edge + } else { + tooltipY = tokenY - boxHeight - VERTICAL_OFFSET; // try above token + } } - // If still doesn't fit above, clamp to viewport top if (tooltipY < viewportY) { tooltipY = viewportY; } - - // Cover the gap between token bottom and tooltip top so mouse can travel through it - // without dismissing the tooltip (tokenY + lineHeight ... tooltipY) - int boundsTopY = Math.min(tokenY + lineHeight, tooltipY); - hoverState.setTooltipBounds(tooltipX, boundsTopY, boxWidth, tooltipY - boundsTopY + boxHeight); + + // Record actual panel rect for drag hit-testing + hoverState.setTooltipPanel(tooltipX, tooltipY, boxWidth, boxHeight); + + // Mouse-over detection bounds: + // When dragged, use panel rect only — no gap extension (token is unrelated to panel position) + // When default, extend downward to cover the gap between token bottom and tooltip top + if (hoverState.hasOverriddenPosition()) { + hoverState.setTooltipBounds(tooltipX, tooltipY, boxWidth, boxHeight); + } else { + int boundsTopY = Math.min(tokenY + lineHeight, tooltipY); + hoverState.setTooltipBounds(tooltipX, boundsTopY, boxWidth, tooltipY - boundsTopY + boxHeight); + } // Render the tooltip renderTooltipBox(tooltipX, tooltipY, boxWidth, maxContentWidth, info, hoverState, @@ -157,8 +180,10 @@ public class TokenHoverRenderer { HoverState hoverState, int totalContentHeight, int visibleContentHeight) { boolean hasScrollbar = totalContentHeight > visibleContentHeight; - // Reserve 6px on right for scrollbar (3px bar + 3px gap) so text doesn't run under it - int effectiveWrapWidth = hasScrollbar ? wrapWidth - 6 : wrapWidth; + // Wrap at the actual box content area width, not the max allowed width. + // Using maxContentWidth here caused lines to never wrap at wide viewports (fullscreen). + int contentWrapWidth = boxWidth - PADDING * 2; + int effectiveWrapWidth = hasScrollbar ? contentWrapWidth - 6 : contentWrapWidth; int boxHeight = visibleContentHeight + PADDING * 2; @@ -180,7 +205,7 @@ public class TokenHoverRenderer { int sf = sr.getScaleFactor(); int clipX = (x + 1) * sf; int clipY = (sr.getScaledHeight() - (y + boxHeight - 1)) * sf; - int clipW = (boxWidth - 2) * sf; + int clipW = (hasScrollbar ? boxWidth - PADDING - 1 : boxWidth - 2) * sf; int clipH = (boxHeight - 2) * sf; GL11.glEnable(GL11.GL_SCISSOR_TEST); GL11.glScissor(clipX, clipY, clipW, clipH); @@ -254,12 +279,44 @@ public class TokenHoverRenderer { currentY += SEPARATOR_HEIGHT + SEPARATOR_SPACING; } - for (TokenHoverInfo.DocumentationLine docLine : jsDocLines) { - if (!docLine.isEmpty()) { + for (int di = 0; di < jsDocLines.size(); di++) { + TokenHoverInfo.DocumentationLine docLine = jsDocLines.get(di); + + if (docLine.isCodeBlockFirst) { + // Lookahead: measure full block height + int blockH = CODE_VPAD; + for (int k = di; k < jsDocLines.size() && jsDocLines.get(k).isCodeLine; k++) { + TokenHoverInfo.DocumentationLine cl = jsDocLines.get(k); + if (!cl.isEmpty()) { + blockH += calculateSegmentsHeight(effectiveWrapWidth - CODE_INDENT, cl.segments) + LINE_SPACING; + } else { + blockH += lineHeight / 2; + } + } + blockH -= LINE_SPACING * 2; // subtract trailing LINE_SPACING (present inside bg) + one more to visually balance top/bottom + blockH += CODE_VPAD; + // Draw background + Gui.drawRect(textX - 2, currentY - CODE_VPAD, + textX - 2 + effectiveWrapWidth + 4, currentY - CODE_VPAD + blockH, + CODE_BG_COLOR); + // Left accent bar + Gui.drawRect(textX - 2, currentY - CODE_VPAD, + textX, currentY - CODE_VPAD + blockH, + CODE_ACCENT_COLOR); + } + + if (docLine.isCodeLine) { + if (!docLine.isEmpty()) { + currentY = drawWrappedSegments(textX + CODE_INDENT, currentY, effectiveWrapWidth - CODE_INDENT, docLine.segments); + currentY += LINE_SPACING; + } else { + currentY += lineHeight / 2; + } + } else if (!docLine.isEmpty()) { currentY = drawWrappedSegments(textX, currentY, effectiveWrapWidth, docLine.segments); currentY += LINE_SPACING; } else { - // Empty line - just add spacing + // blank spacer line currentY += lineHeight / 2; } } @@ -287,7 +344,7 @@ public class TokenHoverRenderer { int scrollbarTrackHeight = visibleContentHeight; // Track Gui.drawRect(scrollbarX, scrollbarTrackTop, scrollbarX + 3, - scrollbarTrackTop + scrollbarTrackHeight, 0x40FFFFFF); + scrollbarTrackTop + scrollbarTrackHeight, 0x40888888); // Thumb float scrollRatio = (float) visibleContentHeight / totalContentHeight; int thumbHeight = Math.max(6, (int)(scrollbarTrackHeight * scrollRatio)); @@ -302,7 +359,7 @@ public class TokenHoverRenderer { int mouseY = hoverState.getLastMouseY(); boolean thumbActive = hoverState.isDraggingScrollbar() || hoverState.isMouseOverScrollbarThumb(mouseX, mouseY); - int thumbColor = thumbActive ? 0xFF808080 : 0xFFCCCCCC; + int thumbColor = thumbActive ? 0xFFCCCCCC : 0xFF808080; Gui.drawRect(scrollbarX, thumbY, scrollbarX + 3, thumbY + thumbHeight, thumbColor); } } @@ -529,7 +586,8 @@ public class TokenHoverRenderer { for (TokenHoverInfo.DocumentationLine docLine : jsDocLines) { if (!docLine.isEmpty()) { - totalHeight += calculateSegmentsHeight(contentWidth, docLine.segments); + int wrapW = docLine.isCodeLine ? contentWidth - CODE_INDENT : contentWidth; + totalHeight += calculateSegmentsHeight(wrapW, docLine.segments); totalHeight += LINE_SPACING; } else { totalHeight += lineHeight / 2; @@ -546,7 +604,7 @@ public class TokenHoverRenderer { } } - return Math.max(lineHeight, totalHeight); + return Math.max(lineHeight, totalHeight + LINE_SPACING * 2); // small bottom breathing room } /**