diff --git a/src/main/java/noppes/npcs/client/ClientProxy.java b/src/main/java/noppes/npcs/client/ClientProxy.java index 481e6ada8..71a5b7967 100644 --- a/src/main/java/noppes/npcs/client/ClientProxy.java +++ b/src/main/java/noppes/npcs/client/ClientProxy.java @@ -930,6 +930,28 @@ public class ClientProxy extends CommonProxy { return Minecraft.getMinecraft().fontRenderer.getStringWidth(text); } + /** + * Return the width of a string when rendered with the specified font style (bold/italic). + * + * @param text the string to measure + * @param fontStyle combination of {@link java.awt.Font#BOLD} and {@link java.awt.Font#ITALIC}, + * or {@link java.awt.Font#PLAIN} for normal style + * @return the width in pixels + */ + public int width(String text, int fontStyle) { + if (useCustomFont) + return textFont.getStringWidth(text, fontStyle); + if (fontStyle == java.awt.Font.PLAIN) + return Minecraft.getMinecraft().fontRenderer.getStringWidth(text); + StringBuilder sb = new StringBuilder(); + if ((fontStyle & java.awt.Font.BOLD) != 0) + sb.append('\u00A7').append('l'); + if ((fontStyle & java.awt.Font.ITALIC) != 0) + sb.append('\u00A7').append('o'); + sb.append(text); + return Minecraft.getMinecraft().fontRenderer.getStringWidth(sb.toString()); + } + public FontContainer copy() { FontContainer font = new FontContainer(); font.textFont = textFont; 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 a37f0b4b6..acf13e760 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiScriptTextArea.java @@ -557,9 +557,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { 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()))); + ScriptLine sl = container.getDocument() != null ? container.getDocument().getLine(cursorLine) : null; + if (sl != null) { + cursorCol = sl.getRenderedWidth(0, Math.min(cursorOffset, ld.text.length())); + } else { + cursorCol = ClientProxy.Font.width(ld.text.substring(0, Math.min(cursorOffset, ld.text.length()))); + } } int screenX = GuiScriptTextArea.this.x + LINE_NUMBER_GUTTER_WIDTH + 1 + cursorCol; @@ -861,13 +865,13 @@ public class GuiScriptTextArea extends GuiNpcTextField { //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; + int s = scriptLine.getRenderedWidth(0, startBracket - data.start); + int e = scriptLine.getRenderedWidth(0, startBracket - data.start + 1) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00); } if (endBracket >= data.start && endBracket < data.end) { - int s = ClientProxy.Font.width(line.substring(0, endBracket - data.start)); - int e = ClientProxy.Font.width(line.substring(0, endBracket - data.start + 1)) + 1; + int s = scriptLine.getRenderedWidth(0, endBracket - data.start); + int e = scriptLine.getRenderedWidth(0, endBracket - data.start + 1) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight + 0, 0x9900cc00); } } @@ -877,8 +881,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { 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; + int s = scriptLine.getRenderedWidth(0, rel); + int e = scriptLine.getRenderedWidth(0, rel + 1) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0xffcc0000); } } @@ -888,8 +892,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { Matcher m = container.regexWord.matcher(line); 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; + int s = scriptLine.getRenderedWidth(0, m.start()); + int e = scriptLine.getRenderedWidth(0, m.end()) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x99004c00); } } @@ -906,8 +910,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { 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; + int s = scriptLine.getRenderedWidth(0, matchStart); + int e = scriptLine.getRenderedWidth(0, matchEnd) + 1; boolean isExcluded = searchBar.isMatchExcluded(mi); // Current match gets brighter highlight, others get dimmer int highlightColor = (mi == currentMatchIdx) ? 0xBB4488ff : 0x662266aa; @@ -941,9 +945,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { boolean isEmpty = (occ[0] == occ[1]); if (occStart <= occEnd) { // Changed from < to <= to handle empty case - 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 s = scriptLine.getRenderedWidth(0, occStart); + int e = isEmpty ? s + 2 : scriptLine.getRenderedWidth( + 0, occEnd) + 1; // 2px wide for empty int occX = x + LINE_NUMBER_GUTTER_WIDTH + s; int occEndX = x + LINE_NUMBER_GUTTER_WIDTH + 2 + e; boolean isPrimary = renameHandler.isPrimaryOccurrence(occ[0]); @@ -975,7 +979,7 @@ public class GuiScriptTextArea extends GuiNpcTextField { 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); + int cursorX = occX + scriptLine.getRenderedWidth(occStart, occStart + beforeCursor.length()); // drawRect(cursorX, posY + 1, cursorX + 1, posY + container.lineHeight - 1, // 0xFFFFFFFF); } @@ -993,10 +997,10 @@ public class GuiScriptTextArea extends GuiNpcTextField { // 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; + int s = scriptLine.getRenderedWidth( + 0, Math.max(selection.getStartSelection() - data.start, 0)); + int e = scriptLine.getRenderedWidth( + 0, Math.min(selection.getEndSelection() - data.start, w)) + 1; drawRect(x + LINE_NUMBER_GUTTER_WIDTH + 1 + s, posY, x + LINE_NUMBER_GUTTER_WIDTH + 1 + e, posY + container.lineHeight, 0x992172ff); } } @@ -1051,8 +1055,8 @@ public class GuiScriptTextArea extends GuiNpcTextField { // Draw cursor: pause blinking while user is active recently boolean recentInput = selection.hadRecentInput(); if (active && isEnabled() && (recentInput || (cursorCounter / 10) % 2 == 0) && (selection.getCursorPosition() >= data.start && selection.getCursorPosition() < data.end || (i == list.size() - 1 && selection.getCursorPosition() == text.length()))) { - int posX = x + LINE_NUMBER_GUTTER_WIDTH + ClientProxy.Font.width( - line.substring(0, Math.min(selection.getCursorPosition() - data.start, line.length()))); + int posX = x + LINE_NUMBER_GUTTER_WIDTH + scriptLine.getRenderedWidth( + 0, Math.min(selection.getCursorPosition() - data.start, line.length())); drawRect(posX + 1, posY, posX + 2, posY + container.lineHeight, ScriptColorScheme.getBackgroundStyle().getCaretColor()); } } @@ -1144,11 +1148,14 @@ public class GuiScriptTextArea extends GuiNpcTextField { if (i >= scroll.getScrolledLine() && i <= scroll.getScrolledLine() + this.container.visibleLines +1) { double yPos = (i - scroll.getScrolledLine()) * this.container.lineHeight; if (yMouseD >= yPos && yMouseD < yPos + this.container.lineHeight) { + ScriptLine scriptLine = container.getDocument() != null ? container.getDocument().getLine(i) : null; int lineWidth = 0; char[] chars = data.text.toCharArray(); for (int j = 1; j <= chars.length; ++j) { - int w = ClientProxy.Font.width(data.text.substring(0, j)); + int w = scriptLine != null + ? scriptLine.getRenderedWidth(0, j) + : ClientProxy.Font.width(data.text.substring(0, j)); if (xMouse < lineWidth + (w - lineWidth) / 2) { return data.start + j - 1; } @@ -1226,9 +1233,9 @@ public class GuiScriptTextArea extends GuiNpcTextField { tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length())); tokenLocalEnd = Math.max(0, Math.min(tokenLocalEnd, lineText.length())); - int tokenScreenX = viewportX + ClientProxy.Font.width(lineText.substring(0, tokenLocalStart)); + int tokenScreenX = viewportX + lineData.getRenderedWidth(0, tokenLocalStart); int tokenScreenY = y + (lineIdx - scroll.getScrolledLine()) * container.lineHeight - (int)fracPixels; - int tokenWidth = ClientProxy.Font.width(lineText.substring(tokenLocalStart, tokenLocalEnd)); + int tokenWidth = lineData.getRenderedWidth(tokenLocalStart, tokenLocalEnd); return new Object[] { token, tokenScreenX, tokenScreenY, tokenWidth }; } diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java index e56e08c7f..bec08df8b 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ErrorUnderlineRenderer.java @@ -44,19 +44,32 @@ public class ErrorUnderlineRenderer { ScriptDocument doc, int lineStartX, int baselineY, String lineText, int lineStart, int lineEnd) { + drawErrorUnderlines(doc, null, lineStartX, baselineY, lineText, lineStart, lineEnd); + } + + /** + * Draw error underlines for all validation errors in the document that intersect the given line. + * When a ScriptLine is provided, uses style-aware width calculation so underlines align + * correctly under bold/italic tokens. + */ + public static void drawErrorUnderlines( + ScriptDocument doc, ScriptLine line, + int lineStartX, int baselineY, + String lineText, int lineStart, int lineEnd) { if (doc == null) return; for (DocumentError error : doc.getErrors()) { drawUnderlineForSpan(error.getStartPos(), error.getEndPos(), - lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR); + lineStartX, baselineY, lineText, lineStart, lineEnd, ERROR_COLOR, line); } } /** * Calculate underline position for a span of text within a line. * Handles clipping to line boundaries and pixel width calculation. + * When a ScriptLine is provided, uses style-aware width calculation. * * @param spanStart Global offset where the span starts * @param spanEnd Global offset where the span ends @@ -64,11 +77,13 @@ public class ErrorUnderlineRenderer { * @param lineText The text content of the line * @param lineStart Global offset where this line starts * @param lineEnd Global offset where this line ends + * @param line Optional ScriptLine for style-aware width; null falls back to plain width * @return UnderlinePosition with x and width, or null if span doesn't intersect line */ private static UnderlinePosition calculateUnderlinePosition( int spanStart, int spanEnd, - int lineStartX, String lineText, int lineStart, int lineEnd) { + int lineStartX, String lineText, int lineStart, int lineEnd, + ScriptLine line) { // Skip if span doesn't intersect this line if (spanEnd < lineStart || spanStart > lineEnd) @@ -89,18 +104,24 @@ public class ErrorUnderlineRenderer { if (lineLocalStart < 0 || lineLocalStart >= lineText.length()) return null; - // Compute pixel position - String beforeSpan = lineText.substring(0, lineLocalStart); - int beforeWidth = ClientProxy.Font.width(beforeSpan); - + // Compute pixel position using style-aware width when available + int beforeWidth; int spanWidth; - if (lineLocalEnd > lineText.length()) { - // Span extends past line end - spanWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + + if (line != null) { + beforeWidth = line.getRenderedWidth(0, lineLocalStart); + int clampedEnd = Math.min(lineLocalEnd, lineText.length()); + spanWidth = line.getRenderedWidth(lineLocalStart, clampedEnd); } else { - // Span is fully on this line (or clipped) - String spanTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); - spanWidth = ClientProxy.Font.width(spanTextOnLine); + String beforeSpan = lineText.substring(0, lineLocalStart); + beforeWidth = ClientProxy.Font.width(beforeSpan); + + if (lineLocalEnd > lineText.length()) { + spanWidth = ClientProxy.Font.width(lineText.substring(lineLocalStart)); + } else { + String spanTextOnLine = lineText.substring(lineLocalStart, lineLocalEnd); + spanWidth = ClientProxy.Font.width(spanTextOnLine); + } } return new UnderlinePosition(lineStartX + beforeWidth, spanWidth); @@ -123,9 +144,22 @@ public class ErrorUnderlineRenderer { int lineStartX, int baselineY, String lineText, int lineStart, int lineEnd, int color) { + drawUnderlineForSpan(spanStart, spanEnd, lineStartX, baselineY, + lineText, lineStart, lineEnd, color, null); + } + + /** + * Draw an underline for a simple span if it intersects the line. + * When a ScriptLine is provided, uses style-aware width calculation. + */ + public static void drawUnderlineForSpan( + int spanStart, int spanEnd, + int lineStartX, int baselineY, + String lineText, int lineStart, int lineEnd, + int color, ScriptLine line) { UnderlinePosition pos = calculateUnderlinePosition( - spanStart, spanEnd, lineStartX, lineText, lineStart, lineEnd); + spanStart, spanEnd, lineStartX, lineText, lineStart, lineEnd, line); if (pos != null && pos.isValid()) { drawCurlyUnderline(pos.x, baselineY, pos.width, color); diff --git a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java index 983018edd..1265aa582 100644 --- a/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java +++ b/src/main/java/noppes/npcs/client/gui/util/script/interpreter/ScriptLine.java @@ -132,15 +132,22 @@ public class ScriptLine { clearTokens(); int cursor = globalStart; + /* + * globalEnd includes the newline character position (lineEnd = lineStart + text.length() + 1), + * but the line's text field does NOT include the newline. We must clamp all token boundaries + * to the displayable text end so that '\n' never leaks into rendered token text. + */ + int displayEnd = Math.min(globalStart + text.length(), fullText.length()); + for (Mark mark : marks) { - // Skip marks that don't overlap this line - if (mark.end <= globalStart || mark.start >= globalEnd) { + // Skip marks that don't overlap the displayable portion of this line + if (mark.end <= globalStart || mark.start >= displayEnd) { continue; } - // Clamp mark to line boundaries + // Clamp mark to displayable line boundaries (excludes trailing newline) int tokenStart = Math.max(mark.start, globalStart); - int tokenEnd = Math.min(mark.end, globalEnd); + int tokenEnd = Math.min(mark.end, displayEnd); // Validate bounds tokenStart = Math.max(0, Math.min(tokenStart, fullText.length())); @@ -168,13 +175,10 @@ public class ScriptLine { cursor = tokenEnd; } - // Add trailing default token if needed - if (cursor < globalEnd) { - int end = Math.min(globalEnd, fullText.length()); - if (cursor < end) { - String trailingText = fullText.substring(cursor, end); - addToken(Token.defaultToken(trailingText, cursor, end)); - } + // Add trailing default token if needed (clamped to displayable text, excluding newline) + if (cursor < displayEnd) { + String trailingText = fullText.substring(cursor, displayEnd); + addToken(Token.defaultToken(trailingText, cursor, displayEnd)); } } @@ -183,12 +187,7 @@ public class ScriptLine { token.setTypeInfo((TypeInfo) metadata); } else if (metadata instanceof TypeParamInfo) { TypeParamInfo typeParam = (TypeParamInfo) metadata; - TypeInfo boundType = typeParam.getBoundTypeInfo(); - if (boundType != null) { - token.setTypeInfo(TypeInfo.typeParameter(typeParam.getName(), boundType)); - } else { - token.setTypeInfo(TypeInfo.typeParameter(typeParam.getName())); - } + token.setTypeInfo(TypeInfo.typeParameter(typeParam.getName(), typeParam)); } else if (metadata instanceof MethodCallInfo) { MethodCallInfo callInfo = (MethodCallInfo) metadata; if (callInfo.isConstructor()) { @@ -234,6 +233,59 @@ public class ScriptLine { // ==================== RENDERING ==================== + /** + * Compute the rendered pixel width of a substring of this line, accounting for + * bold/italic token styles. Characters covered by bold or italic tokens will be + * measured with the appropriate font style, producing the same widths as the + * actual rendered output. + * + * @param localStart start index within the line text (inclusive) + * @param localEnd end index within the line text (exclusive) + * @return pixel width matching the rendered output + */ + public int getRenderedWidth(int localStart, int localEnd) { + localStart = Math.max(0, Math.min(localStart, text.length())); + localEnd = Math.max(localStart, Math.min(localEnd, text.length())); + if (localStart >= localEnd) return 0; + + int width = 0; + int cursor = localStart; + + for (Token t : tokens) { + if (cursor >= localEnd) break; + + int tokenLocalStart = t.getGlobalStart() - globalStart; + int tokenLocalEnd = tokenLocalStart + t.getText().length(); + + // Gap before this token (rendered as plain text) + if (cursor < tokenLocalStart) { + int gapEnd = Math.min(tokenLocalStart, localEnd); + if (gapEnd > cursor) { + width += ClientProxy.Font.width(text.substring(cursor, gapEnd)); + cursor = gapEnd; + } + } + + if (cursor >= localEnd) break; + + // Token overlap with [localStart, localEnd) + int overlapStart = Math.max(cursor, tokenLocalStart); + int overlapEnd = Math.min(tokenLocalEnd, localEnd); + if (overlapEnd > overlapStart) { + String substr = text.substring(overlapStart, overlapEnd); + width += ClientProxy.Font.width(substr, t.getFontStyle()); + cursor = overlapEnd; + } + } + + // Trailing text after last token (plain) + if (cursor < localEnd) { + width += ClientProxy.Font.width(text.substring(cursor, localEnd)); + } + + return width; + } + /** * Draw this line with syntax highlighting using Minecraft color codes. * Compatible with the existing rendering system. @@ -249,7 +301,7 @@ public class ScriptLine { for (Token t : tokens) { int tokenStart = t.getGlobalStart() - globalStart; // relative position in line - int tokenWidth = ClientProxy.Font.width(t.getText()); + int tokenWidth = ClientProxy.Font.width(t.getText(), t.getFontStyle()); // Calculate gap width before this token if (tokenStart > lastIndex && tokenStart <= text.length()) { @@ -293,12 +345,9 @@ public class ScriptLine { * Also draws wavy underlines for tokens with errors. */ public void drawStringHex(int x, int y) { - // Build the complete text with all tokens and gaps, draw it as ONE string - // to match the spacing behavior of drawString which draws everything at once - StringBuilder fullText = new StringBuilder(); int lastIndex = 0; - - // Build segments with token position info + + // Build segments with token position info and compute positions using style-aware widths java.util.List segments = new java.util.ArrayList<>(); for (Token t : tokens) { @@ -307,37 +356,29 @@ public class ScriptLine { // Add any gap before this token if (tokenStart > lastIndex && tokenStart <= text.length()) { String gap = text.substring(lastIndex, tokenStart); - segments.add(new TextSegment(fullText.length(), gap, 0xFFFFFFFF, false)); - fullText.append(gap); + segments.add(new TextSegment(lastIndex, gap, 0xFFFFFFFF, false)); } - // Add the colored token + // Add the colored token (with style prefix for rendering) String styledText = t.getStylePrefix() + t.getText(); - segments.add(new TextSegment(fullText.length(), styledText, t.getHexColor(), true)); - fullText.append(styledText); - - lastIndex = tokenStart + styledText.length(); + segments.add(new TextSegment(tokenStart, styledText, t.getHexColor(), true)); + + lastIndex = tokenStart + t.getText().length(); } // Add any remaining text after the last token if (lastIndex < text.length()) { String remaining = text.substring(lastIndex); - segments.add(new TextSegment(fullText.length(), remaining, 0xFFFFFFFF, false)); - fullText.append(remaining); + segments.add(new TextSegment(lastIndex, remaining, 0xFFFFFFFF, false)); } - // Draw each segment at the correct position - // Calculate positions based on the full string to match drawString's spacing + // Draw each segment at the correct position using style-aware width for (TextSegment seg : segments) { if (!seg.text.isEmpty()) { - // Get the width of everything before this segment - String prefix = fullText.substring(0, seg.startPos); - int prefixWidth = ClientProxy.Font.width(prefix); - int color = seg.color; + int prefixWidth = getRenderedWidth(0, seg.startPos); + int color = seg.color; - // Draw this segment at the correct position ClientProxy.Font.drawString(seg.text, x + prefixWidth, y, color); - // Minecraft.getMinecraft().fontRenderer.drawString(seg.text, x + prefixWidth, y, color); } } @@ -354,6 +395,7 @@ public class ScriptLine { ErrorUnderlineRenderer.drawErrorUnderlines( parent, + this, lineStartX, baselineY, getText(), 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 7618561c5..2aaf8eed4 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 @@ -302,6 +302,7 @@ public class HoverState { /** * Get the character index within a line at the given X pixel position. + * Uses style-aware width calculation to account for bold/italic tokens. */ private int getCharacterIndexAtX(ScriptLine line, int x) { String text = line.getText(); @@ -309,7 +310,7 @@ public class HoverState { int accumWidth = 0; for (int i = 0; i < text.length(); i++) { - int charWidth = noppes.npcs.client.ClientProxy.Font.width(String.valueOf(text.charAt(i))); + int charWidth = line.getRenderedWidth(i, i + 1); if (accumWidth + charWidth / 2 > x) { return i; } @@ -324,20 +325,19 @@ public class HoverState { private void calculateTokenPosition(ScriptLine line, Token token, int viewportX, int viewportY, float scrollOffset, int lineHeight) { - // X position: calculate width of text before the token - String lineText = line.getText(); + // X position: calculate width of text before the token (style-aware) int tokenLocalStart = token.getGlobalStart() - line.getGlobalStart(); - tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, lineText.length())); + tokenLocalStart = Math.max(0, Math.min(tokenLocalStart, line.getText().length())); - String textBefore = lineText.substring(0, tokenLocalStart); - tokenScreenX = viewportX + noppes.npcs.client.ClientProxy.Font.width(textBefore); + tokenScreenX = viewportX + line.getRenderedWidth(0, tokenLocalStart); // Y position: line position minus scroll int lineY = line.getLineIndex(); tokenScreenY = viewportY + (int) ((lineY - scrollOffset) * lineHeight); - // Token width - tokenWidth = noppes.npcs.client.ClientProxy.Font.width(token.getText()); + // Token width (style-aware) + int tokenLocalEnd = tokenLocalStart + token.getText().length(); + tokenWidth = line.getRenderedWidth(tokenLocalStart, tokenLocalEnd); } public void setTooltipBounds(int x, int y, int width, int height) { diff --git a/src/main/java/noppes/npcs/config/StringCache.java b/src/main/java/noppes/npcs/config/StringCache.java index e8d67ee32..dcf3ecadb 100644 --- a/src/main/java/noppes/npcs/config/StringCache.java +++ b/src/main/java/noppes/npcs/config/StringCache.java @@ -762,6 +762,43 @@ public class StringCache { return (int) (entry.advance * renderScale()); } + /** + * Return the width of a string in pixels when rendered with the specified font style. + * This is needed when measuring text that will be rendered with bold/italic formatting, + * since bold glyphs are wider than plain glyphs. + * + *

The font style is applied by prepending the appropriate Minecraft formatting codes + * (§l for bold, §o for italic) to the string before measuring. This causes cacheString() + * to lay out the entire string using the specified font style. + * + * @param str compute the width of this string + * @param fontStyle combination of {@link java.awt.Font#BOLD} and {@link java.awt.Font#ITALIC} + * @return the width in pixels (in the scaled GUI coordinate system) + */ + public int getStringWidth(String str, int fontStyle) { + /* Check for invalid arguments */ + if (str == null || str.isEmpty()) { + return 0; + } + + /* If plain style, delegate to the normal method (no prefix needed) */ + if (fontStyle == Font.PLAIN) { + return getStringWidth(str); + } + + /* Build the style prefix: §l for bold, §o for italic */ + StringBuilder sb = new StringBuilder(); + if ((fontStyle & Font.BOLD) != 0) { + sb.append('\u00A7').append('l'); + } + if ((fontStyle & Font.ITALIC) != 0) { + sb.append('\u00A7').append('o'); + } + sb.append(str); + + return getStringWidth(sb.toString()); + } + /** * Return the number of characters in a string that will completly fit inside the specified width when rendered, with * or without prefering to break the line at whitespace instead of breaking in the middle of a word. This private provides