diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java b/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java new file mode 100644 index 000000000..9f3b7d6a3 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java @@ -0,0 +1,30 @@ +package com.gtnewhorizons.angelica.client.font; + +/** + * Thread-local flags controlling how Angelica's font renderer interprets formatting codes. + */ +public final class AngelicaFontRenderContext { + + private static final ThreadLocal RAW_TEXT_DEPTH = ThreadLocal.withInitial(() -> 0); + + private AngelicaFontRenderContext() { + } + + public static void pushRawTextRendering() { + RAW_TEXT_DEPTH.set(RAW_TEXT_DEPTH.get() + 1); + } + + public static void popRawTextRendering() { + int depth = RAW_TEXT_DEPTH.get() - 1; + if (depth <= 0) { + RAW_TEXT_DEPTH.set(0); + } else { + RAW_TEXT_DEPTH.set(depth); + } + } + + public static boolean isRawTextRendering() { + return RAW_TEXT_DEPTH.get() > 0; + } + +} diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java index 6fed6313b..3ca74c32d 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java @@ -4,6 +4,7 @@ import com.gtnewhorizons.angelica.config.FontConfig; import com.gtnewhorizons.angelica.glsm.GLStateManager; import com.gtnewhorizons.angelica.mixins.interfaces.FontRendererAccessor; +import it.unimi.dsi.fastutil.ints.IntArrayList; import cpw.mods.fml.client.SplashProgress; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import net.coderbot.iris.gl.program.Program; @@ -345,10 +346,10 @@ private void flushBatch() { GL11.glDisableClientState(GL11.GL_VERTEX_ARRAY); if (isTextureEnabledBefore) { - GLStateManager.glEnable(GL11.GL_TEXTURE_2D); + GLStateManager.glEnable(GL11.GL_TEXTURE_2D); } if (textureChanged) { - GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, boundTextureBefore); + GLStateManager.glBindTexture(GL11.GL_TEXTURE_2D, boundTextureBefore); } // Clear for the next batch @@ -393,7 +394,7 @@ public float getShadowOffset() { private static final char FORMATTING_CHAR = 167; // § public float drawString(final float anchorX, final float anchorY, final int color, final boolean enableShadow, - final boolean unicodeFlag, final CharSequence string, int stringOffset, int stringLength) { + final boolean unicodeFlag, final CharSequence string, int stringOffset, int stringLength) { // noinspection SizeReplaceableByIsEmpty if (string == null || string.length() == 0) { return anchorX + (enableShadow ? 1.0f : 0.0f); @@ -414,6 +415,7 @@ public float drawString(final float anchorX, final float anchorY, final int colo } final int stringEnd = stringOffset + stringLength; + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); int curColor = color; int curShadowColor = shadowColor; boolean curItalic = false; @@ -421,6 +423,11 @@ public float drawString(final float anchorX, final float anchorY, final int colo boolean curBold = false; boolean curStrikethrough = false; boolean curUnderline = false; + boolean curRainbow = false; + boolean curDinnerbone = false; + int rainbowIndex = 0; + final IntArrayList colorStack = new IntArrayList(); + final IntArrayList shadowStack = new IntArrayList(); final float glyphScaleY = getGlyphScaleY(); final float heightNorth = anchorY + (underlying.FONT_HEIGHT - 1.0f) * (0.5f - glyphScaleY / 2); @@ -432,73 +439,239 @@ public float drawString(final float anchorX, final float anchorY, final int colo final float strikethroughY = heightNorth + ((float) (underlying.FONT_HEIGHT / 2) - 1.0f) * glyphScaleY; float strikethroughStartX = 0.0f; float strikethroughEndX = 0.0f; + int rawTokenSkip = 0; for (int charIdx = stringOffset; charIdx < stringEnd; charIdx++) { char chr = string.charAt(charIdx); - if (chr == FORMATTING_CHAR && (charIdx + 1) < stringEnd) { - final char fmtCode = Character.toLowerCase(string.charAt(charIdx + 1)); - charIdx++; - - if (curUnderline && underlineStartX != underlineEndX) { - final int ulIdx = idxWriterIndex; - pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); - pushDrawCmd(ulIdx, 6, null, false); - underlineStartX = underlineEndX; + boolean processedRgbOrTag = false; + + if (rawMode) { + if (rawTokenSkip > 0) { + rawTokenSkip--; + } else { + int tokenLen = ColorCodeUtils.detectColorCodeLengthIgnoringRaw(string, charIdx); + if (tokenLen > 0) { + float highlightWidth = angelica$measureLiteralWidth(string, charIdx, tokenLen, stringEnd, unicodeFlag, curBold); + if (highlightWidth > 0.0f) { + final int hlIdx = idxWriterIndex; + pushUntexRect(curX, heightNorth - 1.0f, highlightWidth, heightSouth + 2.0f, angelica$getTokenHighlightColor(string, charIdx)); + pushDrawCmd(hlIdx, 6, null, false); + } + rawTokenSkip = Math.max(tokenLen - 1, 0); + } } - if (curStrikethrough && strikethroughStartX != strikethroughEndX) { - final int ulIdx = idxWriterIndex; - pushUntexRect( - strikethroughStartX, - strikethroughY, - strikethroughEndX - strikethroughStartX, - glyphScaleY, - curColor); - pushDrawCmd(ulIdx, 6, null, false); - strikethroughStartX = strikethroughEndX; - } - - final boolean is09 = charInRange(fmtCode, '0', '9'); - final boolean isAF = charInRange(fmtCode, 'a', 'f'); - if (is09 || isAF) { - curRandom = false; - curBold = false; - curStrikethrough = false; - curUnderline = false; - curItalic = false; + } - final int colorIdx = is09 ? (fmtCode - '0') : (fmtCode - 'a' + 10); - final int rgb = this.colorCode[colorIdx]; + // Check for RGB color codes FIRST (before traditional § codes) + // Format: &RRGGBB (ampersand followed by 6 hex digits) + if (chr == '&' && (charIdx + 6) < stringEnd) { + final int rgb = ColorCodeUtils.parseHexColor(string, charIdx + 1); + if (rgb != -1) { + // Valid RGB color code found + if (curUnderline && underlineStartX != underlineEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + underlineStartX = underlineEndX; + } + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(strikethroughStartX, strikethroughY, strikethroughEndX - strikethroughStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + strikethroughStartX = strikethroughEndX; + } + + // Apply RGB color (preserve formatting state to allow &l&FFxxxx patterns) + colorStack.clear(); + shadowStack.clear(); curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); - final int shadowRgb = this.colorCode[colorIdx + 16]; - curShadowColor = (curShadowColor & 0xFF000000) | (shadowRgb & 0x00FFFFFF); - } else if (fmtCode == 'k') { - curRandom = true; - } else if (fmtCode == 'l') { - curBold = true; - } else if (fmtCode == 'm') { - curStrikethrough = true; - strikethroughStartX = curX - 1.0f; - strikethroughEndX = strikethroughStartX; - } else if (fmtCode == 'n') { - curUnderline = true; - underlineStartX = curX - 1.0f; - underlineEndX = underlineStartX; - } else if (fmtCode == 'o') { - curItalic = true; - } else if (fmtCode == 'r') { + curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + + // reset styles on color change (vanilla behavior) curRandom = false; curBold = false; curStrikethrough = false; curUnderline = false; curItalic = false; - curColor = color; - curShadowColor = shadowColor; + curRainbow = false; + curDinnerbone = false; + + processedRgbOrTag = true; // Prevent traditional &X from overwriting + + if (!rawMode) { + charIdx += 6; // Skip the 6 hex digits + continue; + } } + } - continue; + // Format: (opening tag) or (closing tag) + if (chr == '<') { + // Check for closing tag + if ((charIdx + 9) <= stringEnd && string.charAt(charIdx + 1) == '/' && string.charAt(charIdx + 8) == '>') { + if (ColorCodeUtils.isValidHexString(string, charIdx + 2)) { + // Valid closing tag - reset to original color + if (curUnderline && underlineStartX != underlineEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + underlineStartX = underlineEndX; + } + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(strikethroughStartX, strikethroughY, strikethroughEndX - strikethroughStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + strikethroughStartX = strikethroughEndX; + } + + if (!colorStack.isEmpty()) { + curColor = colorStack.removeInt(colorStack.size() - 1); + curShadowColor = shadowStack.removeInt(shadowStack.size() - 1); + } else { + curColor = color; + curShadowColor = shadowColor; + } + curRandom = false; + curRainbow = false; + processedRgbOrTag = true; + + if (!rawMode) { + charIdx += 8; // Skip (9 chars total, but loop will increment) + continue; + } + } + } + // Check for opening tag + else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { + final int rgb = ColorCodeUtils.parseHexColor(string, charIdx + 1); + if (rgb != -1) { + // Valid opening tag + if (curUnderline && underlineStartX != underlineEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + underlineStartX = underlineEndX; + } + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(strikethroughStartX, strikethroughY, strikethroughEndX - strikethroughStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + strikethroughStartX = strikethroughEndX; + } + + colorStack.add(curColor); + shadowStack.add(curShadowColor); + curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); + curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + + // reset styles on color change + curRandom = false; + curBold = false; + curStrikethrough = false; + curUnderline = false; + curItalic = false; + curRainbow = false; + curDinnerbone = false; + + processedRgbOrTag = true; + + if (!rawMode) { + charIdx += 7; // Skip (8 chars total, but loop will increment) + continue; + } + } + } } - if (curRandom) { + // Traditional & formatting codes (only if we didn't process RGB/tag code) + if (!processedRgbOrTag && (chr == FORMATTING_CHAR || chr == '&') && (charIdx + 1) < stringEnd) { + final char nextChar = string.charAt(charIdx + 1); + final char fmtCode = Character.toLowerCase(nextChar); + if (chr == '&' && !ColorCodeUtils.isFormattingCode(nextChar)) { + // Not a formatting alias, treat as literal '&' + } else { + charIdx++; + + if (curUnderline && underlineStartX != underlineEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + underlineStartX = underlineEndX; + } + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect( + strikethroughStartX, + strikethroughY, + strikethroughEndX - strikethroughStartX, + glyphScaleY, + curColor); + pushDrawCmd(ulIdx, 6, null, false); + strikethroughStartX = strikethroughEndX; + } + + final boolean is09 = charInRange(fmtCode, '0', '9'); + final boolean isAF = charInRange(fmtCode, 'a', 'f'); + if (is09 || isAF) { + final int colorIdx = is09 ? (fmtCode - '0') : (fmtCode - 'a' + 10); + final int rgb = this.colorCode[colorIdx]; + curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); + final int shadowRgb = this.colorCode[colorIdx + 16]; + curShadowColor = (curShadowColor & 0xFF000000) | (shadowRgb & 0x00FFFFFF); + + // vanilla resets styles on color + curRandom = false; + curBold = false; + curStrikethrough = false; + curUnderline = false; + curItalic = false; + curRainbow = false; + curDinnerbone = false; + } else if (fmtCode == 'k') { + curRandom = true; + } else if (fmtCode == 'l') { + curBold = true; + } else if (fmtCode == 'm') { + curStrikethrough = true; + strikethroughStartX = curX - 1.0f; + strikethroughEndX = strikethroughStartX; + } else if (fmtCode == 'n') { + curUnderline = true; + underlineStartX = curX - 1.0f; + underlineEndX = underlineStartX; + } else if (fmtCode == 'o') { + curItalic = true; + } else if (fmtCode == 'g') { + // Rainbow effect - cycles through all hues + curRainbow = true; + rainbowIndex = 0; + } else if (fmtCode == 'h') { + // Dinnerbone effect - renders text upside-down + curDinnerbone = true; + } else if (fmtCode == 'r') { + curRandom = false; + curBold = false; + curStrikethrough = false; + curUnderline = false; + curItalic = false; + curRainbow = false; + curDinnerbone = false; + rainbowIndex = 0; + curColor = color; + curShadowColor = shadowColor; + } + + if (!rawMode) { + continue; + } else { + // In raw mode, we still applied the formatting but need to back up charIdx + // so we render the formatting character + charIdx--; + } + } + } + + if (!rawMode && curRandom) { chr = FontProviderMC.get(this.isSGA).getRandomReplacement(chr); } @@ -520,42 +693,59 @@ public float drawString(final float anchorX, final float anchorY, final int colo final float shadowOffset = fontProvider.getShadowOffset(); final ResourceLocation texture = fontProvider.getTexture(chr); + // Apply rainbow color if enabled + if (curRainbow) { + float hue = (rainbowIndex * 15.0f) % 360.0f; + int rainbowRgb = ColorCodeUtils.hsvToRgb(hue, 1.0f, 1.0f); + curColor = (curColor & 0xFF000000) | (rainbowRgb & 0x00FFFFFF); + curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rainbowRgb); + rainbowIndex++; + } + + // Calculate V coordinates with dinnerbone flipping (flip texture only, keep Y position) + final float yTop = heightNorth; + final float yBottom = heightNorth + heightSouth; + final float vTop = curDinnerbone ? vStart + vSz : vStart; + final float vBottom = curDinnerbone ? vStart : vStart + vSz; + final float itOffTop = itOff; + final float itOffBottom = -itOff; + final int vtxId = vtxWriterIndex; final int idxId = idxWriterIndex; int vtxCount = 0; if (enableShadow) { - pushVtx(curX + itOff + shadowOffset, heightNorth + shadowOffset, curShadowColor, uStart, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX - itOff + shadowOffset, heightNorth + heightSouth + shadowOffset, curShadowColor, uStart, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX + glyphW - 1.0F + itOff + shadowOffset, heightNorth + shadowOffset, curShadowColor, uStart + uSz, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX + glyphW - 1.0F - itOff + shadowOffset, heightNorth + heightSouth + shadowOffset, curShadowColor, uStart + uSz, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + itOffTop + shadowOffset, yTop + shadowOffset, curShadowColor, uStart, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + itOffBottom + shadowOffset, yBottom + shadowOffset, curShadowColor, uStart, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + glyphW - 1.0F + itOffTop + shadowOffset, yTop + shadowOffset, curShadowColor, uStart + uSz, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + glyphW - 1.0F + itOffBottom + shadowOffset, yBottom + shadowOffset, curShadowColor, uStart + uSz, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); pushQuadIdx(vtxId + vtxCount); vtxCount += 4; if (curBold) { final float shadowOffset2 = 2.0f * shadowOffset; - pushVtx(curX + itOff + shadowOffset2, heightNorth + shadowOffset, curShadowColor, uStart, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX - itOff + shadowOffset2, heightNorth + heightSouth + shadowOffset, curShadowColor, uStart, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX + glyphW - 1.0F + itOff + shadowOffset2, heightNorth + shadowOffset, curShadowColor, uStart + uSz, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX + glyphW - 1.0F - itOff + shadowOffset2, heightNorth + heightSouth + shadowOffset, curShadowColor, uStart + uSz, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + itOffTop + shadowOffset2, yTop + shadowOffset, curShadowColor, uStart, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + itOffBottom + shadowOffset2, yBottom + shadowOffset, curShadowColor, uStart, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + glyphW - 1.0F + itOffTop + shadowOffset2, yTop + shadowOffset, curShadowColor, uStart + uSz, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + glyphW - 1.0F + itOffBottom + shadowOffset2, yBottom + shadowOffset, curShadowColor, uStart + uSz, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); pushQuadIdx(vtxId + vtxCount); vtxCount += 4; } } - pushVtx(curX + itOff, heightNorth, curColor, uStart, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX - itOff, heightNorth + heightSouth, curColor, uStart, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX + glyphW - 1.0F + itOff, heightNorth, curColor, uStart + uSz, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(curX + glyphW - 1.0F - itOff, heightNorth + heightSouth, curColor, uStart + uSz, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + itOffTop, yTop, curColor, uStart, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + itOffBottom, yBottom, curColor, uStart, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + glyphW - 1.0F + itOffTop, yTop, curColor, uStart + uSz, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(curX + glyphW - 1.0F + itOffBottom, yBottom, curColor, uStart + uSz, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); pushQuadIdx(vtxId + vtxCount); vtxCount += 4; if (curBold) { - pushVtx(shadowOffset + curX + itOff, heightNorth, curColor, uStart, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(shadowOffset + curX - itOff, heightNorth + heightSouth, curColor, uStart, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(shadowOffset + curX + glyphW - 1.0F + itOff, heightNorth, curColor, uStart + uSz, vStart, uStart, uStart + uSz, vStart, vStart + vSz); - pushVtx(shadowOffset + curX + glyphW - 1.0F - itOff, heightNorth + heightSouth, curColor, uStart + uSz, vStart + vSz, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(shadowOffset + curX + itOffTop, yTop, curColor, uStart, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(shadowOffset + curX + itOffBottom, yBottom, curColor, uStart, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(shadowOffset + curX + glyphW - 1.0F + itOffTop, yTop, curColor, uStart + uSz, vTop, uStart, uStart + uSz, vStart, vStart + vSz); + pushVtx(shadowOffset + curX + glyphW - 1.0F + itOffBottom, yBottom, curColor, uStart + uSz, vBottom, uStart, uStart + uSz, vStart, vStart + vSz); pushQuadIdx(vtxId + vtxCount); vtxCount += 4; } @@ -588,8 +778,66 @@ public float drawString(final float anchorX, final float anchorY, final int colo return curX + (enableShadow ? 1.0f : 0.0f); } + private float angelica$measureLiteralWidth(CharSequence string, int start, int tokenLength, int stringEnd, boolean unicodeFlag, boolean initialBoldState) { + float width = 0.0f; + boolean isBold = initialBoldState; + final int limit = Math.min(start + tokenLength, stringEnd); + + for (int i = start; i < limit; i++) { + char ch = string.charAt(i); + + // Check if this character is the start of a formatting code that affects bold + if ((ch == '&' || ch == FORMATTING_CHAR) && i + 1 < limit) { + char nextChar = string.charAt(i + 1); + char fmtCode = Character.toLowerCase(nextChar); + + // Check if it's a valid formatting code + if (ch == '&' && !ColorCodeUtils.isFormattingCode(nextChar)) { + // Not a valid formatting code, continue + } else if (fmtCode == 'l') { + isBold = true; + } else if (fmtCode == 'r') { + isBold = false; + } else if ((fmtCode >= '0' && fmtCode <= '9') || (fmtCode >= 'a' && fmtCode <= 'f')) { + // In Angelica, color codes don't reset bold (preserves formatting) + // So we keep isBold unchanged + } + } + + FontProvider provider = FontStrategist.getFontProvider(ch, this.isSGA, FontConfig.enableCustomFont, unicodeFlag); + float xAdvance = provider.getXAdvance(ch) * getGlyphScaleX(); + width += xAdvance; + if (isBold) { + width += this.getShadowOffset(); + } + width += getGlyphSpacing(); + } + return width; + } + + private int angelica$getTokenHighlightColor(CharSequence string, int index) { + char c = string.charAt(index); + if (c == FORMATTING_CHAR || (c == '&' && index + 1 < string.length() && ColorCodeUtils.isFormattingCode(string.charAt(index + 1)))) { + return 0x304080FF; + } + if (c == '&') { + return 0x3039C86F; + } + if (c == '<') { + if (index + 1 < string.length() && string.charAt(index + 1) == '/') { + return 0x30FF8C5A; + } + return 0x305A8CFF; + } + return 0x30222222; + } + public float getCharWidthFine(char chr) { - if (chr == FORMATTING_CHAR) { return -1; } + if (chr == FORMATTING_CHAR && !AngelicaFontRenderContext.isRawTextRendering()) { return -1; } + + // Note: We DO NOT return -1 for & or < here anymore + // Width calculation is handled properly in getStringWidthWithRgb() + // This allows & and < to render normally when they're not part of valid color codes if (chr == ' ' || chr == '\u00A0' || chr == '\u202F') { return 4 * this.getWhitespaceScale(); @@ -600,6 +848,25 @@ public float getCharWidthFine(char chr) { return fp.getXAdvance(chr) * this.getGlyphScaleX(); } + /** + * Calculate the width of a string, properly handling RGB color codes. + * This method correctly skips over: + * - Traditional § codes (2 chars) + * - &RRGGBB format (7 chars) + * - format (8 chars) + * - format (9 chars) + * + * @param str The string to measure + * @return The width in pixels + */ + public float getStringWidthWithRgb(CharSequence str) { + if (str == null || str.length() == 0) { + return 0.0f; + } + + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); + return FormattedTextMetrics.calculateMaxLineWidth(str, rawMode, this::getCharWidthFine, + getGlyphSpacing(), this.getShadowOffset()); public void overrideBlendFunc(int srcRgb, int dstRgb) { blendSrcRGB = srcRgb; blendDstRGB = dstRgb; diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java new file mode 100644 index 000000000..717764bdd --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -0,0 +1,314 @@ +package com.gtnewhorizons.angelica.client.font; + +import java.util.ArrayDeque; + +/** + * Utility class for parsing RGB color codes in text. + * Supports multiple formats: + * - Traditional: §0-9a-f (handled elsewhere) + * - Ampersand: &RRGGBB (6 hex digits) + * - Tag style: text + */ +public class ColorCodeUtils { + + /** + * Check if a character is a valid hexadecimal digit (0-9, a-f, A-F) + */ + public static boolean isValidHexChar(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + /** + * Check if a string contains exactly 6 valid hexadecimal characters + */ + public static boolean isValidHexString(String hex) { + if (hex == null || hex.length() != 6) { + return false; + } + for (int i = 0; i < 6; i++) { + if (!isValidHexChar(hex.charAt(i))) { + return false; + } + } + return true; + } + + /** + * Check if a character represents a traditional Minecraft formatting code (0-9, a-f, k-o, r) + * Also includes custom codes: g (rainbow), h (dinnerbone) + */ + public static boolean isFormattingCode(char c) { + char lower = Character.toLowerCase(c); + return (lower >= '0' && lower <= '9') + || (lower >= 'a' && lower <= 'f') + || lower == 'g' // rainbow + || lower == 'h' // dinnerbone + || (lower >= 'k' && lower <= 'o') + || lower == 'r'; + } + + /** + * Check if 6 characters starting at position are valid hex + */ + public static boolean isValidHexString(CharSequence str, int start) { + if (str == null || start < 0 || start + 6 > str.length()) { + return false; + } + for (int i = 0; i < 6; i++) { + if (!isValidHexChar(str.charAt(start + i))) { + return false; + } + } + return true; + } + + /** + * Parse a 6-digit hexadecimal string to an RGB integer (0xRRGGBB) + * @param hex String containing exactly 6 hex digits + * @return RGB value as integer, or -1 if invalid + */ + public static int parseHexColor(String hex) { + if (!isValidHexString(hex)) { + return -1; + } + try { + return Integer.parseInt(hex, 16) & 0x00FFFFFF; + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * Parse 6 hex characters from a CharSequence starting at position + * @param str The string to parse + * @param start Starting position + * @return RGB value as integer, or -1 if invalid + */ + public static int parseHexColor(CharSequence str, int start) { + if (!isValidHexString(str, start)) { + return -1; + } + try { + String hex = str.subSequence(start, start + 6).toString(); + return Integer.parseInt(hex, 16) & 0x00FFFFFF; + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return -1; + } + } + + /** + * Detect the length of a color code starting at position, or 0 if none. + * + * @param str The string to check + * @param pos Position to check + * @return Length of color code: + * - 7 for &RRGGBB format (& + 6 hex) + * - 8 for format (< + 6 hex + >) + * - 9 for format () + * - 2 for §X format (handled elsewhere, but counted here) + * - 0 for no color code + */ + public static int detectColorCodeLength(CharSequence str, int pos) { + return detectColorCodeLengthInternal(str, pos, AngelicaFontRenderContext.isRawTextRendering()); + } + + public static int detectColorCodeLengthIgnoringRaw(CharSequence str, int pos) { + return detectColorCodeLengthInternal(str, pos, false); + } + + private static int detectColorCodeLengthInternal(CharSequence str, int pos, boolean skipDueToRaw) { + if (str == null || pos < 0 || pos >= str.length()) { + return 0; + } + + if (skipDueToRaw) { + return 0; + } + + char c = str.charAt(pos); + + // Check for §X format (traditional Minecraft) + if (c == 167 && pos + 1 < str.length()) { // 167 is § + return 2; + } + + // Check for &RRGGBB format + if (c == '&' && pos + 7 <= str.length()) { + if (isValidHexString(str, pos + 1)) { + return 7; + } + } + + // Check for &X format (traditional formatting alias) + if (c == '&' && pos + 1 < str.length() && isFormattingCode(str.charAt(pos + 1))) { + return 2; + } + + // Check for format (closing tag) + if (c == '<' && pos + 9 <= str.length() && str.charAt(pos + 1) == '/' && str.charAt(pos + 8) == '>') { + if (isValidHexString(str, pos + 2)) { + return 9; + } + } + + // Check for format (opening tag) + if (c == '<' && pos + 8 <= str.length() && str.charAt(pos + 7) == '>') { + if (isValidHexString(str, pos + 1)) { + return 8; + } + } + + return 0; + } + + /** + * Calculate the shadow color for a given RGB color. + * Shadow is typically darker (divided by 4 per component). + * + * @param rgb The base RGB color (0xRRGGBB) + * @return Shadow RGB color (0xRRGGBB) + */ + public static int calculateShadowColor(int rgb) { + return (rgb & 0xFCFCFC) >> 2; + } + + /** + * Convert HSV (Hue, Saturation, Value) color to RGB. + * + * @param hue Hue in degrees (0-360) + * @param saturation Saturation (0.0-1.0) + * @param value Value/Brightness (0.0-1.0) + * @return RGB color as integer (0xRRGGBB) + */ + public static int hsvToRgb(float hue, float saturation, float value) { + // Normalize hue to 0-360 range + hue = hue % 360.0f; + if (hue < 0) hue += 360.0f; + + // If saturation is 0, it's grayscale + if (saturation == 0) { + int gray = (int) (value * 255); + return (gray << 16) | (gray << 8) | gray; + } + + // Calculate which sector (0-5) of the color wheel we're in + float h = hue / 60.0f; + int sector = (int) Math.floor(h); + float fractionalSector = h - sector; + + float p = value * (1.0f - saturation); + float q = value * (1.0f - saturation * fractionalSector); + float t = value * (1.0f - saturation * (1.0f - fractionalSector)); + + float r, g, b; + switch (sector) { + case 0: r = value; g = t; b = p; break; + case 1: r = q; g = value; b = p; break; + case 2: r = p; g = value; b = t; break; + case 3: r = p; g = q; b = value; break; + case 4: r = t; g = p; b = value; break; + default: r = value; g = p; b = q; break; // sector 5 + } + + int red = (int) (r * 255); + int green = (int) (g * 255); + int blue = (int) (b * 255); + + return (red << 16) | (green << 8) | blue; + } + + /** + * Extract the currently active formatting codes from {@code str}. + * + * @param str The formatted string. + * @return A string containing the colour code (if any) followed by active style codes. + */ + public static String extractFormatFromString(String str) { + if (str == null || str.isEmpty()) { + return ""; + } + + String currentColorCode = null; + StringBuilder styleCodes = new StringBuilder(); + ArrayDeque colorStack = new ArrayDeque<>(); + + for (int i = 0; i < str.length(); ) { + int codeLen = detectColorCodeLengthIgnoringRaw(str, i); + + if (codeLen > 0) { + char firstChar = str.charAt(i); + String code = str.substring(i, i + codeLen); + + if (codeLen == 7 && firstChar == '&') { + currentColorCode = code; + colorStack.clear(); + styleCodes.setLength(0); + } else if (codeLen == 8 && firstChar == '<') { + if (currentColorCode != null) { + colorStack.push(currentColorCode); + } + currentColorCode = code; + styleCodes.setLength(0); + } else if (codeLen == 9 && firstChar == '<') { + currentColorCode = colorStack.isEmpty() ? null : colorStack.pop(); + styleCodes.setLength(0); + } else if (codeLen == 2) { + char fmt = Character.toLowerCase(str.charAt(i + 1)); + + if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + currentColorCode = code; + colorStack.clear(); + styleCodes.setLength(0); + } else if (fmt == 'r') { + currentColorCode = null; + colorStack.clear(); + styleCodes.setLength(0); + } else if (fmt == 'l' || fmt == 'o' || fmt == 'n' || fmt == 'm' || fmt == 'k') { + styleCodes.append(code); + } + } + + i += codeLen; + continue; + } + + i++; + } + + StringBuilder result = new StringBuilder(); + if (currentColorCode != null) { + result.append(currentColorCode); + } + if (styleCodes.length() > 0) { + result.append(styleCodes); + } + + return result.toString(); + } + + /** + * Remove all recognised colour/formatting codes from {@code input}. + * + * @param input Text that may contain formatting codes. + * @return The input with all colour codes removed, or {@code null} if the input was {@code null}. + */ + public static String stripColorCodes(CharSequence input) { + if (input == null) { + return null; + } + + StringBuilder builder = new StringBuilder(input.length()); + for (int index = 0; index < input.length(); ) { + int codeLen = detectColorCodeLengthIgnoringRaw(input, index); + if (codeLen > 0) { + index += codeLen; + continue; + } + + builder.append(input.charAt(index)); + index++; + } + + return builder.toString(); + } +} diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProvider.java b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProvider.java index 3896777d9..21bb2ac51 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProvider.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProvider.java @@ -8,13 +8,22 @@ public interface FontProvider { * For use with §k. Should fetch a character of the same width as provided. */ char getRandomReplacement(char chr); + boolean isGlyphAvailable(char chr); + float getUStart(char chr); + float getVStart(char chr); + float getXAdvance(char chr); + float getGlyphW(char chr); + float getUSize(char chr); + float getVSize(char chr); + float getShadowOffset(); + ResourceLocation getTexture(char chr); } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java index c7d380728..d6e66686b 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java @@ -69,12 +69,19 @@ private FontProviderCustom(byte id) { } font = availableFonts[fontPos].deriveFont(currentFontQuality); } + private static class InstLoader { - static final FontProviderCustom instance0 = new FontProviderCustom((byte)0); - static final FontProviderCustom instance1 = new FontProviderCustom((byte)1); + static final FontProviderCustom instance0 = new FontProviderCustom((byte) 0); + static final FontProviderCustom instance1 = new FontProviderCustom((byte) 1); + } + + public static FontProviderCustom getPrimary() { + return InstLoader.instance0; + } + + public static FontProviderCustom getFallback() { + return InstLoader.instance1; } - public static FontProviderCustom getPrimary() { return InstLoader.instance0; } - public static FontProviderCustom getFallback() { return InstLoader.instance1; } public void reloadFont(int fontID) { currentFontQuality = FontConfig.customFontQuality; @@ -150,7 +157,7 @@ void construct(Font font) { int width = 0; int actualChars = 0; for (int i = 0; i < ATLAS_SIZE; i++) { - final char ch = (char)(i + ATLAS_SIZE * this.id); + final char ch = (char) (i + ATLAS_SIZE * this.id); if (font.canDisplay(ch)) { width += (int) (separator + fm.charWidth(ch)); actualChars++; @@ -171,7 +178,7 @@ void construct(Font font) { maxRowWidth = Math.max(maxRowWidth, width); width = 0; } - final char ch = (char)(i + ATLAS_SIZE * this.id); + final char ch = (char) (i + ATLAS_SIZE * this.id); if (font.canDisplay(ch)) { width += (int) (separator + fm.charWidth(ch)); actualChars++; @@ -201,8 +208,10 @@ void construct(Font font) { int imgX = (int) separator; // position in pixels for (int i = 0; i < ATLAS_SIZE; i++) { - final char ch = (char)(i + ATLAS_SIZE * this.id); - if (!font.canDisplay(ch)) { continue; } + final char ch = (char) (i + ATLAS_SIZE * this.id); + if (!font.canDisplay(ch)) { + continue; + } if (tileX >= atlasTilesX) { tileX = 0; @@ -249,7 +258,9 @@ private FontAtlas getAtlas(char chr) { @Override public boolean isGlyphAvailable(char chr) { - if (font == null) { return false; } + if (font == null) { + return false; + } return (getAtlas(chr).glyphData[chr % ATLAS_SIZE] != null); } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderMC.java b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderMC.java index 714f0c5aa..e6847e576 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderMC.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderMC.java @@ -13,11 +13,14 @@ public final class FontProviderMC implements FontProvider { @SuppressWarnings("UnnecessaryUnicodeEscape") private static final String MCFONT_CHARS = "\u00c0\u00c1\u00c2\u00c8\u00ca\u00cb\u00cd\u00d3\u00d4\u00d5\u00da\u00df\u00e3\u00f5\u011f\u0130\u0131\u0152\u0153\u015e\u015f\u0174\u0175\u017e\u0207\u0000\u0000\u0000\u0000\u0000\u0000\u0000 !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\u2302\u00c7\u00fc\u00e9\u00e2\u00e4\u00e0\u00e5\u00e7\u00ea\u00eb\u00e8\u00ef\u00ee\u00ec\u00c4\u00c5\u00c9\u00e6\u00c6\u00f4\u00f6\u00f2\u00fb\u00f9\u00ff\u00d6\u00dc\u00f8\u00a3\u00d8\u00d7\u0192\u00e1\u00ed\u00f3\u00fa\u00f1\u00d1\u00aa\u00ba\u00bf\u00ae\u00ac\u00bd\u00bc\u00a1\u00ab\u00bb\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580\u03b1\u03b2\u0393\u03c0\u03a3\u03c3\u03bc\u03c4\u03a6\u0398\u03a9\u03b4\u221e\u2205\u2208\u2229\u2261\u00b1\u2265\u2264\u2320\u2321\u00f7\u2248\u00b0\u2219\u00b7\u221a\u207f\u00b2\u25a0\u0000"; - private FontProviderMC() {} + private FontProviderMC() { + } + private static class InstLoader { static final FontProviderMC instance = new FontProviderMC(); static final FontProviderMC instanceSGA = new FontProviderMC(); } + public static FontProviderMC get(boolean isSGA) { return (isSGA ? InstLoader.instanceSGA : InstLoader.instance); } @@ -45,7 +48,7 @@ public boolean isGlyphAvailable(char chr) { return MCFONT_ASCII_MAP.containsKey(chr); } - public char getRandomReplacement(char chr){ + public char getRandomReplacement(char chr) { int lutIndex = lookupMcFontPosition(chr); if (lutIndex != -1) { int randomReplacementIndex; diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderUnicode.java b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderUnicode.java index 9a662d4d4..a30e50550 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderUnicode.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderUnicode.java @@ -5,9 +5,16 @@ public final class FontProviderUnicode implements FontProvider { - private FontProviderUnicode() {} - private static class InstLoader { static final FontProviderUnicode instance = new FontProviderUnicode(); } - public static FontProviderUnicode get() { return FontProviderUnicode.InstLoader.instance; } + private FontProviderUnicode() { + } + + private static class InstLoader { + static final FontProviderUnicode instance = new FontProviderUnicode(); + } + + public static FontProviderUnicode get() { + return FontProviderUnicode.InstLoader.instance; + } private static final ResourceLocation[] unicodePageLocations = new ResourceLocation[256]; public byte[] glyphWidth; @@ -37,7 +44,7 @@ public char getRandomReplacement(char chr) { @Override public float getUStart(char chr) { - final float startColumnF = (float)(this.glyphWidth[chr] >>> 4); + final float startColumnF = (float) (this.glyphWidth[chr] >>> 4); return ((float) (chr % 16 * 16) + startColumnF + 0.21f) / 256.0f; } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java b/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java index 639ff7857..cfdb4086b 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java @@ -96,9 +96,13 @@ public static FontProvider getFontProvider(BatchingFontRenderer me, char chr, bo if (customFontEnabled && !me.isSplash) { FontProvider fp; fp = FontProviderCustom.getPrimary(); - if (fp.isGlyphAvailable(chr)) { return fp; } + if (fp.isGlyphAvailable(chr)) { + return fp; + } fp = FontProviderCustom.getFallback(); - if (fp.isGlyphAvailable(chr)) { return fp; } + if (fp.isGlyphAvailable(chr)) { + return fp; + } return FontProviderUnicode.get(); } else { if (!forceUnicode && FontProviderMC.get(false).isGlyphAvailable(chr)) { @@ -123,7 +127,9 @@ public static void reloadCustomFontProviders() { FontProviderCustom.getFallback().reloadFont(i); fallbackFontFound = true; } - if (primaryFontFound && fallbackFontFound) { break; } + if (primaryFontFound && fallbackFontFound) { + break; + } } customFontInUse = (FontConfig.enableCustomFont && (primaryFontFound || fallbackFontFound)); } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/FormattedTextMetrics.java b/src/main/java/com/gtnewhorizons/angelica/client/font/FormattedTextMetrics.java new file mode 100644 index 000000000..680f4dff8 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FormattedTextMetrics.java @@ -0,0 +1,153 @@ +package com.gtnewhorizons.angelica.client.font; + +/** + * Utility methods for measuring formatted Minecraft text. + *

+ * These helpers understand Angelica's extended colour codes and new-line handling, allowing + * shared logic between rendering code and mixins that replicate vanilla string layout. + */ +public final class FormattedTextMetrics { + + private FormattedTextMetrics() {} + + @FunctionalInterface + public interface CharWidthFunction { + float getWidth(char character); + } + + /** + * Calculate the maximum line width for a formatted string, respecting Angelica colour codes and explicit newlines. + * + * @param text The formatted text to measure. + * @param rawMode Whether formatting codes should be ignored (raw text rendering). + * @param charWidthFunc Provider that returns the width of a character in pixels. + * @param glyphSpacing Additional spacing applied after every visible glyph. + * @param boldExtra Extra width applied when bold formatting is active. + * @return The width of the widest line in the input text. + */ + public static float calculateMaxLineWidth(CharSequence text, boolean rawMode, + CharWidthFunction charWidthFunc, float glyphSpacing, float boldExtra) { + if (text == null || text.length() == 0) { + return 0.0f; + } + + float maxWidth = 0.0f; + float currentLineWidth = 0.0f; + boolean isBold = false; + final int length = text.length(); + + for (int index = 0; index < length; ) { + if (!rawMode) { + int codeLen = ColorCodeUtils.detectColorCodeLength(text, index); + if (codeLen > 0) { + if (codeLen == 2 && index + 1 < length) { + char fmt = Character.toLowerCase(text.charAt(index + 1)); + if (fmt == 'l') { + isBold = true; + } else if (fmt == 'r') { + isBold = false; + } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + isBold = false; + } + } + index += codeLen; + continue; + } + } + + char character = text.charAt(index); + if (character == '\n') { + maxWidth = Math.max(maxWidth, currentLineWidth); + currentLineWidth = 0.0f; + index++; + continue; + } + + float charWidth = charWidthFunc.getWidth(character); + if (charWidth > 0.0f) { + currentLineWidth += charWidth; + if (isBold) { + currentLineWidth += boldExtra; + } + currentLineWidth += glyphSpacing; + maxWidth = Math.max(maxWidth, currentLineWidth); + } + index++; + } + + return Math.max(maxWidth, currentLineWidth); + } + + /** + * Determine a safe break index for wrapping formatted text to the supplied width. + * + * @param text The formatted text. + * @param maxWidth Maximum width allowed for the line in pixels. + * @param rawMode Whether formatting codes should be ignored (raw text rendering). + * @param charWidthFunc Provider that returns the width of a character in pixels. + * @param glyphSpacing Additional spacing applied after every visible glyph. + * @param boldExtra Extra width applied when bold formatting is active. + * @return Index where the line should be split. Returns {@code text.length()} if everything fits. + */ + public static int computeLineBreakIndex(CharSequence text, int maxWidth, boolean rawMode, + CharWidthFunction charWidthFunc, float glyphSpacing, float boldExtra) { + if (text == null || text.length() == 0 || maxWidth <= 0) { + return 0; + } + + int lastSafePosition = 0; + float currentWidth = 0.0f; + boolean isBold = false; + final int length = text.length(); + + for (int index = 0; index < length; ) { + if (!rawMode) { + int codeLen = ColorCodeUtils.detectColorCodeLength(text, index); + if (codeLen > 0) { + if (codeLen == 2 && index + 1 < length) { + char fmt = Character.toLowerCase(text.charAt(index + 1)); + if (fmt == 'l') { + isBold = true; + } else if (fmt == 'r') { + isBold = false; + } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + isBold = false; + } + } + index += codeLen; + lastSafePosition = index; + continue; + } + } + + char character = text.charAt(index); + if (character == '\n') { + return index; + } + + float charWidth = charWidthFunc.getWidth(character); + if (charWidth < 0.0f) { + charWidth = 0.0f; + } + + float nextWidth = currentWidth; + if (charWidth > 0.0f) { + nextWidth += charWidth; + if (isBold) { + nextWidth += boldExtra; + } + nextWidth += glyphSpacing; + } + + if (nextWidth > maxWidth) { + return Math.min(lastSafePosition, length); + } + + currentWidth = nextWidth; + index++; + lastSafePosition = index; + } + + return length; + } +} diff --git a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java index 57bf1489f..7e9d083b9 100644 --- a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java +++ b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java @@ -55,6 +55,7 @@ public enum Mixins implements IMixins { "angelica.fontrenderer.MixinGuiIngameForge" , "angelica.fontrenderer.MixinFontRenderer" , "angelica.fontrenderer.MixinMCResourceAccessor" + , "angelica.gui.MixinGuiTextField" ) ), diff --git a/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java b/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java index de8e297d6..60e94f611 100644 --- a/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java +++ b/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java @@ -8,6 +8,8 @@ import net.minecraft.client.gui.FontRenderer; import net.minecraft.util.ResourceLocation; +import com.gtnewhorizons.angelica.client.font.AngelicaFontRenderContext; +import com.gtnewhorizons.angelica.client.font.ColorCodeUtils; import com.prupe.mcpatcher.MCLogger; import com.prupe.mcpatcher.MCPatcherUtils; import com.prupe.mcpatcher.mal.resource.PropertiesFile; @@ -85,7 +87,7 @@ public static ResourceLocation getFontName(FontRenderer fontRenderer, ResourceLo } public static float[] computeCharWidthsf(FontRenderer fontRenderer, ResourceLocation filename, BufferedImage image, - int[] rgb, int[] charWidth) { + int[] rgb, int[] charWidth) { float[] charWidthf = new float[charWidth.length]; if (!((FontRendererExpansion) fontRenderer).getIsHD()) { for (int i = 0; i < charWidth.length; i++) { @@ -178,10 +180,29 @@ public static float getStringWidthf(FontRenderer fontRenderer, String s) { float totalWidth = 0.0f; if (s != null) { boolean isLink = false; - for (int i = 0; i < s.length(); i++) { + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); + for (int i = 0; i < s.length(); ) { + if (!rawMode) { + int codeLen = ColorCodeUtils.detectColorCodeLength(s, i); + if (codeLen > 0) { + if (codeLen == 2 && i + 1 < s.length()) { + char fmt = Character.toLowerCase(s.charAt(i + 1)); + if (fmt == 'l') { + isLink = true; + } else if (fmt == 'r') { + isLink = false; + } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + isLink = false; + } + } + i += codeLen; + continue; + } + } + char c = s.charAt(i); float cWidth = getCharWidthf(fontRenderer, c); - if (cWidth < 0.0f && i < s.length() - 1) { + if (!rawMode && cWidth < 0.0f && i < s.length() - 1) { i++; c = s.charAt(i); if (c == 'l' || c == 'L') { @@ -195,6 +216,7 @@ public static float getStringWidthf(FontRenderer fontRenderer, String s) { if (isLink) { totalWidth++; } + i++; } } return totalWidth; diff --git a/src/main/java/net/coderbot/iris/shaderpack/discovery/ShaderpackDirectoryManager.java b/src/main/java/net/coderbot/iris/shaderpack/discovery/ShaderpackDirectoryManager.java index 1e1bbf1f0..9050c995e 100644 --- a/src/main/java/net/coderbot/iris/shaderpack/discovery/ShaderpackDirectoryManager.java +++ b/src/main/java/net/coderbot/iris/shaderpack/discovery/ShaderpackDirectoryManager.java @@ -1,5 +1,6 @@ package net.coderbot.iris.shaderpack.discovery; +import com.gtnewhorizons.angelica.client.font.ColorCodeUtils; import net.coderbot.iris.Iris; import java.io.IOException; @@ -12,92 +13,79 @@ import java.util.stream.Stream; public class ShaderpackDirectoryManager { - private final Path root; - - public ShaderpackDirectoryManager(Path root) { - this.root = root; - } - - public void copyPackIntoDirectory(String name, Path source) throws IOException { - Path target = Iris.getShaderpacksDirectory().resolve(name); - - // Copy the pack file into the shaderpacks folder. - Files.copy(source, target); - // Zip or other archive files will be copied without issue, - // however normal folders will require additional handling below. - - // Manually copy the contents of the pack if it is a folder - if (Files.isDirectory(source)) { - // Use for loops instead of forEach due to createDirectory throwing an IOException - // which requires additional handling when used in a lambda - - // Copy all sub folders, collected as a list in order to prevent issues with non-ordered sets - try (Stream stream = Files.walk(source)) { - for (Path p : stream.filter(Files::isDirectory).collect(Collectors.toList())) { - Path folder = source.relativize(p); - - if (Files.exists(folder)) { - continue; - } - - Files.createDirectory(target.resolve(folder)); - } - } - - // Copy all non-folder files - try (Stream stream = Files.walk(source)) { - for (Path p : stream.filter(p -> !Files.isDirectory(p)).collect(Collectors.toSet())) { - Path file = source.relativize(p); - - Files.copy(p, target.resolve(file)); - } - } - } - } - - public Collection enumerate() throws IOException { - // Make sure the list is sorted since not all OSes sort the list of files in the directory. - // Case-insensitive sorting is the most intuitive for the user, but we then sort naturally - // afterwards so that we don't alternate cases weirdly in the sorted list. - // - // We also ignore chat formatting characters when sorting - some shader packs include chat - // formatting in the file name so that they have fancy text when displayed in the shaders list. - Comparator baseComparator = String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder()); - Comparator comparator = (a, b) -> { - a = removeFormatting(a); - b = removeFormatting(b); - - return baseComparator.compare(a, b); - }; - - try (Stream list = Files.list(root)) { - return list.filter(Iris::isValidShaderpack) - .map(path -> path.getFileName().toString()) - .sorted(comparator).collect(Collectors.toList()); - } - } - - /** - * Straightforward method to use section-sign based chat formatting from a String - */ - private static String removeFormatting(String formatted) { - char[] original = formatted.toCharArray(); - char[] cleaned = new char[original.length]; - int c = 0; - - for (int i = 0; i < original.length; i++) { - // check if it's a section sign - if (original[i] == '\u00a7') { - i++; - } else { - cleaned[c++] = original[i]; - } - } - - return new String(cleaned, 0, c); - } - - public URI getDirectoryUri() { - return root.toUri(); - } + private final Path root; + + public ShaderpackDirectoryManager(Path root) { + this.root = root; + } + + public void copyPackIntoDirectory(String name, Path source) throws IOException { + Path target = Iris.getShaderpacksDirectory().resolve(name); + + // Copy the pack file into the shaderpacks folder. + Files.copy(source, target); + // Zip or other archive files will be copied without issue, + // however normal folders will require additional handling below. + + // Manually copy the contents of the pack if it is a folder + if (Files.isDirectory(source)) { + // Use for loops instead of forEach due to createDirectory throwing an IOException + // which requires additional handling when used in a lambda + + // Copy all sub folders, collected as a list in order to prevent issues with non-ordered sets + try (Stream stream = Files.walk(source)) { + for (Path p : stream.filter(Files::isDirectory).collect(Collectors.toList())) { + Path folder = source.relativize(p); + + if (Files.exists(folder)) { + continue; + } + + Files.createDirectory(target.resolve(folder)); + } + } + + // Copy all non-folder files + try (Stream stream = Files.walk(source)) { + for (Path p : stream.filter(p -> !Files.isDirectory(p)).collect(Collectors.toSet())) { + Path file = source.relativize(p); + + Files.copy(p, target.resolve(file)); + } + } + } + } + + public Collection enumerate() throws IOException { + // Make sure the list is sorted since not all OSes sort the list of files in the directory. + // Case-insensitive sorting is the most intuitive for the user, but we then sort naturally + // afterwards so that we don't alternate cases weirdly in the sorted list. + // + // We also ignore chat formatting characters when sorting - some shader packs include chat + // formatting in the file name so that they have fancy text when displayed in the shaders list. + Comparator baseComparator = String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder()); + Comparator comparator = (a, b) -> { + a = removeFormatting(a); + b = removeFormatting(b); + + return baseComparator.compare(a, b); + }; + + try (Stream list = Files.list(root)) { + return list.filter(Iris::isValidShaderpack) + .map(path -> path.getFileName().toString()) + .sorted(comparator).collect(Collectors.toList()); + } + } + + /** + * Straightforward method to use section-sign based chat formatting from a String + */ + private static String removeFormatting(String formatted) { + return ColorCodeUtils.stripColorCodes(formatted); + } + + public URI getDirectoryUri() { + return root.toUri(); + } } diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fontrenderer/MixinFontRenderer.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fontrenderer/MixinFontRenderer.java index 3e1fc718a..6f0ea2a5a 100644 --- a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fontrenderer/MixinFontRenderer.java +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/fontrenderer/MixinFontRenderer.java @@ -1,7 +1,10 @@ package com.gtnewhorizons.angelica.mixins.early.angelica.fontrenderer; import com.gtnewhorizon.gtnhlib.util.font.IFontParameters; +import com.gtnewhorizons.angelica.client.font.AngelicaFontRenderContext; import com.gtnewhorizons.angelica.client.font.BatchingFontRenderer; +import com.gtnewhorizons.angelica.client.font.ColorCodeUtils; +import com.gtnewhorizons.angelica.client.font.FormattedTextMetrics; import com.gtnewhorizons.angelica.glsm.GLStateManager; import com.gtnewhorizons.angelica.mixins.interfaces.FontRendererAccessor; import net.minecraft.client.gui.FontRenderer; @@ -119,7 +122,7 @@ public abstract class MixinFontRenderer implements FontRendererAccessor, IFontPa @Inject(method = "", at = @At("TAIL")) private void angelica$injectBatcher(GameSettings settings, ResourceLocation fontLocation, TextureManager texManager, - boolean unicodeMode, CallbackInfo ci) { + boolean unicodeMode, CallbackInfo ci) { angelica$batcher = new BatchingFontRenderer((FontRenderer) (Object) this, this.charWidth, this.glyphWidth, this.colorCode, this.locationFontTexture); } @@ -198,6 +201,20 @@ public void getCharWidth(char c, CallbackInfoReturnable cir) { cir.setReturnValue((int) angelica$getBatcher().getCharWidthFine(c)); } + /** + * Intercept getStringWidth to properly handle RGB color codes. + * Without this, RGB color codes are counted as visible characters, + * causing text wrapping issues in chat, GUIs, text fields, etc. + */ + @Inject(method = "getStringWidth(Ljava/lang/String;)I", at = @At("HEAD"), cancellable = true) + public void angelica$getStringWidthRgbAware(String text, CallbackInfoReturnable cir) { + if (text == null || text.isEmpty()) { + cir.setReturnValue(0); + return; + } + cir.setReturnValue((int) angelica$getBatcher().getStringWidthWithRgb(text)); + } + @Override public float getGlyphScaleX() { return angelica$getBatcher().getGlyphScaleX(); @@ -227,4 +244,173 @@ public float getShadowOffset() { public float getCharWidthFine(char chr) { return angelica$getBatcher().getCharWidthFine(chr); } + + /** + * Intercept sizeStringToWidth to properly handle RGB color codes. + * This method finds the split index that fits within the given width. + * Without this, RGB codes can be split across lines in chat/text wrapping. + */ + @Inject(method = "sizeStringToWidth", at = @At("HEAD"), cancellable = true) + public void angelica$sizeStringToWidthRgbAware(String str, int maxWidth, CallbackInfoReturnable cir) { + cir.setReturnValue(angelica$computeSizeStringToWidthIndex(str, maxWidth)); + } + + @Unique + private int angelica$computeSizeStringToWidthIndex(String str, int maxWidth) { + if (str == null || str.isEmpty() || maxWidth <= 0) { + return 0; + } + + return FormattedTextMetrics.computeLineBreakIndex(str, maxWidth, + AngelicaFontRenderContext.isRawTextRendering(), angelica$getBatcher()::getCharWidthFine, + angelica$getBatcher().getGlyphSpacing(), angelica$getBatcher().getShadowOffset()); + } + + /** + * Intercept trimStringToWidth to properly handle RGB color codes. + * Variant with reverse parameter for trimming from the end. + */ + @Inject(method = "trimStringToWidth(Ljava/lang/String;IZ)Ljava/lang/String;", at = @At("HEAD"), cancellable = true) + public void angelica$trimStringToWidthRgbAware(String text, int width, boolean reverse, CallbackInfoReturnable cir) { + if (text == null || text.isEmpty()) { + cir.setReturnValue(""); + return; + } + + if (!reverse) { + // Forward direction - reuse sizeStringToWidth logic + int endIndex = angelica$computeSizeStringToWidthIndex(text, width); + cir.setReturnValue(text.substring(0, Math.min(endIndex, text.length()))); + return; + } + + // Reverse direction - trim from the end + int length = text.length(); + float currentWidth = 0.0f; + int firstSafePosition = length; + boolean isBold = false; + + for (int i = length - 1; i >= 0; ) { + // Check for color codes (need to scan backwards carefully) + // For reverse, we'll be less aggressive and just avoid breaking simple cases + char c = text.charAt(i); + + // Check if we're at the end of an RGB code + if (i >= 6 && (c == '5' || c == 'F' || c == 'f' || (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { + // Might be end of &RRGGBB, check backwards + if (i >= 6 && text.charAt(i - 6) == '&') { + boolean validHex = true; + for (int j = i - 5; j <= i; j++) { + char hexChar = text.charAt(j); + if (!ColorCodeUtils.isValidHexChar(hexChar)) { + validHex = false; + break; + } + } + if (validHex) { + // Skip the entire &RRGGBB + i -= 7; + firstSafePosition = i + 1; + continue; + } + } + } + + if (c == '\n') { + cir.setReturnValue(text.substring(i + 1)); + return; + } + + float charWidth = angelica$getBatcher().getCharWidthFine(c); + + if (charWidth < 0) { + charWidth = 0; + } + + float nextWidth = currentWidth + charWidth; + if (isBold && charWidth > 0) { + nextWidth += angelica$getBatcher().getShadowOffset(); + } + + if (nextWidth > width) { + // Would exceed width - return string from first safe position + cir.setReturnValue(text.substring(firstSafePosition)); + return; + } + + currentWidth = nextWidth; + i--; + firstSafePosition = i + 1; + } + + // Entire string fits + cir.setReturnValue(text); + } + + /** + * Intercept listFormattedStringToWidth to properly handle RGB color codes. + * This is the CRITICAL method for chat wrapping - it splits long strings into lines + * and prepends formatting codes to each line. Without this, RGB codes get mangled. + * + * This follows vanilla's recursive algorithm but with RGB awareness. + */ + @Inject(method = "listFormattedStringToWidth", at = @At("HEAD"), cancellable = true) + public void angelica$listFormattedStringToWidthRgbAware(String str, int wrapWidth, CallbackInfoReturnable cir) { + if (str == null || str.isEmpty()) { + cir.setReturnValue(java.util.Collections.emptyList()); + return; + } + + String wrapped = angelica$wrapFormattedStringToWidth(str, wrapWidth); + String[] lines = wrapped.split("\n"); + java.util.List result = new java.util.ArrayList<>(lines.length); + java.util.Collections.addAll(result, lines); + cir.setReturnValue(result); + } + + @Inject(method = "wrapFormattedStringToWidth", at = @At("HEAD"), cancellable = true) + public void angelica$wrapFormattedStringToWidthRgbAwareEntry(String str, int wrapWidth, CallbackInfoReturnable cir) { + if (str == null || str.isEmpty()) { + cir.setReturnValue(""); + return; + } + + cir.setReturnValue(angelica$wrapFormattedStringToWidth(str, wrapWidth)); + } + + /** + * RGB-aware version of vanilla's wrapFormattedStringToWidth. + * Uses recursion like vanilla, but handles RGB color codes properly. + */ + @Unique + private String angelica$wrapFormattedStringToWidth(String str, int wrapWidth) { + int breakPoint = angelica$computeSizeStringToWidthIndex(str, wrapWidth); + + if (breakPoint >= str.length()) { + // Everything fits + return str; + } else { + // Need to wrap + String firstPart = str.substring(0, breakPoint); + char charAtBreak = str.charAt(breakPoint); + boolean isSpaceOrNewline = charAtBreak == ' ' || charAtBreak == '\n'; + + // Extract formatting codes from first part and prepend to remainder + String formattingCodes = ColorCodeUtils.extractFormatFromString(firstPart); + String remainder = formattingCodes + str.substring(breakPoint + (isSpaceOrNewline ? 1 : 0)); + + // Recurse on remainder + return firstPart + "\n" + angelica$wrapFormattedStringToWidth(remainder, wrapWidth); + } + } + + @Inject(method = "getFormatFromString", at = @At("HEAD"), cancellable = true) + private static void angelica$getFormatFromStringRgbAware(String text, CallbackInfoReturnable cir) { + if (text == null || text.isEmpty()) { + cir.setReturnValue(""); + return; + } + + cir.setReturnValue(ColorCodeUtils.extractFormatFromString(text)); + } } diff --git a/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/gui/MixinGuiTextField.java b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/gui/MixinGuiTextField.java new file mode 100644 index 000000000..d32d7c4b1 --- /dev/null +++ b/src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/gui/MixinGuiTextField.java @@ -0,0 +1,30 @@ +package com.gtnewhorizons.angelica.mixins.early.angelica.gui; + +import com.gtnewhorizons.angelica.client.font.AngelicaFontRenderContext; +import net.minecraft.client.gui.GuiTextField; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(GuiTextField.class) +public abstract class MixinGuiTextField { + + @Unique + private boolean angelica$rawPushed; + + @Inject(method = "drawTextBox", at = @At("HEAD")) + private void angelica$beginRawMode(CallbackInfo ci) { + AngelicaFontRenderContext.pushRawTextRendering(); + angelica$rawPushed = true; + } + + @Inject(method = "drawTextBox", at = @At("RETURN")) + private void angelica$endRawMode(CallbackInfo ci) { + if (angelica$rawPushed) { + AngelicaFontRenderContext.popRawTextRendering(); + angelica$rawPushed = false; + } + } +}