From 58f86058d4255024bb6d80e633d1d13ddd06572d Mon Sep 17 00:00:00 2001 From: bigguy345 Date: Wed, 4 Mar 2026 22:54:38 +0200 Subject: [PATCH] feat(hover): scrollbar drag/hover-highlight, smooth scroll, compact blank-line spacing - HoverState: add smooth-scroll lerp (updateSmoothScroll, targetScrollOffset), scrollbar thumb bounds (setScrollbarThumb), click/drag/release state methods, and fix broken scrollTooltip/getTooltipScrollOffset/resetTooltipScroll methods that referenced the deleted int field - TokenHoverRenderer: call updateSmoothScroll each frame; store thumb bounds for drag+hover detection; highlight thumb on hover/drag; reduce empty DocumentationLine height from lineHeight+LINE_SPACING to lineHeight/2 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../client/gui/util/GuiScriptTextArea.java | 28 +++- .../script/interpreter/hover/HoverState.java | 120 +++++++++++++++++- .../interpreter/hover/TokenHoverRenderer.java | 103 +++++++++++---- 3 files changed, 224 insertions(+), 27 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 37a26b577..d6a7d4626 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -623,19 +623,35 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (listener instanceof GuiNPCInterface) { ((GuiNPCInterface) listener).mouseScroll = wheelDelta; + // Intercept wheel scroll for tooltip (highest priority) + if (wheelDelta != 0 && hoverState.isTooltipVisible() && hoverState.isMouseOverTooltip(xMouse, yMouse)) { + hoverState.scrollTooltip(wheelDelta); + wheelDelta = 0; + } + // 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) + if (wheelDelta != 0 && canScroll) { + int previousScrollLine = scroll.getScrolledLine(); scroll.applyWheelScroll(wheelDelta, maxScroll); + if (scroll.getScrolledLine() != previousScrollLine) { + hoverState.clearHover(); + } } } + } // Handle scrollbar dragging (delegated to ScrollState) - if (scroll.isClickScrolling()) + if (scroll.isClickScrolling()) { + int prevLine = scroll.getScrolledLine(); scroll.handleClickScrolling(yMouse, x, y, height, container.visibleLines, getPaddedLineCount(), maxScroll); + if (scroll.getScrolledLine() != prevLine) { + hoverState.clearHover(); + } + } // Update scroll animation scroll.initializeIfNeeded(scroll.getScrolledLine()); @@ -1171,6 +1187,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { * Called every frame from drawTextBox. */ private void updateHoverState(int xMouse, int yMouse) { + // 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)) { + return; + } // Don't show tooltips when not active, clicking, or when overlays are visible if (!isEnabled() || clicked || searchBar.isVisible() || goToLineDialog.isVisible() || KEYS_OVERLAY.isVisible() || renameHandler.isActive() || autocompleteManager.isVisible()) { @@ -1189,6 +1209,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { hoverState.update(xMouse, yMouse, token, tokenScreenX, tokenScreenY, tokenWidth); } else { + // If mouse is over the tooltip box, keep tooltip visible — do not clear + if (hoverState.isMouseOverTooltip(xMouse, yMouse)) { + return; + } hoverState.update(xMouse, yMouse, null, 0, 0, 0); } } 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 9a0afe286..18e50291d 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 @@ -43,7 +43,29 @@ public class HoverState { private int tokenScreenX; private int tokenScreenY; private int tokenWidth; - + + /** Tooltip bounding box (set after each render frame, used for mouse-over detection) */ + private int tooltipBoxX, tooltipBoxY, tooltipBoxWidth, tooltipBoxHeight; + + /** Also store the actual tooltip panel rect (not including the token-gap extension) */ + private int tooltipPanelX, tooltipPanelY, tooltipPanelW, tooltipPanelH; + + /** Scrollbar thumb rect (set each render frame, used for drag/hover detection) */ + private int scrollbarThumbX, scrollbarThumbY, scrollbarThumbH; + private int scrollbarX; + private int scrollbarTrackTop; + private int scrollbarTrackHeight; + private boolean isDraggingScrollbar; + private int dragStartMouseY; + private int dragStartScrollOffset; + + /** Smooth scroll: target offset (pixels), current is animated toward it each frame */ + private float targetScrollOffset; + /** Vertical scroll offset for tooltip content (pixels) */ + private float tooltipScrollOffsetF; + + /** Maximum scroll offset = totalContentHeight - visibleContentHeight (pixels) */ + private int tooltipMaxScroll; /** Pinned token (set when user clicks a token to keep tooltip open) */ private Token pinnedToken; private TokenHoverInfo pinnedHoverInfo; @@ -125,6 +147,7 @@ public class HoverState { tooltipVisible = false; hoverInfo = null; + resetTooltipScroll(); // Calculate token screen position for tooltip calculateTokenPosition(line, token, viewportX, viewportY, scrollOffset, lineHeight); } else { @@ -173,6 +196,7 @@ public class HoverState { tooltipVisible = false; hoverInfo = null; + resetTooltipScroll(); // Store token position tokenScreenX = tokenX; tokenScreenY = tokenY; @@ -198,6 +222,12 @@ public class HoverState { if (pinnedToken == null) { tooltipVisible = false; hoverInfo = null; + tooltipBoxX = tooltipBoxY = tooltipBoxWidth = tooltipBoxHeight = 0; + tooltipPanelX = tooltipPanelY = tooltipPanelW = tooltipPanelH = 0; + tooltipScrollOffsetF = 0; + targetScrollOffset = 0; + tooltipMaxScroll = 0; + isDraggingScrollbar = false; } } } @@ -289,6 +319,94 @@ public class HoverState { tokenWidth = noppes.npcs.client.ClientProxy.Font.width(token.getText()); } + public void setTooltipBounds(int x, int y, int width, int height) { + tooltipBoxX = x; + tooltipBoxY = y; + tooltipBoxWidth = width; + tooltipBoxHeight = height; + } + + public void setTooltipMaxScroll(int maxScroll) { + this.tooltipMaxScroll = Math.max(0, maxScroll); + } + + public boolean isMouseOverTooltip(int mouseX, int mouseY) { + if (!tooltipVisible || tooltipBoxWidth <= 0) return false; + return mouseX >= tooltipBoxX && mouseX <= tooltipBoxX + tooltipBoxWidth + && mouseY >= tooltipBoxY && mouseY <= tooltipBoxY + tooltipBoxHeight; + } + + public void scrollTooltip(int wheelDelta) { + // LWJGL wheelDelta: positive = scrolled up (content should move up = offset increases) + // Divide by 2 for sensitivity; negate to match expected direction + targetScrollOffset = Math.max(0, Math.min(tooltipMaxScroll, targetScrollOffset - wheelDelta / 5.0f)); + } + + public int getTooltipScrollOffset() { + return (int) tooltipScrollOffsetF; + } + + public void resetTooltipScroll() { + tooltipScrollOffsetF = 0; + targetScrollOffset = 0; + } + + /** Lerp current offset toward target. Call every frame while tooltip is visible. */ + public void updateSmoothScroll() { + float diff = targetScrollOffset - tooltipScrollOffsetF; + if (Math.abs(diff) < 0.5f) { + tooltipScrollOffsetF = targetScrollOffset; + } else { + tooltipScrollOffsetF += diff * 0.04f; + } + } + + /** Called by renderer each frame to store thumb bounds for drag/hover detection. */ + public void setScrollbarThumb(int barX, int thumbY, int thumbH, int trackTop, int trackHeight) { + scrollbarX = barX; + scrollbarThumbX = barX; + scrollbarThumbY = thumbY; + scrollbarThumbH = thumbH; + scrollbarTrackTop = trackTop; + scrollbarTrackHeight = trackHeight; + } + + public boolean isMouseOverScrollbarThumb(int mx, int my) { + if (!tooltipVisible || scrollbarThumbH <= 0) return false; + return mx >= scrollbarX && mx <= scrollbarX + 3 + && my >= scrollbarThumbY && my <= scrollbarThumbY + scrollbarThumbH; + } + + public void startScrollbarDrag(int mouseY) { + isDraggingScrollbar = true; + dragStartMouseY = mouseY; + dragStartScrollOffset = (int) tooltipScrollOffsetF; + } + + public void updateScrollbarDrag(int mouseY) { + if (!isDraggingScrollbar || scrollbarTrackHeight <= 0 || tooltipMaxScroll <= 0) return; + float scrollRatio = (float) (scrollbarTrackHeight) / Math.max(1, scrollbarTrackHeight + tooltipMaxScroll); + int thumbH = Math.max(6, (int)(scrollbarTrackHeight * scrollRatio)); + int effectiveTrack = scrollbarTrackHeight - thumbH; + if (effectiveTrack <= 0) return; + int deltaY = mouseY - dragStartMouseY; + float scrollDelta = (float) deltaY / effectiveTrack * tooltipMaxScroll; + targetScrollOffset = Math.max(0, Math.min(tooltipMaxScroll, dragStartScrollOffset + scrollDelta)); + tooltipScrollOffsetF = targetScrollOffset; // snap during drag + } + + public void releaseScrollbarDrag() { + isDraggingScrollbar = false; + } + + public boolean isDraggingScrollbar() { + return isDraggingScrollbar; + } + + public int getScrollbarThumbY() { return scrollbarThumbY; } + public int getScrollbarThumbH() { return scrollbarThumbH; } + public int getScrollbarX() { return scrollbarX; } + // ==================== GETTERS ==================== public boolean isTooltipVisible() { 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 7f6e34051..8d4ae5075 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 @@ -2,6 +2,7 @@ package noppes.npcs.client.gui.util.script.interpreter.hover; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.ScaledResolution; import noppes.npcs.client.ClientProxy; import org.lwjgl.opengl.GL11; @@ -72,6 +73,7 @@ public class TokenHoverRenderer { */ public static void render(HoverState hoverState, int viewportX, int viewportWidth, int viewportY, int viewportHeight) { if (!hoverState.isTooltipVisible()) return; + hoverState.updateSmoothScroll(); TokenHoverInfo info = hoverState.getHoverInfo(); if (info == null || !info.hasContent()) return; @@ -86,12 +88,14 @@ public class TokenHoverRenderer { // Calculate actual content width needed (clamped to max) int contentWidth = calculateContentWidth(info, maxContentWidth); - // Calculate height based on the actual content width (accounts for wrapping) - int contentHeight = calculateContentHeight(info, contentWidth); - - // Box dimensions + // Cap visible height at 60% of viewport + int totalContentHeight = calculateContentHeight(info, contentWidth); + int maxVisibleContentHeight = (int)(viewportHeight * 0.60f); + int visibleContentHeight = Math.min(totalContentHeight, maxVisibleContentHeight); + + // Box dimensions — use visibleContentHeight so positioning matches rendering int boxWidth = contentWidth + PADDING * 2; - int boxHeight = contentHeight + PADDING * 2; + int boxHeight = visibleContentHeight + PADDING * 2; // Position the tooltip int tooltipX = tokenX; @@ -122,9 +126,15 @@ public class TokenHoverRenderer { 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); - // Render the tooltip - use maxContentWidth for consistent wrapping - renderTooltipBox(tooltipX, tooltipY, boxWidth, maxContentWidth, info); + // Render the tooltip + renderTooltipBox(tooltipX, tooltipY, boxWidth, maxContentWidth, info, hoverState, + totalContentHeight, visibleContentHeight); } /** @@ -143,11 +153,18 @@ public class TokenHoverRenderer { /** * Render the tooltip box with all content. */ - private static void renderTooltipBox(int x, int y, int boxWidth, int wrapWidth, TokenHoverInfo info) { - GL11.glDisable(GL11.GL_SCISSOR_TEST); + private static void renderTooltipBox(int x, int y, int boxWidth, int wrapWidth, TokenHoverInfo info, + 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; + + int boxHeight = visibleContentHeight + PADDING * 2; + + // Store scroll metadata into HoverState + hoverState.setTooltipMaxScroll(Math.max(0, totalContentHeight - visibleContentHeight)); - int boxHeight = calculateContentHeight(info, wrapWidth) + PADDING * 2; - // Draw background Gui.drawRect(x, y, x + boxWidth, y + boxHeight, BG_COLOR); @@ -156,11 +173,21 @@ public class TokenHoverRenderer { Gui.drawRect(x, y + boxHeight - 1, x + boxWidth, y + boxHeight, BORDER_COLOR); Gui.drawRect(x, y, x + 1, y + boxHeight, BORDER_COLOR); Gui.drawRect(x + boxWidth - 1, y, x + boxWidth, y + boxHeight, BORDER_COLOR); - - int textX = x + PADDING; - int currentY = y + PADDING; - int lineHeight = ClientProxy.Font.height(); + // Set up GL scissor to clip scrollable content within the tooltip box (inside borders) + Minecraft mc = Minecraft.getMinecraft(); + ScaledResolution sr = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + int sf = sr.getScaleFactor(); + int clipX = (x + 1) * sf; + int clipY = (sr.getScaledHeight() - (y + boxHeight - 1)) * sf; + int clipW = (boxWidth - 2) * sf; + int clipH = (boxHeight - 2) * sf; + GL11.glEnable(GL11.GL_SCISSOR_TEST); + GL11.glScissor(clipX, clipY, clipW, clipH); + + int textX = x + PADDING; + int currentY = y + PADDING - hoverState.getTooltipScrollOffset(); + int lineHeight = ClientProxy.Font.height(); String packageName = info.getPackageName(); List declaration = info.getDeclaration(); @@ -171,7 +198,7 @@ public class TokenHoverRenderer { List errors = info.getErrors(); if (!errors.isEmpty()) { for (String error : errors) { - List wrappedLines = wrapText(error, wrapWidth); + List wrappedLines = wrapText(error, effectiveWrapWidth); for (String line : wrappedLines) { drawText(textX, currentY, line, ERROR_COLOR); currentY += lineHeight + LINE_SPACING; @@ -191,7 +218,7 @@ public class TokenHoverRenderer { // Draw package name if (packageName != null && !packageName.isEmpty()) { String packageText = "\u25CB " + packageName; - List wrappedLines = wrapText(packageText, wrapWidth); + List wrappedLines = wrapText(packageText, effectiveWrapWidth); for (String line : wrappedLines) { drawText(textX, currentY, line, PACKAGE_COLOR); currentY += lineHeight + LINE_SPACING; @@ -200,7 +227,7 @@ public class TokenHoverRenderer { // Draw declaration (colored segments with wrapping) if (!declaration.isEmpty()) { - currentY = drawWrappedSegments(textX, currentY, wrapWidth, declaration); + currentY = drawWrappedSegments(textX, currentY, effectiveWrapWidth, declaration); currentY += LINE_SPACING; } @@ -210,7 +237,7 @@ public class TokenHoverRenderer { Gui.drawRect(textX, currentY, x + boxWidth - PADDING, currentY + SEPARATOR_HEIGHT, BORDER_COLOR); currentY += SEPARATOR_HEIGHT + SEPARATOR_SPACING; for (String doc : docs) { - List wrappedLines = wrapText(doc, wrapWidth); + List wrappedLines = wrapText(doc, effectiveWrapWidth); for (String line : wrappedLines) { drawText(textX, currentY, line, DOC_COLOR); currentY += lineHeight + LINE_SPACING; @@ -229,27 +256,55 @@ public class TokenHoverRenderer { for (TokenHoverInfo.DocumentationLine docLine : jsDocLines) { if (!docLine.isEmpty()) { - currentY = drawWrappedSegments(textX, currentY, wrapWidth, docLine.segments); + currentY = drawWrappedSegments(textX, currentY, effectiveWrapWidth, docLine.segments); currentY += LINE_SPACING; } else { // Empty line - just add spacing - currentY += lineHeight + LINE_SPACING; + currentY += lineHeight / 2; } } } // Draw additional info - if (!additionalInfo.isEmpty()) { currentY += LINE_SPACING; for (String infoLine : additionalInfo) { - List wrappedLines = wrapText(infoLine, wrapWidth); + List wrappedLines = wrapText(infoLine, effectiveWrapWidth); for (String line : wrappedLines) { drawText(textX, currentY, line, INFO_COLOR); currentY += lineHeight + LINE_SPACING; } } } + + // Restore scissor state before drawing scrollbar (scrollbar must not be clipped) + GL11.glDisable(GL11.GL_SCISSOR_TEST); + + // Draw scrollbar outside scissor rect so it is never clipped + if (hasScrollbar) { + int scrollbarX = x + boxWidth - PADDING; + int scrollbarTrackTop = y + PADDING; + int scrollbarTrackHeight = visibleContentHeight; + // Track + Gui.drawRect(scrollbarX, scrollbarTrackTop, scrollbarX + 3, + scrollbarTrackTop + scrollbarTrackHeight, 0x40FFFFFF); + // Thumb + float scrollRatio = (float) visibleContentHeight / totalContentHeight; + int thumbHeight = Math.max(6, (int)(scrollbarTrackHeight * scrollRatio)); + int effectiveMaxScroll = totalContentHeight - visibleContentHeight; + float scrollProgress = effectiveMaxScroll > 0 + ? (float) hoverState.getTooltipScrollOffset() / effectiveMaxScroll : 0f; + int thumbY = scrollbarTrackTop + (int)((scrollbarTrackHeight - thumbHeight) * scrollProgress); + // Store thumb bounds in HoverState for drag/hover detection + hoverState.setScrollbarThumb(scrollbarX, thumbY, thumbHeight, scrollbarTrackTop, scrollbarTrackHeight); + // Highlight thumb when hovered or dragging (darker = active, like AutocompleteMenu) + int mouseX = hoverState.getLastMouseX(); + int mouseY = hoverState.getLastMouseY(); + boolean thumbActive = hoverState.isDraggingScrollbar() + || hoverState.isMouseOverScrollbarThumb(mouseX, mouseY); + int thumbColor = thumbActive ? 0xFF808080 : 0xFFCCCCCC; + Gui.drawRect(scrollbarX, thumbY, scrollbarX + 3, thumbY + thumbHeight, thumbColor); + } } /** @@ -477,7 +532,7 @@ public class TokenHoverRenderer { totalHeight += calculateSegmentsHeight(contentWidth, docLine.segments); totalHeight += LINE_SPACING; } else { - totalHeight += lineHeight + LINE_SPACING; + totalHeight += lineHeight / 2; } } }