From 6dcb285f9a2d1a8b1c86c39e79861826bb66923a Mon Sep 17 00:00:00 2001 From: alppp Date: Tue, 21 Oct 2025 15:27:07 +0300 Subject: [PATCH 01/12] Support inline RGB color codes and RGB-aware text layout/rendering --- .../font/AngelicaFontRenderContext.java | 29 ++ .../client/font/BatchingFontRenderer.java | 360 +++++++++++++++--- .../angelica/client/font/ColorCodeUtils.java | 169 ++++++++ .../gtnewhorizons/angelica/mixins/Mixins.java | 1 + .../com/prupe/mcpatcher/hd/FontUtils.java | 69 +++- .../fontrenderer/MixinFontRenderer.java | 296 ++++++++++++++ .../early/angelica/gui/MixinGuiTextField.java | 30 ++ 7 files changed, 896 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java create mode 100644 src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java create mode 100644 src/mixin/java/com/gtnewhorizons/angelica/mixins/early/angelica/gui/MixinGuiTextField.java 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..88da8bee5 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java @@ -0,0 +1,29 @@ +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 504b7f7eb..e9d4ea1bb 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 it.unimi.dsi.fastutil.objects.ObjectArrayList; import net.coderbot.iris.gl.program.Program; import net.coderbot.iris.gl.program.ProgramBuilder; @@ -393,6 +394,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; @@ -400,6 +402,8 @@ public float drawString(final float anchorX, final float anchorY, final int colo boolean curBold = false; boolean curStrikethrough = false; boolean curUnderline = false; + 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); @@ -411,73 +415,205 @@ 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); curRandom = false; - curBold = false; - curStrikethrough = false; - curUnderline = false; - curItalic = false; - curColor = color; - curShadowColor = shadowColor; + 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; + 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); + curRandom = 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) { + // Only reset random flag, preserve bold/italic/underline/strikethrough + // This allows &l&6 (bold gold) patterns to work + curRandom = false; + + 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); + } 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') { + curRandom = false; + curBold = false; + curStrikethrough = false; + curUnderline = false; + curItalic = false; + 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); } @@ -567,8 +703,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(); @@ -578,4 +772,56 @@ 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 (9 chars) + * - format (10 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; + } + + float width = 0.0f; + boolean isBold = false; + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); + + for (int i = 0; i < str.length(); i++) { + int codeLen = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, i); + if (codeLen > 0) { + // Check if this is a bold formatting code + if (codeLen == 2 && i + 1 < str.length()) { + char fmt = Character.toLowerCase(str.charAt(i + 1)); + if (fmt == 'l') { + isBold = true; + } else if (fmt == 'r') { + isBold = false; + } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + isBold = false; // Color codes reset bold + } + } + + i += codeLen - 1; // Skip the color code (minus 1 because loop will increment) + continue; + } + + char c = str.charAt(i); + float charWidth = getCharWidthFine(c); + if (charWidth > 0) { + width += charWidth; + if (isBold) { + width += this.getShadowOffset(); // Bold adds extra width + } + } + } + + return width; + } } 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..d88afc84a --- /dev/null +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -0,0 +1,169 @@ +package com.gtnewhorizons.angelica.client.font; + +/** + * 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) + */ + public static boolean isFormattingCode(char c) { + char lower = Character.toLowerCase(c); + return (lower >= '0' && lower <= '9') + || (lower >= 'a' && lower <= 'f') + || (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) + * - 9 for format (< + 6 hex + >) + * - 10 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 10; + } + } + + // Check for format (opening tag) + if (c == '<' && pos + 8 <= str.length() && str.charAt(pos + 7) == '>') { + if (isValidHexString(str, pos + 1)) { + return 9; + } + } + + 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; + } +} diff --git a/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java b/src/main/java/com/gtnewhorizons/angelica/mixins/Mixins.java index e84f2430c..a8133e947 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..f6e39d52e 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; @@ -178,10 +180,75 @@ public static float getStringWidthf(FontRenderer fontRenderer, String s) { float totalWidth = 0.0f; if (s != null) { boolean isLink = false; + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); + + // Check for RGB color codes first (&RRGGBB, , ) + // These need to be skipped entirely for width calculation + if (!rawMode && c == '&' && i + 6 < s.length()) { + // Check for &RRGGBB format + boolean validHex = true; + for (int j = 1; j <= 6; j++) { + char hexChar = s.charAt(i + j); + if (!((hexChar >= '0' && hexChar <= '9') || + (hexChar >= 'a' && hexChar <= 'f') || + (hexChar >= 'A' && hexChar <= 'F'))) { + validHex = false; + break; + } + } + if (validHex) { + i += 6; // Skip the entire &RRGGBB code + continue; + } + } else if (!rawMode && c == '<' && i + 9 <= s.length() && s.charAt(i + 1) == '/' && s.charAt(i + 8) == '>') { + // Check for format + boolean validHex = true; + for (int j = 2; j <= 7; j++) { + char hexChar = s.charAt(i + j); + if (!((hexChar >= '0' && hexChar <= '9') || + (hexChar >= 'a' && hexChar <= 'f') || + (hexChar >= 'A' && hexChar <= 'F'))) { + validHex = false; + break; + } + } + if (validHex) { + i += 8; // Skip the entire tag + continue; + } + } else if (!rawMode && c == '<' && i + 8 <= s.length() && s.charAt(i + 7) == '>') { + // Check for format + boolean validHex = true; + for (int j = 1; j <= 6; j++) { + char hexChar = s.charAt(i + j); + if (!((hexChar >= '0' && hexChar <= '9') || + (hexChar >= 'a' && hexChar <= 'f') || + (hexChar >= 'A' && hexChar <= 'F'))) { + validHex = false; + break; + } + } + if (validHex) { + i += 7; // Skip the entire tag + continue; + } + } else if (!rawMode && c == '&' && i < s.length() - 1 && ColorCodeUtils.isFormattingCode(s.charAt(i + 1))) { + char fmt = Character.toLowerCase(s.charAt(++i)); + if (fmt == 'l') { + isLink = true; + } else if (fmt == 'r') { + isLink = false; + } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + isLink = false; + } + continue; + } + + // Handle traditional & formatting codes 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') { 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..3bb8f790e 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 @@ -20,6 +20,7 @@ import org.spongepowered.asm.mixin.injection.ModifyConstant; import org.spongepowered.asm.mixin.injection.Constant; +import java.util.ArrayDeque; import java.util.Random; /** @@ -198,6 +199,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 +242,285 @@ 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 substring 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) { + if (str == null || str.isEmpty()) { + cir.setReturnValue(""); + return; + } + + int length = str.length(); + float currentWidth = 0.0f; + int lastSafePosition = 0; + boolean isBold = false; + + for (int i = 0; i < length; ) { + // Check for color codes (RGB or traditional) + int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, i); + + if (codeLen > 0) { + // This is a color code - skip it atomically (never split) + // But first check if we need to update bold state + if (codeLen == 2 && i + 1 < length) { + char fmt = Character.toLowerCase(str.charAt(i + 1)); + if (fmt == 'l') { + isBold = true; + } else if (fmt == 'r') { + isBold = false; + } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + isBold = false; // Color codes reset bold + } + } + + i += codeLen; + lastSafePosition = i; // Can safely break after a complete color code + continue; + } + + // Regular character + char c = str.charAt(i); + float charWidth = angelica$getBatcher().getCharWidthFine(c); + + if (charWidth < 0) { + // Formatting character outside of detected codes + charWidth = 0; + } + + float nextWidth = currentWidth + charWidth; + if (isBold && charWidth > 0) { + nextWidth += angelica$getBatcher().getShadowOffset(); + } + + if (nextWidth > maxWidth) { + // Would exceed width - return string up to last safe position + cir.setReturnValue(str.substring(0, lastSafePosition)); + return; + } + + currentWidth = nextWidth; + i++; + lastSafePosition = i; + } + + // Entire string fits + cir.setReturnValue(str); + } + + /** + * 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 + angelica$sizeStringToWidthRgbAware(text, width, cir); + 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 (!com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isValidHexChar(hexChar)) { + validHex = false; + break; + } + } + if (validHex) { + // Skip the entire &RRGGBB + i -= 7; + firstSafePosition = i + 1; + continue; + } + } + } + + 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) { + // Use our RGB-aware sizeStringToWidth via mixin callback + CallbackInfoReturnable cir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); + angelica$sizeStringToWidthRgbAware(str, wrapWidth, cir); + String sized = cir.getReturnValue(); + int breakPoint = sized.length(); + + if (str.length() <= breakPoint) { + // 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 = angelica$extractFormatFromString(firstPart); + String remainder = formattingCodes + str.substring(breakPoint + (isSpaceOrNewline ? 1 : 0)); + + // Recurse on remainder + return firstPart + "\n" + angelica$wrapFormattedStringToWidth(remainder, wrapWidth); + } + } + + /** + * RGB-aware version of vanilla's getFormatFromString. + * Extracts active formatting codes from a string (RGB colors + traditional formatting). + */ + @Unique + private static String angelica$extractFormatFromString(String str) { + String currentColorCode = null; + StringBuilder styleCodes = new StringBuilder(); + ArrayDeque colorStack = new ArrayDeque<>(); + + for (int i = 0; i < str.length(); ) { + int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLengthIgnoringRaw(str, i); + + if (codeLen > 0) { + char firstChar = str.charAt(i); + String code = str.substring(i, i + codeLen); + + if (codeLen == 7 && firstChar == '&') { + // &RRGGBB - inline RGB colour, clears prior colour stack + currentColorCode = code; + colorStack.clear(); + styleCodes.setLength(0); + } else if (codeLen == 9 && firstChar == '<') { + // - push current colour (if any) then apply new colour + colorStack.push(currentColorCode); + currentColorCode = code; + styleCodes.setLength(0); + } else if (codeLen == 10 && firstChar == '<') { + // - pop back to previous colour (or none) + currentColorCode = colorStack.isEmpty() ? null : colorStack.pop(); + styleCodes.setLength(0); + } else if (codeLen == 2) { + // Traditional formatting code + 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(); + } + + @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(angelica$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; + } + } +} From e46f27805c084317a6e8e2079b6d199a7c8aeca8 Mon Sep 17 00:00:00 2001 From: alppp Date: Tue, 21 Oct 2025 23:02:04 +0300 Subject: [PATCH 02/12] Add rainbow (g) and dinnerbone (h) text formatting --- .../client/font/BatchingFontRenderer.java | 66 ++++++++++++++----- .../angelica/client/font/ColorCodeUtils.java | 48 ++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) 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 e9d4ea1bb..0a3b53cb4 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java @@ -402,6 +402,9 @@ 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(); @@ -463,6 +466,7 @@ public float drawString(final float anchorX, final float anchorY, final int colo curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); curRandom = false; + curRainbow = false; processedRgbOrTag = true; // Prevent traditional &X from overwriting if (!rawMode) { @@ -499,6 +503,7 @@ public float drawString(final float anchorX, final float anchorY, final int colo curShadowColor = shadowColor; } curRandom = false; + curRainbow = false; processedRgbOrTag = true; if (!rawMode) { @@ -530,6 +535,7 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); curRandom = false; + curRainbow = false; processedRgbOrTag = true; if (!rawMode) { @@ -573,6 +579,7 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { // Only reset random flag, preserve bold/italic/underline/strikethrough // This allows &l&6 (bold gold) patterns to work curRandom = false; + curRainbow = false; final int colorIdx = is09 ? (fmtCode - '0') : (fmtCode - 'a' + 10); final int rgb = this.colorCode[colorIdx]; @@ -593,12 +600,22 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { 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; } @@ -635,42 +652,59 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { 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; } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java index d88afc84a..523023e6e 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -33,11 +33,14 @@ public static boolean isValidHexString(String hex) { /** * 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'; } @@ -166,4 +169,49 @@ private static int detectColorCodeLengthInternal(CharSequence str, int pos, bool 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; + } } From 38b24373c0ce806b22333e57df12068ec5f831de Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 23 Oct 2025 16:14:40 -0400 Subject: [PATCH 03/12] Upload Book Editor for Testing --- dependencies.gradle | 1 + libs/bookeditor.jar | Bin 0 -> 44418 bytes 2 files changed, 1 insertion(+) create mode 100644 libs/bookeditor.jar diff --git a/dependencies.gradle b/dependencies.gradle index 4a6cd2cb8..f0f7bb702 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -101,6 +101,7 @@ dependencies { compileOnly(rfg.deobf("curse.maven:security-craft-64760:2818228")) runtimeOnlyNonPublishable(rfg.deobf("CoreTweaks:CoreTweaks:0.3.3.2")) + runtimeOnlyNonPublishable(rfg.deobf(files("libs/bookeditor.jar"))) // Hodgepodge transformedMod("net.industrial-craft:industrialcraft-2:2.2.828-experimental:dev") diff --git a/libs/bookeditor.jar b/libs/bookeditor.jar new file mode 100644 index 0000000000000000000000000000000000000000..1e9eb75b9d318b698079bef3fac5e08c44d72cfc GIT binary patch literal 44418 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 literal 0 HcmV?d00001 From ea5a681c5224554574364da09abb0767f3667f71 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 23 Oct 2025 19:04:34 -0400 Subject: [PATCH 04/12] Better Plug Editor --- dependencies.gradle | 2 +- libs/{bookeditor.jar => plugeditor-1.3.jar} | Bin 44418 -> 46505 bytes 2 files changed, 1 insertion(+), 1 deletion(-) rename libs/{bookeditor.jar => plugeditor-1.3.jar} (78%) diff --git a/dependencies.gradle b/dependencies.gradle index f0f7bb702..e63e25228 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -101,7 +101,7 @@ dependencies { compileOnly(rfg.deobf("curse.maven:security-craft-64760:2818228")) runtimeOnlyNonPublishable(rfg.deobf("CoreTweaks:CoreTweaks:0.3.3.2")) - runtimeOnlyNonPublishable(rfg.deobf(files("libs/bookeditor.jar"))) + runtimeOnlyNonPublishable(rfg.deobf(files("libs/plugeditor-1.3.jar"))) // Hodgepodge transformedMod("net.industrial-craft:industrialcraft-2:2.2.828-experimental:dev") diff --git a/libs/bookeditor.jar b/libs/plugeditor-1.3.jar similarity index 78% rename from libs/bookeditor.jar rename to libs/plugeditor-1.3.jar index 1e9eb75b9d318b698079bef3fac5e08c44d72cfc..2a29217afcc883b859e3ec8a7cb8d0cfbeea851e 100644 GIT binary patch delta 7464 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 delta 5541 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 D<9e74 From 722cc47ce6126df7de38e19c7950911d7f5d218f Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 23 Oct 2025 19:04:50 -0400 Subject: [PATCH 05/12] Reset Formatting on Color Codes --- .../client/font/BatchingFontRenderer.java | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 fa7a0045b..2f2fcb476 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java @@ -467,8 +467,16 @@ public float drawString(final float anchorX, final float anchorY, final int colo shadowStack.clear(); curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + + // reset styles on color change (vanilla behavior) curRandom = false; + curBold = false; + curStrikethrough = false; + curUnderline = false; + curItalic = false; curRainbow = false; + curDinnerbone = false; + processedRgbOrTag = true; // Prevent traditional &X from overwriting if (!rawMode) { @@ -536,8 +544,16 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { 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) { @@ -578,16 +594,20 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { final boolean is09 = charInRange(fmtCode, '0', '9'); final boolean isAF = charInRange(fmtCode, 'a', 'f'); if (is09 || isAF) { - // Only reset random flag, preserve bold/italic/underline/strikethrough - // This allows &l&6 (bold gold) patterns to work - curRandom = false; - curRainbow = false; - 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') { @@ -855,6 +875,7 @@ public float getStringWidthWithRgb(CharSequence str) { if (isBold) { width += this.getShadowOffset(); // Bold adds extra width } + width += getGlyphSpacing(); } } From 6a8c66d0d3c149fda18441921039c35502d2f28f Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 23 Oct 2025 23:01:11 -0400 Subject: [PATCH 06/12] Fix a lot of issues with cursor placement, typing, string lengths, bold calculations, etc --- .../font/AngelicaFontRenderContext.java | 3 +- .../client/font/BatchingFontRenderer.java | 726 ++++++++++-------- .../angelica/client/font/ColorCodeUtils.java | 54 +- .../angelica/client/font/FontProvider.java | 9 + .../client/font/FontProviderCustom.java | 34 +- .../angelica/client/font/FontProviderMC.java | 7 +- .../client/font/FontProviderUnicode.java | 15 +- .../angelica/client/font/FontStrategist.java | 19 +- .../fontrenderer/MixinFontRenderer.java | 185 ++--- 9 files changed, 605 insertions(+), 447 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java b/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java index 88da8bee5..9f3b7d6a3 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/AngelicaFontRenderContext.java @@ -7,7 +7,8 @@ public final class AngelicaFontRenderContext { private static final ThreadLocal RAW_TEXT_DEPTH = ThreadLocal.withInitial(() -> 0); - private AngelicaFontRenderContext() {} + private AngelicaFontRenderContext() { + } public static void pushRawTextRendering() { RAW_TEXT_DEPTH.set(RAW_TEXT_DEPTH.get() + 1); 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 2f2fcb476..47161b5fc 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java @@ -4,7 +4,6 @@ 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 it.unimi.dsi.fastutil.objects.ObjectArrayList; import net.coderbot.iris.gl.program.Program; import net.coderbot.iris.gl.program.ProgramBuilder; @@ -23,7 +22,10 @@ import java.util.Comparator; import java.util.Objects; -import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.*; +import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAlloc; +import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAllocFloat; +import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAllocInt; +import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memRealloc; /** * A batching replacement for {@code FontRenderer} @@ -32,18 +34,26 @@ */ public class BatchingFontRenderer { - /** The underlying FontRenderer object that's being accelerated */ + /** + * The underlying FontRenderer object that's being accelerated + */ protected FontRenderer underlying; - /** Array of width of all the characters in default.png */ + /** + * Array of width of all the characters in default.png + */ protected int[] charWidth = new int[256]; - /** Array of the start/end column (in upper/lower nibble) for every glyph in the /font directory. */ + /** + * Array of the start/end column (in upper/lower nibble) for every glyph in the /font directory. + */ protected byte[] glyphWidth; /** * Array of RGB triplets defining the 16 standard chat colors followed by 16 darker version of the same colors for * drop shadows. */ private int[] colorCode; - /** Location of the primary font atlas to bind. */ + /** + * Location of the primary font atlas to bind. + */ protected final ResourceLocation locationFontTexture; private final int AAMode; @@ -56,6 +66,7 @@ public class BatchingFontRenderer { private static class FontAAShader { private static Program fontShader = null; + public static Program getProgram() { if (fontShader == null) { String vsh, fsh; @@ -115,7 +126,9 @@ public BatchingFontRenderer(FontRenderer underlying, int[] charWidth, byte[] gly private final ObjectArrayList batchCommands = ObjectArrayList.wrap(new FontDrawCmd[64], 0); private final ObjectArrayList batchCommandPool = ObjectArrayList.wrap(new FontDrawCmd[64], 0); - /** */ + /** + * + */ private void pushVtx(float x, float y, int rgba, float u, float v, float uMin, float uMax, float vMin, float vMax) { final int oldCap = batchVtxPositions.capacity() / 2; if (vtxWriterIndex >= oldCap) { @@ -262,6 +275,7 @@ public void endBatch() { int lastActiveProgram; int fontAAModeLast = -1; int fontAAStrengthLast = -1; + private void flushBatch() { // Sort&Draw batchCommands.sort(FontDrawCmd.DRAW_ORDER_COMPARATOR); @@ -331,10 +345,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 @@ -374,391 +388,456 @@ 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) { - // noinspection SizeReplaceableByIsEmpty - if (string == null || string.length() == 0) { - return anchorX + (enableShadow ? 1.0f : 0.0f); - } - final int shadowColor = (color & 0xfcfcfc) >> 2 | color & 0xff000000; - + public float drawString( + final float startX, + final float startY, + final int baseColorARGB, + final boolean drawShadow, + final boolean unicodeFlag, + final CharSequence text, + int textOffset, + int textLen + ) { + // Fast exits + if (text == null || text.length() == 0) return startX + (drawShadow ? 1.0f : 0.0f); + + // Shadow color computed like vanilla + final int baseShadowARGB = (baseColorARGB & 0xFCFCFC) >> 2 | (baseColorARGB & 0xFF000000); + + // Inform providers of current font assets (vanilla atlas vs unicode pages) FontProviderMC.get(this.isSGA).charWidth = this.charWidth; FontProviderMC.get(this.isSGA).locationFontTexture = this.locationFontTexture; - this.beginBatch(); - float curX = anchorX; + // Clamp the slice we’ll draw + final int totalLen = text.length(); + textOffset = MathHelper.clamp_int(textOffset, 0, totalLen); + textLen = MathHelper.clamp_int(textLen, 0, totalLen - textOffset); + if (textLen <= 0) return 0; + + // Per-line vertical metrics (derived from vanilla FONT_HEIGHT + Angelica scaling) + final float scaleY = getGlyphScaleY(); + final float lineHeight = (underlying.FONT_HEIGHT - 1.0f) * scaleY; + final float ascentY = startY + (underlying.FONT_HEIGHT - 1.0f) * (0.5f - scaleY / 2.0f); // top of glyphs + final float underlineYOffset = (underlying.FONT_HEIGHT - 1.0f) * scaleY; + final float strikethroughYOffset = ((underlying.FONT_HEIGHT / 2.0f) - 1.0f) * scaleY; + final float lineAdvance = underlying.FONT_HEIGHT; // vertical step between lines (vanilla baseline distance) + + // Dynamic drawing state + float penX = startX; + float lineYOffset = 0.0f; // how far we’ve moved down from the first line + + int currentColor = baseColorARGB; + int currentShadow = baseShadowARGB; + boolean styleItalic = false; + boolean styleRandom = false; + boolean styleBold = false; + boolean styleStrike = false; + boolean styleUnder = false; + boolean styleRainbow = false; + boolean styleFlip = false; // dinnerbone + + final float boldDx = getShadowOffset(); + + int rainbowStep = 0; + + // For nested RGB tag colors ( ... ) + final it.unimi.dsi.fastutil.ints.IntArrayList colorStack = new it.unimi.dsi.fastutil.ints.IntArrayList(); + final it.unimi.dsi.fastutil.ints.IntArrayList shadowStack = new it.unimi.dsi.fastutil.ints.IntArrayList(); + + // Underline / strikethrough segments on the current line + float underlineStartX = 0.0f, underlineEndX = 0.0f; + float strikeStartX = 0.0f, strikeEndX = 0.0f; + + // Editor “raw mode” highlighting support (unchanged behavior) + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); + int rawTokenSkip = 0; + + beginBatch(); try { - final int totalStringLength = string.length(); - stringOffset = MathHelper.clamp_int(stringOffset, 0, totalStringLength); - stringLength = MathHelper.clamp_int(stringLength, 0, totalStringLength - stringOffset); - if (stringLength <= 0) { - return 0; - } - final int stringEnd = stringOffset + stringLength; - - final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); - int curColor = color; - int curShadowColor = shadowColor; - boolean curItalic = false; - boolean curRandom = false; - 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); - final float heightSouth = (underlying.FONT_HEIGHT - 1.0f) * glyphScaleY; - - final float underlineY = heightNorth + (underlying.FONT_HEIGHT - 1.0f) * glyphScaleY; - float underlineStartX = 0.0f; - float underlineEndX = 0.0f; - 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); - boolean processedRgbOrTag = false; + final int end = textOffset + textLen; + + for (int i = textOffset; i < end; i++) { + char ch = text.charAt(i); + + // 1) Hard line break + if (ch == '\n') { + // Flush underline/strike for this line + if (styleUnder && underlineStartX != underlineEndX) { + final int idx = idxWriterIndex; + pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, + underlineEndX - underlineStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); + underlineStartX = underlineEndX = penX; + } + if (styleStrike && strikeStartX != strikeEndX) { + final int idx = idxWriterIndex; + pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, + strikeEndX - strikeStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); + strikeStartX = strikeEndX = penX; + } + + // Move pen to next line + lineYOffset += lineAdvance; + penX = startX; + continue; + } + // 2) Raw-mode token highlight (unchanged, just renamed vars) if (rawMode) { if (rawTokenSkip > 0) { rawTokenSkip--; } else { - int tokenLen = ColorCodeUtils.detectColorCodeLengthIgnoringRaw(string, charIdx); + int tokenLen = ColorCodeUtils.detectColorCodeLengthIgnoringRaw(text, i); 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); + float tokenW = angelica$measureLiteralWidth(text, i, tokenLen, end, unicodeFlag, styleBold); + if (tokenW > 0) { + final int idx = idxWriterIndex; + pushUntexRect(penX, ascentY + lineYOffset - 1.0f, + tokenW, lineHeight + 2.0f, + angelica$getTokenHighlightColor(text, i)); + pushDrawCmd(idx, 6, null, false); } rawTokenSkip = Math.max(tokenLen - 1, 0); } } } - // 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); + // 3) RGB and formatting codes + boolean consumedFormatting = false; + + // 3a) &RRGGBB + if (ch == '&' && (i + 6) < end) { + final int rgb = ColorCodeUtils.parseHexColor(text, i + 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); + // Close any active underline/strike segments before changing color + if (styleUnder && underlineStartX != underlineEndX) { + final int idx = idxWriterIndex; + pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, + underlineEndX - underlineStartX, scaleY, currentColor); + pushDrawCmd(idx, 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 (styleStrike && strikeStartX != strikeEndX) { + final int idx = idxWriterIndex; + pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, + strikeEndX - strikeStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); + strikeStartX = strikeEndX; } - // Apply RGB color (preserve formatting state to allow &l&FFxxxx patterns) + // Apply new color and reset styles (vanilla behavior on color change) colorStack.clear(); shadowStack.clear(); - curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); - curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); - - // reset styles on color change (vanilla behavior) - curRandom = false; - curBold = false; - curStrikethrough = false; - curUnderline = false; - curItalic = false; - curRainbow = false; - curDinnerbone = false; - - processedRgbOrTag = true; // Prevent traditional &X from overwriting - + currentColor = (currentColor & 0xFF000000) | (rgb & 0x00FFFFFF); + currentShadow = (currentShadow & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + + styleRandom = false; + styleBold = false; + styleStrike = false; + styleUnder = false; + styleItalic = false; + styleRainbow = false; + styleFlip = false; + + consumedFormatting = true; if (!rawMode) { - charIdx += 6; // Skip the 6 hex digits + i += 6; 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); + // 3b) or + if (!consumedFormatting && ch == '<') { + // Close tag: + if ((i + 9) <= end && text.charAt(i + 1) == '/' && text.charAt(i + 8) == '>') { + if (ColorCodeUtils.isValidHexString(text, i + 2)) { + if (styleUnder && underlineStartX != underlineEndX) { + final int idx = idxWriterIndex; + pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, + underlineEndX - underlineStartX, scaleY, currentColor); + pushDrawCmd(idx, 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 (styleStrike && strikeStartX != strikeEndX) { + final int idx = idxWriterIndex; + pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, + strikeEndX - strikeStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); + strikeStartX = strikeEndX; } if (!colorStack.isEmpty()) { - curColor = colorStack.removeInt(colorStack.size() - 1); - curShadowColor = shadowStack.removeInt(shadowStack.size() - 1); + currentColor = colorStack.removeInt(colorStack.size() - 1); + currentShadow = shadowStack.removeInt(shadowStack.size() - 1); } else { - curColor = color; - curShadowColor = shadowColor; + currentColor = baseColorARGB; + currentShadow = baseShadowARGB; } - curRandom = false; - curRainbow = false; - processedRgbOrTag = true; + styleRandom = false; + styleRainbow = false; + consumedFormatting = true; if (!rawMode) { - charIdx += 8; // Skip (9 chars total, but loop will increment) + i += 8; continue; } } } - // Check for opening tag - else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { - final int rgb = ColorCodeUtils.parseHexColor(string, charIdx + 1); + // Open tag: + else if ((i + 8) <= end && text.charAt(i + 7) == '>') { + final int rgb = ColorCodeUtils.parseHexColor(text, i + 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); + if (styleUnder && underlineStartX != underlineEndX) { + final int idx = idxWriterIndex; + pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, + underlineEndX - underlineStartX, scaleY, currentColor); + pushDrawCmd(idx, 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 (styleStrike && strikeStartX != strikeEndX) { + final int idx = idxWriterIndex; + pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, + strikeEndX - strikeStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); + strikeStartX = strikeEndX; } - 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; - + colorStack.add(currentColor); + shadowStack.add(currentShadow); + currentColor = (currentColor & 0xFF000000) | (rgb & 0x00FFFFFF); + currentShadow = (currentShadow & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + + // Vanilla resets styles on color change + styleRandom = false; + styleBold = false; + styleStrike = false; + styleUnder = false; + styleItalic = false; + styleRainbow = false; + styleFlip = false; + + consumedFormatting = true; if (!rawMode) { - charIdx += 7; // Skip (8 chars total, but loop will increment) + i += 7; continue; } } } } - // 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++; + // 3c) Traditional (§) or alias (&) formatting codes + if (!consumedFormatting && (ch == FORMATTING_CHAR || ch == '&') && (i + 1) < end) { + final char next = text.charAt(i + 1); + final char fmt = Character.toLowerCase(next); - if (curUnderline && underlineStartX != underlineEndX) { - final int ulIdx = idxWriterIndex; - pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); - pushDrawCmd(ulIdx, 6, null, false); + // treat '&' as literal unless it's a valid formatting code + if (ch == '&' && !ColorCodeUtils.isFormattingCode(next)) { + // fall-through to render literal '&' + } else { + i++; // consume code + + // before changing styles, flush current underline/strike segments + if (styleUnder && underlineStartX != underlineEndX) { + final int idx = idxWriterIndex; + pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, + underlineEndX - underlineStartX, scaleY, currentColor); + pushDrawCmd(idx, 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 (styleStrike && strikeStartX != strikeEndX) { + final int idx = idxWriterIndex; + pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, + strikeEndX - strikeStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); + strikeStartX = strikeEndX; } - final boolean is09 = charInRange(fmtCode, '0', '9'); - final boolean isAF = charInRange(fmtCode, 'a', 'f'); + final boolean is09 = (fmt >= '0' && fmt <= '9'); + final boolean isAF = (fmt >= 'a' && fmt <= '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; + // Vanilla: color sets RGB and resets all styles + final int colorIdx = is09 ? (fmt - '0') : (fmt - 'a' + 10); + currentColor = (currentColor & 0xFF000000) | (this.colorCode[colorIdx] & 0x00FFFFFF); + currentShadow = (currentShadow & 0xFF000000) | (this.colorCode[colorIdx + 16] & 0x00FFFFFF); + + styleRandom = false; + styleBold = false; + styleStrike = false; + styleUnder = false; + styleItalic = false; + styleRainbow = false; + styleFlip = false; + } else if (fmt == 'k') { + styleRandom = true; + } else if (fmt == 'l') { + styleBold = true; + } else if (fmt == 'm') { + styleStrike = true; + strikeStartX = penX - 1.0f; + strikeEndX = strikeStartX; + } else if (fmt == 'n') { + styleUnder = true; + underlineStartX = penX - 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; + } else if (fmt == 'o') { + styleItalic = true; + } else if (fmt == 'g') { + styleRainbow = true; + rainbowStep = 0; + } else if (fmt == 'h') { + styleFlip = true; + } else if (fmt == 'r') { + styleRandom = false; + styleBold = false; + styleStrike = false; + styleUnder = false; + styleItalic = false; + styleRainbow = false; + styleFlip = false; + rainbowStep = 0; + currentColor = baseColorARGB; + currentShadow = baseShadowARGB; } if (!rawMode) { - continue; + continue; // formatting consumed } else { - // In raw mode, we still applied the formatting but need to back up charIdx - // so we render the formatting character - charIdx--; + i--; // in rawMode we still draw the code char itself } } } - if (!rawMode && curRandom) { - chr = FontProviderMC.get(this.isSGA).getRandomReplacement(chr); + // 4) Random obfuscation (after formatting has been applied) + if (!rawMode && styleRandom) { + ch = FontProviderMC.get(this.isSGA).getRandomReplacement(ch); } - FontProvider fontProvider = FontStrategist.getFontProvider(chr, this.isSGA, FontConfig.enableCustomFont, unicodeFlag); - - // Check ASCII space, NBSP, NNBSP - if (chr == ' ' || chr == '\u00A0' || chr == '\u202F') { - curX += 4 * this.getWhitespaceScale(); + // 5) Space (ASCII / NBSP / NNBSP) → just advance penX + if (ch == ' ' || ch == '\u00A0' || ch == '\u202F') { + final float spaceAdvance = 4 * getWhitespaceScale() + + (styleBold ? boldDx : 0.0f) + + getGlyphSpacing(); + penX += spaceAdvance; + + // keep underline/strike segment ends in sync with caret movement + if (styleUnder) + underlineEndX = penX; + if (styleStrike) + strikeEndX = penX; continue; } - final float uStart = fontProvider.getUStart(chr); - final float vStart = fontProvider.getVStart(chr); - final float xAdvance = fontProvider.getXAdvance(chr) * getGlyphScaleX(); - final float glyphW = fontProvider.getGlyphW(chr) * getGlyphScaleX(); - final float uSz = fontProvider.getUSize(chr); - final float vSz = fontProvider.getVSize(chr); - final float itOff = curItalic ? 1.0F : 0.0F; // italic offset - 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++; + // 6) Lookup glyph metrics/texture and push quads + final FontProvider fp = FontStrategist.getFontProvider(ch, this.isSGA, FontConfig.enableCustomFont, unicodeFlag); + + // Rainbow (per glyph) + if (styleRainbow) { + float hue = (rainbowStep * 15.0f) % 360.0f; + int rgb = ColorCodeUtils.hsvToRgb(hue, 1.0f, 1.0f); + currentColor = (currentColor & 0xFF000000) | (rgb & 0x00FFFFFF); + currentShadow = (currentShadow & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + rainbowStep++; } - // 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 + 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 + 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; + final float u0 = fp.getUStart(ch); + final float v0 = fp.getVStart(ch); + final float uSize = fp.getUSize(ch); + final float vSize = fp.getVSize(ch); + final float advX = fp.getXAdvance(ch) * getGlyphScaleX(); + final float gw = fp.getGlyphW(ch) * getGlyphScaleX(); + final float italicOffset = styleItalic ? 1.0f : 0.0f; + final float shadowDx = fp.getShadowOffset(); + final ResourceLocation tex = fp.getTexture(ch); + + // Current baseline for this line + final float yTop = ascentY + lineYOffset; + final float yBottom = yTop + lineHeight; + + // Texture V flip for dinnerbone (flip texture only, not geometry Y) + final float vTop = styleFlip ? (v0 + vSize) : v0; + final float vBottom = styleFlip ? v0 : (v0 + vSize); + + final float x0 = penX; + final float x1 = penX + gw - 1.0f; + + // push vertices (shadow → normal → bold offset) + final int vStart = vtxWriterIndex; + final int iStart = idxWriterIndex; + int pushedQuads = 0; + + if (drawShadow) { + pushVtx(x0 + italicOffset + shadowDx, yTop + shadowDx, currentShadow, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x0 - italicOffset + shadowDx, yBottom + shadowDx, currentShadow, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 + italicOffset + shadowDx, yTop + shadowDx, currentShadow, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 - italicOffset + shadowDx, yBottom + shadowDx, currentShadow, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushQuadIdx(vStart + pushedQuads * 4); + pushedQuads++; + + if (styleBold) { + final float shadowDxBold = shadowDx + boldDx; // not 2 * boldDx + pushVtx(x0 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x0 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushQuadIdx(vStart + pushedQuads * 4); + pushedQuads++; } } - 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 + 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; + // Normal glyph + pushVtx(x0 + italicOffset, yTop, currentColor, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x0 - italicOffset, yBottom, currentColor, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 + italicOffset, yTop, currentColor, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 - italicOffset, yBottom, currentColor, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushQuadIdx(vStart + pushedQuads * 4); + pushedQuads++; + + if (styleBold) { + pushVtx(boldDx + x0 + italicOffset, yTop, currentColor, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(boldDx + x0 - italicOffset, yBottom, currentColor, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(boldDx + x1 + italicOffset, yTop, currentColor, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(boldDx + x1 - italicOffset, yBottom, currentColor, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushQuadIdx(vStart + pushedQuads * 4); + pushedQuads++; } - pushDrawCmd(idxId, vtxCount / 2 * 3, texture, chr > 255); - curX += (xAdvance + (curBold ? shadowOffset : 0.0f)) + getGlyphSpacing(); - underlineEndX = curX; - strikethroughEndX = curX; + // Record draw for this glyph batch + pushDrawCmd(iStart, pushedQuads * 6, tex, ch > 255); + + // Advance caret (include spacing; bold adds an extra shadow offset like vanilla) + penX += (advX + (styleBold ? boldDx : 0.0f)) + getGlyphSpacing(); + + // Keep decoration extents in sync with caret + underlineEndX = penX; + strikeEndX = penX; } - if (curUnderline && underlineStartX != underlineEndX) { - final int ulIdx = idxWriterIndex; - pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); - pushDrawCmd(ulIdx, 6, null, false); + // 7) Flush remaining underline/strike on the last line + if (styleUnder && underlineStartX != underlineEndX) { + final int idx = idxWriterIndex; + pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, + underlineEndX - underlineStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); } - if (curStrikethrough && strikethroughStartX != strikethroughEndX) { - final int ulIdx = idxWriterIndex; - pushUntexRect( - strikethroughStartX, - strikethroughY, - strikethroughEndX - strikethroughStartX, - glyphScaleY, - curColor); - pushDrawCmd(ulIdx, 6, null, false); + if (styleStrike && strikeStartX != strikeEndX) { + final int idx = idxWriterIndex; + pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, + strikeEndX - strikeStartX, scaleY, currentColor); + pushDrawCmd(idx, 6, null, false); } } finally { - this.endBatch(); + endBatch(); } - return curX + (enableShadow ? 1.0f : 0.0f); + + // Return the final pen position (matches vanilla’s “right edge”), with +1 if shadow was drawn. + return penX + (drawShadow ? 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; @@ -814,7 +893,9 @@ else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { } public float getCharWidthFine(char chr) { - if (chr == FORMATTING_CHAR && !AngelicaFontRenderContext.isRawTextRendering()) { 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() @@ -845,40 +926,45 @@ public float getStringWidthWithRgb(CharSequence str) { return 0.0f; } - float width = 0.0f; + float width = 0.0f, maxWidth = 0.0f; boolean isBold = false; final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); for (int i = 0; i < str.length(); i++) { + char ch = str.charAt(i); + + if (ch == '\n') { + if (width > maxWidth) + maxWidth = width; + width = 0.0f; + isBold = false; // matches vanilla behavior for width calc across lines + continue; + } + int codeLen = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, i); if (codeLen > 0) { - // Check if this is a bold formatting code if (codeLen == 2 && i + 1 < str.length()) { char fmt = Character.toLowerCase(str.charAt(i + 1)); if (fmt == 'l') { isBold = true; - } else if (fmt == 'r') { + } else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { isBold = false; - } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { - isBold = false; // Color codes reset bold } } - - i += codeLen - 1; // Skip the color code (minus 1 because loop will increment) + i += codeLen - 1; // skip entire code (loop will ++) continue; } - char c = str.charAt(i); - float charWidth = getCharWidthFine(c); - if (charWidth > 0) { - width += charWidth; - if (isBold) { - width += this.getShadowOffset(); // Bold adds extra width - } + float charW = getCharWidthFine(ch); + if (charW > 0) { + width += charW; + if (isBold) + width += this.getShadowOffset(); width += getGlyphSpacing(); } } - return width; + return Math.max(width, maxWidth); } + } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java index 523023e6e..c48b46268 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -62,6 +62,7 @@ public static boolean isValidHexString(CharSequence str, int start) { /** * 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 */ @@ -78,7 +79,8 @@ public static int parseHexColor(String hex) { /** * Parse 6 hex characters from a CharSequence starting at position - * @param str The string to parse + * + * @param str The string to parse * @param start Starting position * @return RGB value as integer, or -1 if invalid */ @@ -100,11 +102,11 @@ public static int parseHexColor(CharSequence str, int start) { * @param str The string to check * @param pos Position to check * @return Length of color code: - * - 7 for &RRGGBB format (& + 6 hex) - * - 9 for format (< + 6 hex + >) - * - 10 for format () - * - 2 for §X format (handled elsewhere, but counted here) - * - 0 for no color code + * - 7 for &RRGGBB format (& + 6 hex) + * - 9 for format (< + 6 hex + >) + * - 10 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()); @@ -173,9 +175,9 @@ public static int calculateShadowColor(int rgb) { /** * Convert HSV (Hue, Saturation, Value) color to RGB. * - * @param hue Hue in degrees (0-360) + * @param hue Hue in degrees (0-360) * @param saturation Saturation (0.0-1.0) - * @param value Value/Brightness (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) { @@ -200,12 +202,36 @@ public static int hsvToRgb(float hue, float saturation, float value) { 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 + 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); 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..2ffd06781 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java @@ -13,10 +13,7 @@ import org.apache.logging.log4j.Logger; import javax.imageio.ImageIO; -import java.awt.Font; -import java.awt.FontMetrics; -import java.awt.Graphics2D; -import java.awt.RenderingHints; +import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -69,12 +66,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 +154,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 +175,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 +205,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 +255,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 3134095fb..e62b18961 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java @@ -9,8 +9,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.awt.Font; -import java.awt.GraphicsEnvironment; +import java.awt.*; import java.io.File; import java.util.Comparator; import java.util.HashMap; @@ -84,8 +83,8 @@ public class FontStrategist { } /** - Lets you get a FontProvider per char while respecting font priority and fallbacks, the unicode flag, whether or not - SGA is on, if a font can even display a character in the first place, etc. + * Lets you get a FontProvider per char while respecting font priority and fallbacks, the unicode flag, whether or not + * SGA is on, if a font can even display a character in the first place, etc. */ public static FontProvider getFontProvider(char chr, boolean isSGA, boolean customFontEnabled, boolean forceUnicode) { if (isSGA && FontProviderMC.get(true).isGlyphAvailable(chr)) { @@ -94,9 +93,13 @@ public static FontProvider getFontProvider(char chr, boolean isSGA, boolean cust if (customFontEnabled) { 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)) { @@ -121,7 +124,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/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 3bb8f790e..5717239f7 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 @@ -7,6 +7,7 @@ import net.minecraft.client.gui.FontRenderer; import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.client.settings.GameSettings; +import net.minecraft.util.MathHelper; import net.minecraft.util.ResourceLocation; import org.lwjgl.opengl.GL11; import org.spongepowered.asm.mixin.Final; @@ -177,7 +178,9 @@ public abstract class MixinFontRenderer implements FontRendererAccessor, IFontPa GL11.glColor4f(1.0f, 1.0f, 1.0f, 1.0f); this.posX = (float)x; this.posY = (float)y; - return (int) angelica$batcher.drawString(x, y, argb, dropShadow, unicodeFlag, text, 0, text.length()); + + float adv = angelica$batcher.drawString(x, y, argb, dropShadow, unicodeFlag, text, 0, text.length()); + return MathHelper.ceiling_float_int(adv); } } @@ -196,7 +199,8 @@ public abstract class MixinFontRenderer implements FontRendererAccessor, IFontPa @Inject(method = "getCharWidth", at = @At("HEAD"), cancellable = true) public void getCharWidth(char c, CallbackInfoReturnable cir) { - cir.setReturnValue((int) angelica$getBatcher().getCharWidthFine(c)); + float w = angelica$getBatcher().getCharWidthFine(c); + cir.setReturnValue(MathHelper.ceiling_float_int(w)); } /** @@ -210,7 +214,7 @@ public void getCharWidth(char c, CallbackInfoReturnable cir) { cir.setReturnValue(0); return; } - cir.setReturnValue((int) angelica$getBatcher().getStringWidthWithRgb(text)); + cir.setReturnValue(MathHelper.ceiling_float_int(angelica$getBatcher().getStringWidthWithRgb(text))); } @Override @@ -249,67 +253,70 @@ public float getCharWidthFine(char chr) { * 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) { + public void angelica$sizeStringToWidthRgbAware(String str, int maxWidth, CallbackInfoReturnable cir) { if (str == null || str.isEmpty()) { - cir.setReturnValue(""); + cir.setReturnValue(0); return; } - int length = str.length(); + final BatchingFontRenderer batcher = angelica$getBatcher(); + final int length = str.length(); float currentWidth = 0.0f; - int lastSafePosition = 0; + int lastSafePosition = 0; // after full color code / normal char + int lastSpace = -1; // word wrap fallback boolean isBold = false; for (int i = 0; i < length; ) { - // Check for color codes (RGB or traditional) - int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, i); + // Hard line break: stop at '\n' + if (str.charAt(i) == '\n') { + cir.setReturnValue(i); + return; + } + // Color/format code + final int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, i); if (codeLen > 0) { - // This is a color code - skip it atomically (never split) - // But first check if we need to update bold state if (codeLen == 2 && i + 1 < length) { - char fmt = Character.toLowerCase(str.charAt(i + 1)); + final char fmt = Character.toLowerCase(str.charAt(i + 1)); if (fmt == 'l') { isBold = true; - } else if (fmt == 'r') { + } else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { + // vanilla resets styles on color code isBold = false; - } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { - isBold = false; // Color codes reset bold } } - i += codeLen; - lastSafePosition = i; // Can safely break after a complete color code + lastSafePosition = i; // never break inside codes continue; } - // Regular character - char c = str.charAt(i); - float charWidth = angelica$getBatcher().getCharWidthFine(c); + // Normal char + final char c = str.charAt(i); + if (c == ' ') + lastSpace = i; // word wrap point - if (charWidth < 0) { - // Formatting character outside of detected codes - charWidth = 0; - } + float charW = batcher.getCharWidthFine(c); + if (charW < 0) charW = 0; - float nextWidth = currentWidth + charWidth; - if (isBold && charWidth > 0) { - nextWidth += angelica$getBatcher().getShadowOffset(); - } + float next = currentWidth + charW; + if (isBold && charW > 0) next += batcher.getShadowOffset(); + next += batcher.getGlyphSpacing(); - if (nextWidth > maxWidth) { - // Would exceed width - return string up to last safe position - cir.setReturnValue(str.substring(0, lastSafePosition)); + if (next > maxWidth) { + // Prefer last space, then last safe position, else here + int bp = (lastSpace >= 0 ? lastSpace : lastSafePosition); + if (bp <= 0) bp = i; + cir.setReturnValue(bp); return; } - currentWidth = nextWidth; + currentWidth = next; i++; lastSafePosition = i; } // Entire string fits - cir.setReturnValue(str); + cir.setReturnValue(length); } /** @@ -323,67 +330,81 @@ public float getCharWidthFine(char chr) { return; } + final BatchingFontRenderer batcher = angelica$getBatcher(); + if (!reverse) { - // Forward direction - reuse sizeStringToWidth logic - angelica$sizeStringToWidthRgbAware(text, width, cir); + // Forward: reuse sizeStringToWidth index + CallbackInfoReturnable idxCir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); + angelica$sizeStringToWidthRgbAware(text, width, idxCir); + int idx = idxCir.getReturnValue(); + idx = Math.min(Math.max(idx, 0), text.length()); + cir.setReturnValue(text.substring(0, idx)); return; } - // Reverse direction - trim from the end - int length = text.length(); + // Reverse: trim from the end, stop at newline, include spacing/bold + final 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 (!com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isValidHexChar(hexChar)) { - validHex = false; - break; - } - } - if (validHex) { - // Skip the entire &RRGGBB - i -= 7; - firstSafePosition = i + 1; - continue; + final char c = text.charAt(i); + + // Hard line break (reverse): return from char after '\n' + if (c == '\n') { + cir.setReturnValue(text.substring(i + 1)); + return; + } + + // Simple reverse RGB &RRGGBB skip + if (i >= 6 && text.charAt(i - 6) == '&') { + boolean validHex = true; + for (int j = i - 5; j <= i; j++) { + if (!com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isValidHexChar(text.charAt(j))) { + validHex = false; break; } } + if (validHex) { + i -= 7; + firstSafePosition = i + 1; + continue; + } } - float charWidth = angelica$getBatcher().getCharWidthFine(c); - - if (charWidth < 0) { - charWidth = 0; + // Reverse scan: basic §X formatting impacts bold + if ((c == 167 || c == '&') && i + 1 < length) { + char fmt = Character.toLowerCase(text.charAt(i + 1)); + if (c == '&' && !com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isFormattingCode(text.charAt(i + 1))) { + // literal '&' + } else { + // we’re seeing the code second char first (reverse), so just toggle states safely: + if (fmt == 'l') isBold = true; + else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) isBold = false; + i--; // step over the pair + firstSafePosition = i + 1; + continue; + } } - float nextWidth = currentWidth + charWidth; - if (isBold && charWidth > 0) { - nextWidth += angelica$getBatcher().getShadowOffset(); - } + float charW = batcher.getCharWidthFine(c); + if (charW < 0) charW = 0; + + float next = currentWidth + charW; + if (isBold && charW > 0) next += batcher.getShadowOffset(); + next += batcher.getGlyphSpacing(); - if (nextWidth > width) { - // Would exceed width - return string from first safe position + if (next > width) { cir.setReturnValue(text.substring(firstSafePosition)); return; } - currentWidth = nextWidth; + currentWidth = next; i--; firstSafePosition = i + 1; } - // Entire string fits + // Entire string fits from start cir.setReturnValue(text); } @@ -414,7 +435,6 @@ public float getCharWidthFine(char chr) { cir.setReturnValue(""); return; } - cir.setReturnValue(angelica$wrapFormattedStringToWidth(str, wrapWidth)); } @@ -424,26 +444,19 @@ public float getCharWidthFine(char chr) { */ @Unique private String angelica$wrapFormattedStringToWidth(String str, int wrapWidth) { - // Use our RGB-aware sizeStringToWidth via mixin callback - CallbackInfoReturnable cir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); + CallbackInfoReturnable cir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); angelica$sizeStringToWidthRgbAware(str, wrapWidth, cir); - String sized = cir.getReturnValue(); - int breakPoint = sized.length(); + final int breakPoint = Math.max(0, Math.min(cir.getReturnValue(), str.length())); if (str.length() <= breakPoint) { - // Everything fits - return str; + return str; // Everything fits } 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 = angelica$extractFormatFromString(firstPart); - String remainder = formattingCodes + str.substring(breakPoint + (isSpaceOrNewline ? 1 : 0)); + final String firstPart = str.substring(0, breakPoint); + final char charAtBreak = str.charAt(breakPoint); + final boolean isSpaceOrNewline = (charAtBreak == ' ' || charAtBreak == '\n'); - // Recurse on remainder + final String formattingCodes = angelica$extractFormatFromString(firstPart); + final String remainder = formattingCodes + str.substring(breakPoint + (isSpaceOrNewline ? 1 : 0)); return firstPart + "\n" + angelica$wrapFormattedStringToWidth(remainder, wrapWidth); } } From a6a32de5bff1c9d49f71bbf564298bafae25d71b Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 24 Oct 2025 00:40:39 -0400 Subject: [PATCH 07/12] Fix Cursor Placements in other GUIs for absorbed codes and Bold Lettering --- .../client/font/BatchingFontRenderer.java | 26 +++++++--- .../angelica/client/font/ColorCodeUtils.java | 46 ++++++++--------- .../fontrenderer/MixinFontRenderer.java | 51 ++++++++++++------- 3 files changed, 74 insertions(+), 49 deletions(-) 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 47161b5fc..cb4203ca0 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java @@ -934,13 +934,13 @@ public float getStringWidthWithRgb(CharSequence str) { char ch = str.charAt(i); if (ch == '\n') { - if (width > maxWidth) - maxWidth = width; + if (width > maxWidth) maxWidth = width; width = 0.0f; - isBold = false; // matches vanilla behavior for width calc across lines + isBold = false; // vanilla-style reset across lines continue; } + // STRICT: only fully-formed color/format codes are zero-width int codeLen = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, i); if (codeLen > 0) { if (codeLen == 2 && i + 1 < str.length()) { @@ -951,20 +951,30 @@ public float getStringWidthWithRgb(CharSequence str) { isBold = false; } } - i += codeLen - 1; // skip entire code (loop will ++) + i += codeLen - 1; // skip whole token continue; } float charW = getCharWidthFine(ch); if (charW > 0) { width += charW; - if (isBold) - width += this.getShadowOffset(); - width += getGlyphSpacing(); + if (isBold) width += this.getShadowOffset(); + + // Add spacing only if a visible glyph follows on the same line + boolean nextVisibleSameLine = false; + int j = i + 1; + while (j < str.length()) { + char cj = str.charAt(j); + if (cj == '\n') break; + int n2 = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, j); // STRICT + if (n2 > 0) { j += n2; continue; } + if (getCharWidthFine(cj) > 0) nextVisibleSameLine = true; + break; + } + if (nextVisibleSameLine) width += getGlyphSpacing(); } } return Math.max(width, maxWidth); } - } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java index c48b46268..b2f2e2372 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -103,8 +103,8 @@ public static int parseHexColor(CharSequence str, int start) { * @param pos Position to check * @return Length of color code: * - 7 for &RRGGBB format (& + 6 hex) - * - 9 for format (< + 6 hex + >) - * - 10 for format () + * - 8 for format (< + 6 hex + >) + * - 9 for format () * - 2 for §X format (handled elsewhere, but counted here) * - 0 for no color code */ @@ -117,45 +117,45 @@ public static int detectColorCodeLengthIgnoringRaw(CharSequence str, int pos) { } private static int detectColorCodeLengthInternal(CharSequence str, int pos, boolean skipDueToRaw) { - if (str == null || pos < 0 || pos >= str.length()) { + if (str == null || pos < 0 || pos >= str.length()) return 0; - } - if (skipDueToRaw) { + if (skipDueToRaw) return 0; - } + final int len = str.length(); char c = str.charAt(pos); - // Check for §X format (traditional Minecraft) - if (c == 167 && pos + 1 < str.length()) { // 167 is § + // §x (traditional) + if (c == 167 && pos + 1 < len) { return 2; } - // Check for &RRGGBB format - if (c == '&' && pos + 7 <= str.length()) { + // &RRGGBB + if (c == '&' && pos + 7 <= len) { if (isValidHexString(str, pos + 1)) { - return 7; + return 7; // & + 6 hex } } - // Check for &X format (traditional formatting alias) - if (c == '&' && pos + 1 < str.length() && isFormattingCode(str.charAt(pos + 1))) { + // &x (alias for traditional formatting) + if (c == '&' && pos + 1 < len && 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 10; - } + // -> 9 chars total + // indices: pos:'<', pos+1:'/', pos+2..pos+7: 6 hex, pos+8:'>' + if (c == '<' && pos + 9 <= len && pos + 8 < len + && str.charAt(pos + 1) == '/' && str.charAt(pos + 8) == '>' + && 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 9; - } + // -> 8 chars total + // indices: pos:'<', pos+1..pos+6: 6 hex, pos+7:'>' + if (c == '<' && pos + 8 <= len && pos + 7 < len + && str.charAt(pos + 7) == '>' && isValidHexString(str, pos + 1)) { + return 8; } return 0; 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 5717239f7..6ac4c3f3f 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 @@ -267,13 +267,13 @@ public float getCharWidthFine(char chr) { boolean isBold = false; for (int i = 0; i < length; ) { - // Hard line break: stop at '\n' + // Hard line break if (str.charAt(i) == '\n') { cir.setReturnValue(i); return; } - // Color/format code + // STRICT color/format token final int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, i); if (codeLen > 0) { if (codeLen == 2 && i + 1 < length) { @@ -281,29 +281,39 @@ public float getCharWidthFine(char chr) { if (fmt == 'l') { isBold = true; } else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { - // vanilla resets styles on color code isBold = false; } } i += codeLen; - lastSafePosition = i; // never break inside codes + lastSafePosition = i; // never break inside tokens continue; } // Normal char final char c = str.charAt(i); if (c == ' ') - lastSpace = i; // word wrap point + lastSpace = i; float charW = batcher.getCharWidthFine(c); if (charW < 0) charW = 0; float next = currentWidth + charW; if (isBold && charW > 0) next += batcher.getShadowOffset(); - next += batcher.getGlyphSpacing(); + + // Add spacing only if another visible glyph follows on this line + boolean nextVisibleSameLine = false; + int j = i + 1; + while (j < length) { + char cj = str.charAt(j); + if (cj == '\n') break; + int n2 = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, j); // STRICT + if (n2 > 0) { j += n2; continue; } + if (batcher.getCharWidthFine(cj) > 0) nextVisibleSameLine = true; + break; + } + if (nextVisibleSameLine) next += batcher.getGlyphSpacing(); if (next > maxWidth) { - // Prefer last space, then last safe position, else here int bp = (lastSpace >= 0 ? lastSpace : lastSafePosition); if (bp <= 0) bp = i; cir.setReturnValue(bp); @@ -333,7 +343,7 @@ public float getCharWidthFine(char chr) { final BatchingFontRenderer batcher = angelica$getBatcher(); if (!reverse) { - // Forward: reuse sizeStringToWidth index + // Forward: reuse sizeStringToWidth index (now STRICT) CallbackInfoReturnable idxCir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); angelica$sizeStringToWidthRgbAware(text, width, idxCir); int idx = idxCir.getReturnValue(); @@ -342,7 +352,8 @@ public float getCharWidthFine(char chr) { return; } - // Reverse: trim from the end, stop at newline, include spacing/bold + // Reverse: trim from the end, stop at newline, include spacing/bold. + // IMPORTANT: do NOT treat partial = 0; ) { final char c = text.charAt(i); - // Hard line break (reverse): return from char after '\n' + // Stop at explicit newline if (c == '\n') { cir.setReturnValue(text.substring(i + 1)); return; } - // Simple reverse RGB &RRGGBB skip + // Reverse skip for &RRGGBB (easy to detect backwards) if (i >= 6 && text.charAt(i - 6) == '&') { boolean validHex = true; for (int j = i - 5; j <= i; j++) { @@ -372,13 +383,11 @@ public float getCharWidthFine(char chr) { } } - // Reverse scan: basic §X formatting impacts bold + // Reverse scan: basic §/& formatting impacts bold (STRICT for alias) if ((c == 167 || c == '&') && i + 1 < length) { - char fmt = Character.toLowerCase(text.charAt(i + 1)); - if (c == '&' && !com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isFormattingCode(text.charAt(i + 1))) { - // literal '&' - } else { - // we’re seeing the code second char first (reverse), so just toggle states safely: + char next = text.charAt(i + 1); + if (!(c == '&' && !com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isFormattingCode(next))) { + char fmt = Character.toLowerCase(next); if (fmt == 'l') isBold = true; else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) isBold = false; i--; // step over the pair @@ -387,6 +396,10 @@ public float getCharWidthFine(char chr) { } } + // NOTE: We DO NOT special-case partial " - push current colour (if any) then apply new colour - colorStack.push(currentColorCode); + if (currentColorCode != null) { + colorStack.push(currentColorCode); + } currentColorCode = code; styleCodes.setLength(0); } else if (codeLen == 10 && firstChar == '<') { From 626f02f4cd73a47efc0c99b6a176b4bc8f7801ea Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 24 Oct 2025 03:07:52 -0400 Subject: [PATCH 08/12] Reformat Code --- .../client/font/BatchingFontRenderer.java | 17 ++++++++++------- .../client/font/FontProviderCustom.java | 5 ++++- .../angelica/client/font/FontStrategist.java | 3 ++- 3 files changed, 16 insertions(+), 9 deletions(-) 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 cb4203ca0..a52b8ef80 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/BatchingFontRenderer.java @@ -778,9 +778,9 @@ else if ((i + 8) <= end && text.charAt(i + 7) == '>') { if (styleBold) { final float shadowDxBold = shadowDx + boldDx; // not 2 * boldDx - pushVtx(x0 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x0 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x0 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x0 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(x1 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); pushVtx(x1 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); pushQuadIdx(vStart + pushedQuads * 4); pushedQuads++; @@ -796,9 +796,9 @@ else if ((i + 8) <= end && text.charAt(i + 7) == '>') { pushedQuads++; if (styleBold) { - pushVtx(boldDx + x0 + italicOffset, yTop, currentColor, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(boldDx + x0 - italicOffset, yBottom, currentColor, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(boldDx + x1 + italicOffset, yTop, currentColor, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(boldDx + x0 + italicOffset, yTop, currentColor, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(boldDx + x0 - italicOffset, yBottom, currentColor, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); + pushVtx(boldDx + x1 + italicOffset, yTop, currentColor, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); pushVtx(boldDx + x1 - italicOffset, yBottom, currentColor, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); pushQuadIdx(vStart + pushedQuads * 4); pushedQuads++; @@ -967,7 +967,10 @@ public float getStringWidthWithRgb(CharSequence str) { char cj = str.charAt(j); if (cj == '\n') break; int n2 = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, j); // STRICT - if (n2 > 0) { j += n2; continue; } + if (n2 > 0) { + j += n2; + continue; + } if (getCharWidthFine(cj) > 0) nextVisibleSameLine = true; break; } 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 2ffd06781..d6e66686b 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontProviderCustom.java @@ -13,7 +13,10 @@ import org.apache.logging.log4j.Logger; import javax.imageio.ImageIO; -import java.awt.*; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; 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 e62b18961..30328cb28 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/FontStrategist.java @@ -9,7 +9,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.awt.*; +import java.awt.Font; +import java.awt.GraphicsEnvironment; import java.io.File; import java.util.Comparator; import java.util.HashMap; From f30e70ac0950554e8bc0fac580100642984e4eeb Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 24 Oct 2025 03:34:35 -0400 Subject: [PATCH 09/12] Remove Plug Editor --- dependencies.gradle | 1 - libs/plugeditor-1.3.jar | Bin 46505 -> 0 bytes 2 files changed, 1 deletion(-) delete mode 100644 libs/plugeditor-1.3.jar diff --git a/dependencies.gradle b/dependencies.gradle index e63e25228..4a6cd2cb8 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -101,7 +101,6 @@ dependencies { compileOnly(rfg.deobf("curse.maven:security-craft-64760:2818228")) runtimeOnlyNonPublishable(rfg.deobf("CoreTweaks:CoreTweaks:0.3.3.2")) - runtimeOnlyNonPublishable(rfg.deobf(files("libs/plugeditor-1.3.jar"))) // Hodgepodge transformedMod("net.industrial-craft:industrialcraft-2:2.2.828-experimental:dev") diff --git a/libs/plugeditor-1.3.jar b/libs/plugeditor-1.3.jar deleted file mode 100644 index 2a29217afcc883b859e3ec8a7cb8d0cfbeea851e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46505 zcmbTdQ;?=nuq9euwr$&HmtB9^wr!)!wrzIVwr$(Car?|Uabq54&cw_Y@x5iP+z%@w zbLY+_FAV~U3IquW3Dg;>rUmpreNaH4Kmbu?K{`oUF^2DPARrJRd1(m9|F8i5FEjc7 ztuyLBi~rvlASf#-CaR=N4-k6*Oisv1)6vhtNz+kJPtG(bGA^?09yvZdIl$H_QjSsq zi-Id^uNIo`UghJL~mqm;N+B?uwlC(fEe;KHYRBw zPE}k~oFoLu6p&ON+yf5s$0a0&5P|MGO+KhfuxZ{NubTj~>xZ$2lYk^zgxJkTJSb8G z6-uUF<9V2h`QaMv@%`Zhw?`oBKOg17gvuWerN#~GhHcclw&0`YKN;9&7XY=|;c^@w zzXLy#wbN@22;d|_F-a<*=^wYC23HLFt8}3(-T}u>4@JsMr9H|!Bsm2#In~pqa?sZ=pJw|BptJvCC1KKT=4OSaRm<7ZIEZn!83wu zB`L=|6oPLrS7b8e1gfVX>yC3>topc3Dz=!zJY2u*Bgk4Uw43H!5y~oB9B{iMs`@*F z_daeIce;U&(MKeUFYg-bh2|4v2Zjoa<;Iu{Vz5t zeb~**WAde{<-5We7FzGe*CS<>yvr!U5#dD?=D7W`r?H$0SY@h+T^pERb> zuF}U2#{_OACK9w=Pcg!QWl6tGA8pb45TUrntsYMsVymZV-uxb|&EOhX4M zPQ>YkGz3y1OakGI_AwQ9 z0s7yunE3y|;{RlAm#2q!($dloms_(LIY5iL|uE$J`TI3Q%uKhUsa zq*&>bgXvI_(p&rU>+ObG@Yt{t!2cD$xva-4EIJ b5a*PcVyLVFTU-KXFeoiy8aY0&zvkpy&3z~v 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 a(OXpk8q_a?f#HE(wV;)NJy2{gu>S+ Date: Mon, 27 Oct 2025 20:07:21 -0400 Subject: [PATCH 10/12] Complete Commit and Final Changes + Util Classes for GTNHLib or Future Port for Hodgepodge, etc Added Better Utility Handling within ForattedTextMetrics and ColorCodeUtils. Created an optional Strip Color Codes method for Universal Usage. --- .../client/font/BatchingFontRenderer.java | 745 ++++++++---------- .../angelica/client/font/ColorCodeUtils.java | 191 +++-- .../client/font/FormattedTextMetrics.java | 153 ++++ .../com/prupe/mcpatcher/hd/FontUtils.java | 79 +- .../discovery/ShaderpackDirectoryManager.java | 164 ++-- .../fontrenderer/MixinFontRenderer.java | 282 ++----- 6 files changed, 755 insertions(+), 859 deletions(-) create mode 100644 src/main/java/com/gtnewhorizons/angelica/client/font/FormattedTextMetrics.java 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 a52b8ef80..19646eba2 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 it.unimi.dsi.fastutil.objects.ObjectArrayList; import net.coderbot.iris.gl.program.Program; import net.coderbot.iris.gl.program.ProgramBuilder; @@ -22,10 +23,7 @@ import java.util.Comparator; import java.util.Objects; -import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAlloc; -import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAllocFloat; -import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memAllocInt; -import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.memRealloc; +import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.*; /** * A batching replacement for {@code FontRenderer} @@ -34,26 +32,18 @@ */ public class BatchingFontRenderer { - /** - * The underlying FontRenderer object that's being accelerated - */ + /** The underlying FontRenderer object that's being accelerated */ protected FontRenderer underlying; - /** - * Array of width of all the characters in default.png - */ + /** Array of width of all the characters in default.png */ protected int[] charWidth = new int[256]; - /** - * Array of the start/end column (in upper/lower nibble) for every glyph in the /font directory. - */ + /** Array of the start/end column (in upper/lower nibble) for every glyph in the /font directory. */ protected byte[] glyphWidth; /** * Array of RGB triplets defining the 16 standard chat colors followed by 16 darker version of the same colors for * drop shadows. */ private int[] colorCode; - /** - * Location of the primary font atlas to bind. - */ + /** Location of the primary font atlas to bind. */ protected final ResourceLocation locationFontTexture; private final int AAMode; @@ -66,7 +56,6 @@ public class BatchingFontRenderer { private static class FontAAShader { private static Program fontShader = null; - public static Program getProgram() { if (fontShader == null) { String vsh, fsh; @@ -126,9 +115,7 @@ public BatchingFontRenderer(FontRenderer underlying, int[] charWidth, byte[] gly private final ObjectArrayList batchCommands = ObjectArrayList.wrap(new FontDrawCmd[64], 0); private final ObjectArrayList batchCommandPool = ObjectArrayList.wrap(new FontDrawCmd[64], 0); - /** - * - */ + /** */ private void pushVtx(float x, float y, int rgba, float u, float v, float uMin, float uMax, float vMin, float vMax) { final int oldCap = batchVtxPositions.capacity() / 2; if (vtxWriterIndex >= oldCap) { @@ -275,7 +262,6 @@ public void endBatch() { int lastActiveProgram; int fontAAModeLast = -1; int fontAAStrengthLast = -1; - private void flushBatch() { // Sort&Draw batchCommands.sort(FontDrawCmd.DRAW_ORDER_COMPARATOR); @@ -388,456 +374,391 @@ public float getShadowOffset() { private static final char FORMATTING_CHAR = 167; // § - public float drawString( - final float startX, - final float startY, - final int baseColorARGB, - final boolean drawShadow, - final boolean unicodeFlag, - final CharSequence text, - int textOffset, - int textLen - ) { - // Fast exits - if (text == null || text.length() == 0) return startX + (drawShadow ? 1.0f : 0.0f); - - // Shadow color computed like vanilla - final int baseShadowARGB = (baseColorARGB & 0xFCFCFC) >> 2 | (baseColorARGB & 0xFF000000); - - // Inform providers of current font assets (vanilla atlas vs unicode pages) + 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) { + // noinspection SizeReplaceableByIsEmpty + if (string == null || string.length() == 0) { + return anchorX + (enableShadow ? 1.0f : 0.0f); + } + final int shadowColor = (color & 0xfcfcfc) >> 2 | color & 0xff000000; + FontProviderMC.get(this.isSGA).charWidth = this.charWidth; FontProviderMC.get(this.isSGA).locationFontTexture = this.locationFontTexture; - // Clamp the slice we’ll draw - final int totalLen = text.length(); - textOffset = MathHelper.clamp_int(textOffset, 0, totalLen); - textLen = MathHelper.clamp_int(textLen, 0, totalLen - textOffset); - if (textLen <= 0) return 0; - - // Per-line vertical metrics (derived from vanilla FONT_HEIGHT + Angelica scaling) - final float scaleY = getGlyphScaleY(); - final float lineHeight = (underlying.FONT_HEIGHT - 1.0f) * scaleY; - final float ascentY = startY + (underlying.FONT_HEIGHT - 1.0f) * (0.5f - scaleY / 2.0f); // top of glyphs - final float underlineYOffset = (underlying.FONT_HEIGHT - 1.0f) * scaleY; - final float strikethroughYOffset = ((underlying.FONT_HEIGHT / 2.0f) - 1.0f) * scaleY; - final float lineAdvance = underlying.FONT_HEIGHT; // vertical step between lines (vanilla baseline distance) - - // Dynamic drawing state - float penX = startX; - float lineYOffset = 0.0f; // how far we’ve moved down from the first line - - int currentColor = baseColorARGB; - int currentShadow = baseShadowARGB; - boolean styleItalic = false; - boolean styleRandom = false; - boolean styleBold = false; - boolean styleStrike = false; - boolean styleUnder = false; - boolean styleRainbow = false; - boolean styleFlip = false; // dinnerbone - - final float boldDx = getShadowOffset(); - - int rainbowStep = 0; - - // For nested RGB tag colors ( ... ) - final it.unimi.dsi.fastutil.ints.IntArrayList colorStack = new it.unimi.dsi.fastutil.ints.IntArrayList(); - final it.unimi.dsi.fastutil.ints.IntArrayList shadowStack = new it.unimi.dsi.fastutil.ints.IntArrayList(); - - // Underline / strikethrough segments on the current line - float underlineStartX = 0.0f, underlineEndX = 0.0f; - float strikeStartX = 0.0f, strikeEndX = 0.0f; - - // Editor “raw mode” highlighting support (unchanged behavior) - final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); - int rawTokenSkip = 0; - - beginBatch(); + this.beginBatch(); + float curX = anchorX; try { - final int end = textOffset + textLen; - - for (int i = textOffset; i < end; i++) { - char ch = text.charAt(i); - - // 1) Hard line break - if (ch == '\n') { - // Flush underline/strike for this line - if (styleUnder && underlineStartX != underlineEndX) { - final int idx = idxWriterIndex; - pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, - underlineEndX - underlineStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); - underlineStartX = underlineEndX = penX; - } - if (styleStrike && strikeStartX != strikeEndX) { - final int idx = idxWriterIndex; - pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, - strikeEndX - strikeStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); - strikeStartX = strikeEndX = penX; - } - - // Move pen to next line - lineYOffset += lineAdvance; - penX = startX; - continue; - } + final int totalStringLength = string.length(); + stringOffset = MathHelper.clamp_int(stringOffset, 0, totalStringLength); + stringLength = MathHelper.clamp_int(stringLength, 0, totalStringLength - stringOffset); + if (stringLength <= 0) { + return 0; + } + final int stringEnd = stringOffset + stringLength; + + final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); + int curColor = color; + int curShadowColor = shadowColor; + boolean curItalic = false; + boolean curRandom = false; + 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); + final float heightSouth = (underlying.FONT_HEIGHT - 1.0f) * glyphScaleY; + + final float underlineY = heightNorth + (underlying.FONT_HEIGHT - 1.0f) * glyphScaleY; + float underlineStartX = 0.0f; + float underlineEndX = 0.0f; + 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); + boolean processedRgbOrTag = false; - // 2) Raw-mode token highlight (unchanged, just renamed vars) if (rawMode) { if (rawTokenSkip > 0) { rawTokenSkip--; } else { - int tokenLen = ColorCodeUtils.detectColorCodeLengthIgnoringRaw(text, i); + int tokenLen = ColorCodeUtils.detectColorCodeLengthIgnoringRaw(string, charIdx); if (tokenLen > 0) { - float tokenW = angelica$measureLiteralWidth(text, i, tokenLen, end, unicodeFlag, styleBold); - if (tokenW > 0) { - final int idx = idxWriterIndex; - pushUntexRect(penX, ascentY + lineYOffset - 1.0f, - tokenW, lineHeight + 2.0f, - angelica$getTokenHighlightColor(text, i)); - pushDrawCmd(idx, 6, null, false); + 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); } } } - // 3) RGB and formatting codes - boolean consumedFormatting = false; - - // 3a) &RRGGBB - if (ch == '&' && (i + 6) < end) { - final int rgb = ColorCodeUtils.parseHexColor(text, i + 1); + // 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) { - // Close any active underline/strike segments before changing color - if (styleUnder && underlineStartX != underlineEndX) { - final int idx = idxWriterIndex; - pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, - underlineEndX - underlineStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); + // 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 (styleStrike && strikeStartX != strikeEndX) { - final int idx = idxWriterIndex; - pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, - strikeEndX - strikeStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); - strikeStartX = strikeEndX; + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(strikethroughStartX, strikethroughY, strikethroughEndX - strikethroughStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + strikethroughStartX = strikethroughEndX; } - // Apply new color and reset styles (vanilla behavior on color change) + // Apply RGB color (preserve formatting state to allow &l&FFxxxx patterns) colorStack.clear(); shadowStack.clear(); - currentColor = (currentColor & 0xFF000000) | (rgb & 0x00FFFFFF); - currentShadow = (currentShadow & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); - - styleRandom = false; - styleBold = false; - styleStrike = false; - styleUnder = false; - styleItalic = false; - styleRainbow = false; - styleFlip = false; - - consumedFormatting = true; + curColor = (curColor & 0xFF000000) | (rgb & 0x00FFFFFF); + curShadowColor = (curShadowColor & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); + + // reset styles on color change (vanilla behavior) + curRandom = false; + curBold = false; + curStrikethrough = false; + curUnderline = false; + curItalic = false; + curRainbow = false; + curDinnerbone = false; + + processedRgbOrTag = true; // Prevent traditional &X from overwriting + if (!rawMode) { - i += 6; + charIdx += 6; // Skip the 6 hex digits continue; } } } - // 3b) or - if (!consumedFormatting && ch == '<') { - // Close tag: - if ((i + 9) <= end && text.charAt(i + 1) == '/' && text.charAt(i + 8) == '>') { - if (ColorCodeUtils.isValidHexString(text, i + 2)) { - if (styleUnder && underlineStartX != underlineEndX) { - final int idx = idxWriterIndex; - pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, - underlineEndX - underlineStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); + // 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 (styleStrike && strikeStartX != strikeEndX) { - final int idx = idxWriterIndex; - pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, - strikeEndX - strikeStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); - strikeStartX = strikeEndX; + 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()) { - currentColor = colorStack.removeInt(colorStack.size() - 1); - currentShadow = shadowStack.removeInt(shadowStack.size() - 1); + curColor = colorStack.removeInt(colorStack.size() - 1); + curShadowColor = shadowStack.removeInt(shadowStack.size() - 1); } else { - currentColor = baseColorARGB; - currentShadow = baseShadowARGB; + curColor = color; + curShadowColor = shadowColor; } - styleRandom = false; - styleRainbow = false; - consumedFormatting = true; + curRandom = false; + curRainbow = false; + processedRgbOrTag = true; if (!rawMode) { - i += 8; + charIdx += 8; // Skip (9 chars total, but loop will increment) continue; } } } - // Open tag: - else if ((i + 8) <= end && text.charAt(i + 7) == '>') { - final int rgb = ColorCodeUtils.parseHexColor(text, i + 1); + // Check for opening tag + else if ((charIdx + 8) <= stringEnd && string.charAt(charIdx + 7) == '>') { + final int rgb = ColorCodeUtils.parseHexColor(string, charIdx + 1); if (rgb != -1) { - if (styleUnder && underlineStartX != underlineEndX) { - final int idx = idxWriterIndex; - pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, - underlineEndX - underlineStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); + // 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 (styleStrike && strikeStartX != strikeEndX) { - final int idx = idxWriterIndex; - pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, - strikeEndX - strikeStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); - strikeStartX = strikeEndX; + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(strikethroughStartX, strikethroughY, strikethroughEndX - strikethroughStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); + strikethroughStartX = strikethroughEndX; } - colorStack.add(currentColor); - shadowStack.add(currentShadow); - currentColor = (currentColor & 0xFF000000) | (rgb & 0x00FFFFFF); - currentShadow = (currentShadow & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); - - // Vanilla resets styles on color change - styleRandom = false; - styleBold = false; - styleStrike = false; - styleUnder = false; - styleItalic = false; - styleRainbow = false; - styleFlip = false; - - consumedFormatting = true; + 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) { - i += 7; + charIdx += 7; // Skip (8 chars total, but loop will increment) continue; } } } } - // 3c) Traditional (§) or alias (&) formatting codes - if (!consumedFormatting && (ch == FORMATTING_CHAR || ch == '&') && (i + 1) < end) { - final char next = text.charAt(i + 1); - final char fmt = Character.toLowerCase(next); - - // treat '&' as literal unless it's a valid formatting code - if (ch == '&' && !ColorCodeUtils.isFormattingCode(next)) { - // fall-through to render literal '&' + // 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 { - i++; // consume code - - // before changing styles, flush current underline/strike segments - if (styleUnder && underlineStartX != underlineEndX) { - final int idx = idxWriterIndex; - pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, - underlineEndX - underlineStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); + charIdx++; + + if (curUnderline && underlineStartX != underlineEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); underlineStartX = underlineEndX; } - if (styleStrike && strikeStartX != strikeEndX) { - final int idx = idxWriterIndex; - pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, - strikeEndX - strikeStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); - strikeStartX = strikeEndX; + 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 = (fmt >= '0' && fmt <= '9'); - final boolean isAF = (fmt >= 'a' && fmt <= 'f'); - + final boolean is09 = charInRange(fmtCode, '0', '9'); + final boolean isAF = charInRange(fmtCode, 'a', 'f'); if (is09 || isAF) { - // Vanilla: color sets RGB and resets all styles - final int colorIdx = is09 ? (fmt - '0') : (fmt - 'a' + 10); - currentColor = (currentColor & 0xFF000000) | (this.colorCode[colorIdx] & 0x00FFFFFF); - currentShadow = (currentShadow & 0xFF000000) | (this.colorCode[colorIdx + 16] & 0x00FFFFFF); - - styleRandom = false; - styleBold = false; - styleStrike = false; - styleUnder = false; - styleItalic = false; - styleRainbow = false; - styleFlip = false; - } else if (fmt == 'k') { - styleRandom = true; - } else if (fmt == 'l') { - styleBold = true; - } else if (fmt == 'm') { - styleStrike = true; - strikeStartX = penX - 1.0f; - strikeEndX = strikeStartX; - } else if (fmt == 'n') { - styleUnder = true; - underlineStartX = penX - 1.0f; + 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 (fmt == 'o') { - styleItalic = true; - } else if (fmt == 'g') { - styleRainbow = true; - rainbowStep = 0; - } else if (fmt == 'h') { - styleFlip = true; - } else if (fmt == 'r') { - styleRandom = false; - styleBold = false; - styleStrike = false; - styleUnder = false; - styleItalic = false; - styleRainbow = false; - styleFlip = false; - rainbowStep = 0; - currentColor = baseColorARGB; - currentShadow = baseShadowARGB; + } 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; // formatting consumed + continue; } else { - i--; // in rawMode we still draw the code char itself + // In raw mode, we still applied the formatting but need to back up charIdx + // so we render the formatting character + charIdx--; } } } - // 4) Random obfuscation (after formatting has been applied) - if (!rawMode && styleRandom) { - ch = FontProviderMC.get(this.isSGA).getRandomReplacement(ch); + if (!rawMode && curRandom) { + chr = FontProviderMC.get(this.isSGA).getRandomReplacement(chr); } - // 5) Space (ASCII / NBSP / NNBSP) → just advance penX - if (ch == ' ' || ch == '\u00A0' || ch == '\u202F') { - final float spaceAdvance = 4 * getWhitespaceScale() - + (styleBold ? boldDx : 0.0f) - + getGlyphSpacing(); - penX += spaceAdvance; - - // keep underline/strike segment ends in sync with caret movement - if (styleUnder) - underlineEndX = penX; - if (styleStrike) - strikeEndX = penX; + FontProvider fontProvider = FontStrategist.getFontProvider(chr, this.isSGA, FontConfig.enableCustomFont, unicodeFlag); + + // Check ASCII space, NBSP, NNBSP + if (chr == ' ' || chr == '\u00A0' || chr == '\u202F') { + curX += 4 * this.getWhitespaceScale(); continue; } - // 6) Lookup glyph metrics/texture and push quads - final FontProvider fp = FontStrategist.getFontProvider(ch, this.isSGA, FontConfig.enableCustomFont, unicodeFlag); - - // Rainbow (per glyph) - if (styleRainbow) { - float hue = (rainbowStep * 15.0f) % 360.0f; - int rgb = ColorCodeUtils.hsvToRgb(hue, 1.0f, 1.0f); - currentColor = (currentColor & 0xFF000000) | (rgb & 0x00FFFFFF); - currentShadow = (currentShadow & 0xFF000000) | ColorCodeUtils.calculateShadowColor(rgb); - rainbowStep++; + final float uStart = fontProvider.getUStart(chr); + final float vStart = fontProvider.getVStart(chr); + final float xAdvance = fontProvider.getXAdvance(chr) * getGlyphScaleX(); + final float glyphW = fontProvider.getGlyphW(chr) * getGlyphScaleX(); + final float uSz = fontProvider.getUSize(chr); + final float vSz = fontProvider.getVSize(chr); + final float itOff = curItalic ? 1.0F : 0.0F; // italic offset + 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++; } - final float u0 = fp.getUStart(ch); - final float v0 = fp.getVStart(ch); - final float uSize = fp.getUSize(ch); - final float vSize = fp.getVSize(ch); - final float advX = fp.getXAdvance(ch) * getGlyphScaleX(); - final float gw = fp.getGlyphW(ch) * getGlyphScaleX(); - final float italicOffset = styleItalic ? 1.0f : 0.0f; - final float shadowDx = fp.getShadowOffset(); - final ResourceLocation tex = fp.getTexture(ch); - - // Current baseline for this line - final float yTop = ascentY + lineYOffset; - final float yBottom = yTop + lineHeight; - - // Texture V flip for dinnerbone (flip texture only, not geometry Y) - final float vTop = styleFlip ? (v0 + vSize) : v0; - final float vBottom = styleFlip ? v0 : (v0 + vSize); - - final float x0 = penX; - final float x1 = penX + gw - 1.0f; - - // push vertices (shadow → normal → bold offset) - final int vStart = vtxWriterIndex; - final int iStart = idxWriterIndex; - int pushedQuads = 0; - - if (drawShadow) { - pushVtx(x0 + italicOffset + shadowDx, yTop + shadowDx, currentShadow, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x0 - italicOffset + shadowDx, yBottom + shadowDx, currentShadow, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 + italicOffset + shadowDx, yTop + shadowDx, currentShadow, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 - italicOffset + shadowDx, yBottom + shadowDx, currentShadow, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushQuadIdx(vStart + pushedQuads * 4); - pushedQuads++; - - if (styleBold) { - final float shadowDxBold = shadowDx + boldDx; // not 2 * boldDx - pushVtx(x0 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x0 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 + italicOffset + shadowDxBold, yTop + shadowDx, currentShadow, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 - italicOffset + shadowDxBold, yBottom + shadowDx, currentShadow, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushQuadIdx(vStart + pushedQuads * 4); - pushedQuads++; + // 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 + 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 + 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; } } - // Normal glyph - pushVtx(x0 + italicOffset, yTop, currentColor, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x0 - italicOffset, yBottom, currentColor, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 + italicOffset, yTop, currentColor, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(x1 - italicOffset, yBottom, currentColor, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushQuadIdx(vStart + pushedQuads * 4); - pushedQuads++; - - if (styleBold) { - pushVtx(boldDx + x0 + italicOffset, yTop, currentColor, u0, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(boldDx + x0 - italicOffset, yBottom, currentColor, u0, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(boldDx + x1 + italicOffset, yTop, currentColor, u0 + uSize, vTop, u0, u0 + uSize, v0, v0 + vSize); - pushVtx(boldDx + x1 - italicOffset, yBottom, currentColor, u0 + uSize, vBottom, u0, u0 + uSize, v0, v0 + vSize); - pushQuadIdx(vStart + pushedQuads * 4); - pushedQuads++; + 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 + 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; } - // Record draw for this glyph batch - pushDrawCmd(iStart, pushedQuads * 6, tex, ch > 255); - - // Advance caret (include spacing; bold adds an extra shadow offset like vanilla) - penX += (advX + (styleBold ? boldDx : 0.0f)) + getGlyphSpacing(); - - // Keep decoration extents in sync with caret - underlineEndX = penX; - strikeEndX = penX; + pushDrawCmd(idxId, vtxCount / 2 * 3, texture, chr > 255); + curX += (xAdvance + (curBold ? shadowOffset : 0.0f)) + getGlyphSpacing(); + underlineEndX = curX; + strikethroughEndX = curX; } - // 7) Flush remaining underline/strike on the last line - if (styleUnder && underlineStartX != underlineEndX) { - final int idx = idxWriterIndex; - pushUntexRect(underlineStartX, ascentY + lineYOffset + underlineYOffset, - underlineEndX - underlineStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); + if (curUnderline && underlineStartX != underlineEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect(underlineStartX, underlineY, underlineEndX - underlineStartX, glyphScaleY, curColor); + pushDrawCmd(ulIdx, 6, null, false); } - if (styleStrike && strikeStartX != strikeEndX) { - final int idx = idxWriterIndex; - pushUntexRect(strikeStartX, ascentY + lineYOffset + strikethroughYOffset, - strikeEndX - strikeStartX, scaleY, currentColor); - pushDrawCmd(idx, 6, null, false); + if (curStrikethrough && strikethroughStartX != strikethroughEndX) { + final int ulIdx = idxWriterIndex; + pushUntexRect( + strikethroughStartX, + strikethroughY, + strikethroughEndX - strikethroughStartX, + glyphScaleY, + curColor); + pushDrawCmd(ulIdx, 6, null, false); } } finally { - endBatch(); + this.endBatch(); } - - // Return the final pen position (matches vanilla’s “right edge”), with +1 if shadow was drawn. - return penX + (drawShadow ? 1.0f : 0.0f); + 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; @@ -893,9 +814,7 @@ else if ((i + 8) <= end && text.charAt(i + 7) == '>') { } public float getCharWidthFine(char chr) { - if (chr == FORMATTING_CHAR && !AngelicaFontRenderContext.isRawTextRendering()) { - 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() @@ -915,8 +834,8 @@ public float getCharWidthFine(char chr) { * This method correctly skips over: * - Traditional § codes (2 chars) * - &RRGGBB format (7 chars) - * - format (9 chars) - * - format (10 chars) + * - format (8 chars) + * - format (9 chars) * * @param str The string to measure * @return The width in pixels @@ -926,58 +845,8 @@ public float getStringWidthWithRgb(CharSequence str) { return 0.0f; } - float width = 0.0f, maxWidth = 0.0f; - boolean isBold = false; final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); - - for (int i = 0; i < str.length(); i++) { - char ch = str.charAt(i); - - if (ch == '\n') { - if (width > maxWidth) maxWidth = width; - width = 0.0f; - isBold = false; // vanilla-style reset across lines - continue; - } - - // STRICT: only fully-formed color/format codes are zero-width - int codeLen = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, i); - if (codeLen > 0) { - if (codeLen == 2 && i + 1 < str.length()) { - char fmt = Character.toLowerCase(str.charAt(i + 1)); - if (fmt == 'l') { - isBold = true; - } else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { - isBold = false; - } - } - i += codeLen - 1; // skip whole token - continue; - } - - float charW = getCharWidthFine(ch); - if (charW > 0) { - width += charW; - if (isBold) width += this.getShadowOffset(); - - // Add spacing only if a visible glyph follows on the same line - boolean nextVisibleSameLine = false; - int j = i + 1; - while (j < str.length()) { - char cj = str.charAt(j); - if (cj == '\n') break; - int n2 = rawMode ? 0 : ColorCodeUtils.detectColorCodeLength(str, j); // STRICT - if (n2 > 0) { - j += n2; - continue; - } - if (getCharWidthFine(cj) > 0) nextVisibleSameLine = true; - break; - } - if (nextVisibleSameLine) width += getGlyphSpacing(); - } - } - - return Math.max(width, maxWidth); + return FormattedTextMetrics.calculateMaxLineWidth(str, rawMode, this::getCharWidthFine, + getGlyphSpacing(), this.getShadowOffset()); } } diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java index b2f2e2372..546d008a4 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -1,5 +1,7 @@ package com.gtnewhorizons.angelica.client.font; +import java.util.ArrayDeque; + /** * Utility class for parsing RGB color codes in text. * Supports multiple formats: @@ -62,7 +64,6 @@ public static boolean isValidHexString(CharSequence str, int start) { /** * 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 */ @@ -79,8 +80,7 @@ public static int parseHexColor(String hex) { /** * Parse 6 hex characters from a CharSequence starting at position - * - * @param str The string to parse + * @param str The string to parse * @param start Starting position * @return RGB value as integer, or -1 if invalid */ @@ -102,11 +102,11 @@ public static int parseHexColor(CharSequence str, int start) { * @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 + * - 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()); @@ -117,45 +117,45 @@ public static int detectColorCodeLengthIgnoringRaw(CharSequence str, int pos) { } private static int detectColorCodeLengthInternal(CharSequence str, int pos, boolean skipDueToRaw) { - if (str == null || pos < 0 || pos >= str.length()) + if (str == null || pos < 0 || pos >= str.length()) { return 0; + } - if (skipDueToRaw) + if (skipDueToRaw) { return 0; + } - final int len = str.length(); char c = str.charAt(pos); - // §x (traditional) - if (c == 167 && pos + 1 < len) { + // Check for §X format (traditional Minecraft) + if (c == 167 && pos + 1 < str.length()) { // 167 is § return 2; } - // &RRGGBB - if (c == '&' && pos + 7 <= len) { + // Check for &RRGGBB format + if (c == '&' && pos + 7 <= str.length()) { if (isValidHexString(str, pos + 1)) { - return 7; // & + 6 hex + return 7; } } - // &x (alias for traditional formatting) - if (c == '&' && pos + 1 < len && isFormattingCode(str.charAt(pos + 1))) { + // Check for &X format (traditional formatting alias) + if (c == '&' && pos + 1 < str.length() && isFormattingCode(str.charAt(pos + 1))) { return 2; } - // -> 9 chars total - // indices: pos:'<', pos+1:'/', pos+2..pos+7: 6 hex, pos+8:'>' - if (c == '<' && pos + 9 <= len && pos + 8 < len - && str.charAt(pos + 1) == '/' && str.charAt(pos + 8) == '>' - && isValidHexString(str, pos + 2)) { - return 9; + // 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; + } } - // -> 8 chars total - // indices: pos:'<', pos+1..pos+6: 6 hex, pos+7:'>' - if (c == '<' && pos + 8 <= len && pos + 7 < len - && str.charAt(pos + 7) == '>' && isValidHexString(str, pos + 1)) { - return 8; + // Check for format (opening tag) + if (c == '<' && pos + 8 <= str.length() && str.charAt(pos + 7) == '>') { + if (isValidHexString(str, pos + 1)) { + return 8; + } } return 0; @@ -175,9 +175,9 @@ public static int calculateShadowColor(int rgb) { /** * Convert HSV (Hue, Saturation, Value) color to RGB. * - * @param hue Hue in degrees (0-360) + * @param hue Hue in degrees (0-360) * @param saturation Saturation (0.0-1.0) - * @param value Value/Brightness (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) { @@ -202,36 +202,12 @@ public static int hsvToRgb(float hue, float saturation, float value) { 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 + 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); @@ -240,4 +216,97 @@ public static int hsvToRgb(float hue, float saturation, float value) { 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 == '<') { + 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/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/prupe/mcpatcher/hd/FontUtils.java b/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java index f6e39d52e..60e94f611 100644 --- a/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java +++ b/src/main/java/com/prupe/mcpatcher/hd/FontUtils.java @@ -87,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++) { @@ -181,72 +181,26 @@ public static float getStringWidthf(FontRenderer fontRenderer, String s) { if (s != null) { boolean isLink = false; final boolean rawMode = AngelicaFontRenderContext.isRawTextRendering(); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - - // Check for RGB color codes first (&RRGGBB, , ) - // These need to be skipped entirely for width calculation - if (!rawMode && c == '&' && i + 6 < s.length()) { - // Check for &RRGGBB format - boolean validHex = true; - for (int j = 1; j <= 6; j++) { - char hexChar = s.charAt(i + j); - if (!((hexChar >= '0' && hexChar <= '9') || - (hexChar >= 'a' && hexChar <= 'f') || - (hexChar >= 'A' && hexChar <= 'F'))) { - validHex = false; - break; - } - } - if (validHex) { - i += 6; // Skip the entire &RRGGBB code - continue; - } - } else if (!rawMode && c == '<' && i + 9 <= s.length() && s.charAt(i + 1) == '/' && s.charAt(i + 8) == '>') { - // Check for format - boolean validHex = true; - for (int j = 2; j <= 7; j++) { - char hexChar = s.charAt(i + j); - if (!((hexChar >= '0' && hexChar <= '9') || - (hexChar >= 'a' && hexChar <= 'f') || - (hexChar >= 'A' && hexChar <= 'F'))) { - validHex = false; - break; - } - } - if (validHex) { - i += 8; // Skip the entire tag - continue; - } - } else if (!rawMode && c == '<' && i + 8 <= s.length() && s.charAt(i + 7) == '>') { - // Check for format - boolean validHex = true; - for (int j = 1; j <= 6; j++) { - char hexChar = s.charAt(i + j); - if (!((hexChar >= '0' && hexChar <= '9') || - (hexChar >= 'a' && hexChar <= 'f') || - (hexChar >= 'A' && hexChar <= 'F'))) { - validHex = false; - break; + 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; + } } - } - if (validHex) { - i += 7; // Skip the entire tag + i += codeLen; continue; } - } else if (!rawMode && c == '&' && i < s.length() - 1 && ColorCodeUtils.isFormattingCode(s.charAt(i + 1))) { - char fmt = Character.toLowerCase(s.charAt(++i)); - if (fmt == 'l') { - isLink = true; - } else if (fmt == 'r') { - isLink = false; - } else if ((fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { - isLink = false; - } - continue; } - // Handle traditional & formatting codes + char c = s.charAt(i); float cWidth = getCharWidthf(fontRenderer, c); if (!rawMode && cWidth < 0.0f && i < s.length() - 1) { i++; @@ -262,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 6ac4c3f3f..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,13 +1,15 @@ 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; import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.client.settings.GameSettings; -import net.minecraft.util.MathHelper; import net.minecraft.util.ResourceLocation; import org.lwjgl.opengl.GL11; import org.spongepowered.asm.mixin.Final; @@ -21,7 +23,6 @@ import org.spongepowered.asm.mixin.injection.ModifyConstant; import org.spongepowered.asm.mixin.injection.Constant; -import java.util.ArrayDeque; import java.util.Random; /** @@ -121,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); } @@ -178,9 +179,7 @@ public abstract class MixinFontRenderer implements FontRendererAccessor, IFontPa GL11.glColor4f(1.0f, 1.0f, 1.0f, 1.0f); this.posX = (float)x; this.posY = (float)y; - - float adv = angelica$batcher.drawString(x, y, argb, dropShadow, unicodeFlag, text, 0, text.length()); - return MathHelper.ceiling_float_int(adv); + return (int) angelica$batcher.drawString(x, y, argb, dropShadow, unicodeFlag, text, 0, text.length()); } } @@ -199,8 +198,7 @@ public abstract class MixinFontRenderer implements FontRendererAccessor, IFontPa @Inject(method = "getCharWidth", at = @At("HEAD"), cancellable = true) public void getCharWidth(char c, CallbackInfoReturnable cir) { - float w = angelica$getBatcher().getCharWidthFine(c); - cir.setReturnValue(MathHelper.ceiling_float_int(w)); + cir.setReturnValue((int) angelica$getBatcher().getCharWidthFine(c)); } /** @@ -214,7 +212,7 @@ public void getCharWidth(char c, CallbackInfoReturnable cir) { cir.setReturnValue(0); return; } - cir.setReturnValue(MathHelper.ceiling_float_int(angelica$getBatcher().getStringWidthWithRgb(text))); + cir.setReturnValue((int) angelica$getBatcher().getStringWidthWithRgb(text)); } @Override @@ -249,84 +247,23 @@ public float getCharWidthFine(char chr) { /** * Intercept sizeStringToWidth to properly handle RGB color codes. - * This method finds the substring that fits within the given width. + * 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) { - if (str == null || str.isEmpty()) { - cir.setReturnValue(0); - return; - } - - final BatchingFontRenderer batcher = angelica$getBatcher(); - final int length = str.length(); - float currentWidth = 0.0f; - int lastSafePosition = 0; // after full color code / normal char - int lastSpace = -1; // word wrap fallback - boolean isBold = false; - - for (int i = 0; i < length; ) { - // Hard line break - if (str.charAt(i) == '\n') { - cir.setReturnValue(i); - return; - } - - // STRICT color/format token - final int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, i); - if (codeLen > 0) { - if (codeLen == 2 && i + 1 < length) { - final char fmt = Character.toLowerCase(str.charAt(i + 1)); - if (fmt == 'l') { - isBold = true; - } else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) { - isBold = false; - } - } - i += codeLen; - lastSafePosition = i; // never break inside tokens - continue; - } - - // Normal char - final char c = str.charAt(i); - if (c == ' ') - lastSpace = i; - - float charW = batcher.getCharWidthFine(c); - if (charW < 0) charW = 0; - - float next = currentWidth + charW; - if (isBold && charW > 0) next += batcher.getShadowOffset(); - - // Add spacing only if another visible glyph follows on this line - boolean nextVisibleSameLine = false; - int j = i + 1; - while (j < length) { - char cj = str.charAt(j); - if (cj == '\n') break; - int n2 = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLength(str, j); // STRICT - if (n2 > 0) { j += n2; continue; } - if (batcher.getCharWidthFine(cj) > 0) nextVisibleSameLine = true; - break; - } - if (nextVisibleSameLine) next += batcher.getGlyphSpacing(); - - if (next > maxWidth) { - int bp = (lastSpace >= 0 ? lastSpace : lastSafePosition); - if (bp <= 0) bp = i; - cir.setReturnValue(bp); - return; - } + cir.setReturnValue(angelica$computeSizeStringToWidthIndex(str, maxWidth)); + } - currentWidth = next; - i++; - lastSafePosition = i; + @Unique + private int angelica$computeSizeStringToWidthIndex(String str, int maxWidth) { + if (str == null || str.isEmpty() || maxWidth <= 0) { + return 0; } - // Entire string fits - cir.setReturnValue(length); + return FormattedTextMetrics.computeLineBreakIndex(str, maxWidth, + AngelicaFontRenderContext.isRawTextRendering(), angelica$getBatcher()::getCharWidthFine, + angelica$getBatcher().getGlyphSpacing(), angelica$getBatcher().getShadowOffset()); } /** @@ -340,84 +277,73 @@ public float getCharWidthFine(char chr) { return; } - final BatchingFontRenderer batcher = angelica$getBatcher(); - if (!reverse) { - // Forward: reuse sizeStringToWidth index (now STRICT) - CallbackInfoReturnable idxCir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); - angelica$sizeStringToWidthRgbAware(text, width, idxCir); - int idx = idxCir.getReturnValue(); - idx = Math.min(Math.max(idx, 0), text.length()); - cir.setReturnValue(text.substring(0, idx)); + // Forward direction - reuse sizeStringToWidth logic + int endIndex = angelica$computeSizeStringToWidthIndex(text, width); + cir.setReturnValue(text.substring(0, Math.min(endIndex, text.length()))); return; } - // Reverse: trim from the end, stop at newline, include spacing/bold. - // IMPORTANT: do NOT treat partial = 0; ) { - final char c = text.charAt(i); + // 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; + } + } + } - // Stop at explicit newline if (c == '\n') { cir.setReturnValue(text.substring(i + 1)); return; } - // Reverse skip for &RRGGBB (easy to detect backwards) - if (i >= 6 && text.charAt(i - 6) == '&') { - boolean validHex = true; - for (int j = i - 5; j <= i; j++) { - if (!com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isValidHexChar(text.charAt(j))) { - validHex = false; break; - } - } - if (validHex) { - i -= 7; - firstSafePosition = i + 1; - continue; - } - } + float charWidth = angelica$getBatcher().getCharWidthFine(c); - // Reverse scan: basic §/& formatting impacts bold (STRICT for alias) - if ((c == 167 || c == '&') && i + 1 < length) { - char next = text.charAt(i + 1); - if (!(c == '&' && !com.gtnewhorizons.angelica.client.font.ColorCodeUtils.isFormattingCode(next))) { - char fmt = Character.toLowerCase(next); - if (fmt == 'l') isBold = true; - else if (fmt == 'r' || (fmt >= '0' && fmt <= '9') || (fmt >= 'a' && fmt <= 'f')) isBold = false; - i--; // step over the pair - firstSafePosition = i + 1; - continue; - } + if (charWidth < 0) { + charWidth = 0; } - // NOTE: We DO NOT special-case partial " 0) next += batcher.getShadowOffset(); - next += batcher.getGlyphSpacing(); + float nextWidth = currentWidth + charWidth; + if (isBold && charWidth > 0) { + nextWidth += angelica$getBatcher().getShadowOffset(); + } - if (next > width) { + if (nextWidth > width) { + // Would exceed width - return string from first safe position cir.setReturnValue(text.substring(firstSafePosition)); return; } - currentWidth = next; + currentWidth = nextWidth; i--; firstSafePosition = i + 1; } - // Entire string fits from start + // Entire string fits cir.setReturnValue(text); } @@ -448,6 +374,7 @@ public float getCharWidthFine(char chr) { cir.setReturnValue(""); return; } + cir.setReturnValue(angelica$wrapFormattedStringToWidth(str, wrapWidth)); } @@ -457,89 +384,24 @@ public float getCharWidthFine(char chr) { */ @Unique private String angelica$wrapFormattedStringToWidth(String str, int wrapWidth) { - CallbackInfoReturnable cir = new CallbackInfoReturnable<>("angelica$sizeStringToWidth", true); - angelica$sizeStringToWidthRgbAware(str, wrapWidth, cir); - final int breakPoint = Math.max(0, Math.min(cir.getReturnValue(), str.length())); + int breakPoint = angelica$computeSizeStringToWidthIndex(str, wrapWidth); - if (str.length() <= breakPoint) { - return str; // Everything fits + if (breakPoint >= str.length()) { + // Everything fits + return str; } else { - final String firstPart = str.substring(0, breakPoint); - final char charAtBreak = str.charAt(breakPoint); - final boolean isSpaceOrNewline = (charAtBreak == ' ' || charAtBreak == '\n'); - - final String formattingCodes = angelica$extractFormatFromString(firstPart); - final String remainder = formattingCodes + str.substring(breakPoint + (isSpaceOrNewline ? 1 : 0)); - return firstPart + "\n" + angelica$wrapFormattedStringToWidth(remainder, wrapWidth); - } - } - - /** - * RGB-aware version of vanilla's getFormatFromString. - * Extracts active formatting codes from a string (RGB colors + traditional formatting). - */ - @Unique - private static String angelica$extractFormatFromString(String str) { - String currentColorCode = null; - StringBuilder styleCodes = new StringBuilder(); - ArrayDeque colorStack = new ArrayDeque<>(); - - for (int i = 0; i < str.length(); ) { - int codeLen = com.gtnewhorizons.angelica.client.font.ColorCodeUtils.detectColorCodeLengthIgnoringRaw(str, i); - - if (codeLen > 0) { - char firstChar = str.charAt(i); - String code = str.substring(i, i + codeLen); - - if (codeLen == 7 && firstChar == '&') { - // &RRGGBB - inline RGB colour, clears prior colour stack - currentColorCode = code; - colorStack.clear(); - styleCodes.setLength(0); - } else if (codeLen == 9 && firstChar == '<') { - // - push current colour (if any) then apply new colour - if (currentColorCode != null) { - colorStack.push(currentColorCode); - } - currentColorCode = code; - styleCodes.setLength(0); - } else if (codeLen == 10 && firstChar == '<') { - // - pop back to previous colour (or none) - currentColorCode = colorStack.isEmpty() ? null : colorStack.pop(); - styleCodes.setLength(0); - } else if (codeLen == 2) { - // Traditional formatting code - 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; - } + // Need to wrap + String firstPart = str.substring(0, breakPoint); + char charAtBreak = str.charAt(breakPoint); + boolean isSpaceOrNewline = charAtBreak == ' ' || charAtBreak == '\n'; - i++; - } + // Extract formatting codes from first part and prepend to remainder + String formattingCodes = ColorCodeUtils.extractFormatFromString(firstPart); + String remainder = formattingCodes + str.substring(breakPoint + (isSpaceOrNewline ? 1 : 0)); - StringBuilder result = new StringBuilder(); - if (currentColorCode != null) { - result.append(currentColorCode); - } - if (styleCodes.length() > 0) { - result.append(styleCodes); + // Recurse on remainder + return firstPart + "\n" + angelica$wrapFormattedStringToWidth(remainder, wrapWidth); } - - return result.toString(); } @Inject(method = "getFormatFromString", at = @At("HEAD"), cancellable = true) @@ -549,6 +411,6 @@ public float getCharWidthFine(char chr) { return; } - cir.setReturnValue(angelica$extractFormatFromString(text)); + cir.setReturnValue(ColorCodeUtils.extractFormatFromString(text)); } } From b870f17c9a33c63f829479835206a4ee2bd4c90b Mon Sep 17 00:00:00 2001 From: Kam Date: Mon, 27 Oct 2025 21:39:31 -0400 Subject: [PATCH 11/12] Add a quick null guard back --- .../gtnewhorizons/angelica/client/font/ColorCodeUtils.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java index 546d008a4..717764bdd 100644 --- a/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java +++ b/src/main/java/com/gtnewhorizons/angelica/client/font/ColorCodeUtils.java @@ -244,7 +244,9 @@ public static String extractFormatFromString(String str) { colorStack.clear(); styleCodes.setLength(0); } else if (codeLen == 8 && firstChar == '<') { - colorStack.push(currentColorCode); + if (currentColorCode != null) { + colorStack.push(currentColorCode); + } currentColorCode = code; styleCodes.setLength(0); } else if (codeLen == 9 && firstChar == '<') { From 4d1de57ceb36bd6d3bd69f16aa899b10b945c540 Mon Sep 17 00:00:00 2001 From: Kam Date: Tue, 28 Oct 2025 03:35:12 -0400 Subject: [PATCH 12/12] Update gradle.properties --- gradle.properties | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index af0df2adf..12e5b63f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -96,7 +96,9 @@ usesMixinDebug = false # Specify the location of your implementation of IMixinConfigPlugin. Leave it empty otherwise. mixinPlugin = -# Specify the package that contains all of your Mixins. You may only place Mixins in this package or the build will fail! +# Specify the package that contains all of your Mixins. The package must exist or +# the build will fail. If you have a package property defined in your mixins..json, +# it must match with this or the build will fail. mixinsPackage = mixins # Specify the core mod entry class if you use a core mod. This class must implement IFMLLoadingPlugin!