package fr.pandacube.lib.chat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TranslatableComponent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration.State; import net.md_5.bungee.api.ChatColor; import fr.pandacube.lib.chat.Chat.FormatableChat; /** * Provides various methods and properties to manipulate text displayed in chat an other parts of the game. */ public class ChatUtil { /* * Note : this field is for easy listing of all characters with special sizes. It will all be reported to * #CHAR_SIZES on class initialization for optimization. */ private static final Map SIZE_CHARS_MAPPING = Map.ofEntries( Map.entry(-6, "§"), Map.entry(2, "!.,:;i|¡'"), Map.entry(3, "`lìí’‘"), Map.entry(4, " I[]tï×"), Map.entry(5, "\"()*<>fk{}"), Map.entry(7, "@~®©«»"), Map.entry(9, "├└") ); /** * The default text pixel width for a character in the default Minecraft font. * If a character has another width, it should be found in {@link #CHAR_SIZES}. */ public static final int DEFAULT_CHAR_SIZE = 6; /** * Mapping indicating the text pixel with for specific characters in the default Minecraft font. * If a character doesn’t have a mapping in this map, then its width is {@link #DEFAULT_CHAR_SIZE}. */ public static final Map CHAR_SIZES; static { Map charSizes = new HashMap<>(); for (var e : SIZE_CHARS_MAPPING.entrySet()) { int size = e.getKey(); for (char c : e.getValue().toCharArray()) { charSizes.put(c, size); } } CHAR_SIZES = Collections.unmodifiableMap(charSizes); } /** * The default width of the Minecraft Java Edition chat window, in text pixels. */ public static final int DEFAULT_CHAT_WIDTH = 320; /** * The width of a Minecraft sign, in text pixels. */ public static final int SIGN_WIDTH = 90; /** * The width of a Minecraft book content, in text pixels. */ public static final int BOOK_WIDTH = 116; /** * The width of a Minecraft server MOTD message, in text pixels. */ public static final int MOTD_WIDTH = 270; /** * The width of a Minecraft Bedrock Edition form button, in text pixels. */ public static final int BEDROCK_FORM_WIDE_BUTTON = 178; /** * The default number of character per lines for the console. */ public static final int CONSOLE_NB_CHAR_DEFAULT = 50; /** * Create a page navigator with clickable page numbers for the chat. * @param prefix the text to put before the * @param cmdFormat the command with %d inside to be replaced with the page number (must start with slash) * @param currentPage the current page number (it is highlighted, and the pages around are displayed, according to * {@code nbPagesToDisplay}). * @param nbPages the number of pages. * @param nbPagesToDisplay the number of pages to display around the first page, the last page and the * {@code currentPage}. * @return a {@link Chat} containging the created page navigator. */ public static Chat createPagination(String prefix, String cmdFormat, int currentPage, int nbPages, int nbPagesToDisplay) { Set pagesToDisplay = new TreeSet<>(); for (int i = 0; i < nbPagesToDisplay && i < nbPages && nbPages - i > 0; i++) { pagesToDisplay.add(i + 1); pagesToDisplay.add(nbPages - i); } for (int i = currentPage - nbPagesToDisplay + 1; i < currentPage + nbPagesToDisplay; i++) { if (i > 0 && i <= nbPages) pagesToDisplay.add(i); } Chat d = ChatStatic.chat().thenLegacyText(prefix); boolean first = true; int previous = 0; for (int page : pagesToDisplay) { if (!first) { if (page == previous + 1) { d.thenText(" "); } else { if (cmdFormat.endsWith("%d")) { d.thenText(" "); d.thenCommandSuggest(Chat.text("..."), cmdFormat.substring(0, cmdFormat.length() - 2), Chat.text("Choisir la page")); d.thenText(" "); } else d.thenText(" ... "); } } else first = false; FormatableChat pDisp = Chat.clickableCommand(Chat.text(page), String.format(cmdFormat, page), Chat.text("Aller à la page " + page)); if (page == currentPage) { pDisp.highlightedCommandColor(); } d.then(pDisp); previous = page; } return d; } /* package */ static String repeatedChar(char repeatedChar, int count) { char[] c = new char[count]; Arrays.fill(c, repeatedChar); return new String(c); } /** * Compute the width of the provided component. * @param component the component to compute the width. * @param console true to compute the width when displayed on console (so it will count the characters), * false to compute the width when displayed in game (so it will count the pixels). * @return the width of the provided component. */ public static int componentWidth(Component component, boolean console) { return componentWidth(component, console, false); } /** * Compute the width of the provided component, with extra information about the parent component. * @param component the component to compute the width. * @param console true to compute the width when displayed on console (so it will count the characters), * false to compute the width when displayed in game (so it will count the pixels). * @param parentBold if the component inherits a bold styling from an eventual parent component. * @return the width of the provided component. */ public static int componentWidth(Component component, boolean console, boolean parentBold) { if (component == null) return 0; int count = 0; State currentBold = component.style().decoration(TextDecoration.BOLD); boolean actuallyBold = childBold(parentBold, currentBold); if (component instanceof TextComponent) { count += strWidth(((TextComponent)component).content(), console, actuallyBold); } else if (component instanceof TranslatableComponent) { for (Component c : ((TranslatableComponent)component).args()) count += componentWidth(c, console, actuallyBold); } for (Component c : component.children()) count += componentWidth(c, console, actuallyBold); return count; } private static boolean childBold(boolean parent, TextDecoration.State child) { return (parent && child != State.FALSE) || (!parent && child == State.TRUE); } /** * Compute the width of the provided text. * @param str the text to compute the width. * @param console true to compute the width when displayed on console (so it will count the characters), * false to compute the width when displayed in game (so it will count the pixels). * @param bold if the text is bold (may change its width). * @return the width of the provided text. */ public static int strWidth(String str, boolean console, boolean bold) { int count = 0; for (char c : str.toCharArray()) count += charW(c, console, bold); return Math.max(count, 0); } /** * Compute the width of the provided character. *

* It uses the mapping in {@link #CHAR_SIZES} for in-game display. For console, every character is size 1. * The {@code §} character is treated has a negative value, to make legacy codes take 0 width. * @param c the character to compute the width. * @param console true to compute the width when displayed on console (so it will count the characters), * false to compute the width when displayed in game (so it will count the pixels). * @param bold if the character is bold (may change its width). * @return the width of the provided character. */ public static int charW(char c, boolean console, boolean bold) { if (console) return (c == '§') ? -1 : 1; return CHAR_SIZES.getOrDefault(c, DEFAULT_CHAR_SIZE) + (bold ? 1 : 0); } /** * Wraps the provided text in multiple lines, taking into account the legacy formating. *

* This method only takes into account IG text width. Use a regular text-wrapper for console instead. * @param legacyText the text to wrap. * @param pixelWidth the width in which the text must fit. * @return the wrapped text in a {@link List} of {@link Chat} components. */ public static List wrapInLimitedPixelsToChat(String legacyText, int pixelWidth) { return wrapInLimitedPixels(legacyText, pixelWidth).stream() .map(ChatStatic::legacyText) .collect(Collectors.toList()); } /** * Wraps the provided text in multiple lines, taking into account the legacy formating. *

* This method only takes into account IG text width. Use a regular text-wrapper for console instead. * @param legacyText the text to wrap. * @param pixelWidth the width in which the text must fit. * @return the wrapped text in a {@link List} of line. */ public static List wrapInLimitedPixels(String legacyText, int pixelWidth) { List lines = new ArrayList<>(); legacyText += "\n"; // workaround to force algorithm to compute last lines; String currentLine = ""; int currentLineSize = 0; int index = 0; StringBuilder currentWord = new StringBuilder(); int currentWordSize = 0; boolean bold = false; boolean firstCharCurrentWordBold = false; do { char c = legacyText.charAt(index); if (c == ChatColor.COLOR_CHAR && index < legacyText.length() - 1) { currentWord.append(c); c = legacyText.charAt(++index); currentWord.append(c); if (c == 'l' || c == 'L') // bold bold = true; if ((c >= '0' && c <= '9') // reset bold || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c == 'r' || c == 'R' || c == 'x' || c == 'X') bold = false; } else if (c == ' ') { if (currentLineSize + currentWordSize > pixelWidth && currentLineSize > 0) { // wrap before word lines.add(currentLine); String lastStyle = ChatColorUtil.getLastColors(currentLine); if (currentWord.charAt(0) == ' ') { currentWord = new StringBuilder(currentWord.substring(1)); currentWordSize -= charW(' ', false, firstCharCurrentWordBold); } currentLine = (lastStyle.equals("§r") ? "" : lastStyle) + currentWord; currentLineSize = currentWordSize; } else { currentLine += currentWord; currentLineSize += currentWordSize; } currentWord = new StringBuilder("" + c); currentWordSize = charW(c, false, bold); firstCharCurrentWordBold = bold; } else if (c == '\n') { if (currentLineSize + currentWordSize > pixelWidth && currentLineSize > 0) { // wrap before word lines.add(currentLine); String lastStyle = ChatColorUtil.getLastColors(currentLine); if (currentWord.charAt(0) == ' ') { currentWord = new StringBuilder(currentWord.substring(1)); } currentLine = (lastStyle.equals("§r") ? "" : lastStyle) + currentWord; } else { currentLine += currentWord; } // wrap after lines.add(currentLine); String lastStyle = ChatColorUtil.getLastColors(currentLine); currentLine = lastStyle.equals("§r") ? "" : lastStyle; currentLineSize = 0; currentWord = new StringBuilder(); currentWordSize = 0; firstCharCurrentWordBold = bold; } else { currentWord.append(c); currentWordSize += charW(c, false, bold); } } while(++index < legacyText.length()); return lines; } /** * Try to render a matrix of {@link Chat} components into a table in the chat or console. * @param data the component, in the form of {@link List} of {@link List} of {@link Chat}. The englobing list holds * the table lines (line 0 being the top line). Each sublist holds the cells content (element 0 is the * leftText one). The row lengths can be different. * @param space a spacer to put between columns. * @param console true to display the table on the console (character alignement), false in game chat (pixel * alignment, much harder). * @return a List containing each rendered line of the table. */ public static List renderTable(List> data, String space, boolean console) { List> compRows = new ArrayList<>(data.size()); for (List row : data) { List compRow = new ArrayList<>(row.size()); for (Chat c : row) { compRow.add(c.getAdv()); } compRows.add(compRow); } return renderTableComp(compRows, space, console); } /** * Try to render a matrix of {@link Component} components into a table in the chat or console. * @param data the component, in the form of {@link List} of {@link List} of {@link Component}. The englobing list holds * the table lines (line 0 being the top line). Each sublist holds the cells content (element 0 is the * leftText one). The row lengths can be different. * @param space a spacer to put between columns. * @param console true to display the table on the console (character alignement), false in game chat (pixel * alignment, much harder). * @return a List containing each rendered line of the table. */ public static List renderTableComp(List> data, String space, boolean console) { // determine columns width List nbPixelPerColumn = new ArrayList<>(); for (List row : data) { for (int i = 0; i < row.size(); i++) { int w = componentWidth(row.get(i), console); if (nbPixelPerColumn.size() <= i) nbPixelPerColumn.add(w); else if (nbPixelPerColumn.get(i) < w) nbPixelPerColumn.set(i, w); } } // create the lines with appropriate spacing List spacedRows = new ArrayList<>(data.size()); for (List row : data) { Chat spacedRow = Chat.chat(); for (int i = 0; i < row.size() - 1; i++) { int w = componentWidth(row.get(i), console); int padding = nbPixelPerColumn.get(i) - w; spacedRow.then(row.get(i)); spacedRow.then(customWidthSpace(padding, console)); spacedRow.thenText(space); } if (!row.isEmpty()) spacedRow.then(row.get(row.size() - 1)); spacedRows.add(spacedRow.getAdv()); } return spacedRows; } /** * Provides a component acting as a spacer of a specific width. *

* The returned component contains mostly spaces. If it has visible characters, the component color will be set to * black to be the least visible as possible. *

* For console, the method returns a {@link Component} with a regular space repeated {@code width} times. * For IG, the methods returns a {@link Component} with a combination of spaces and some small characters, with part * of them bold. For some specific width, the returned {@link Component} may not have the intended width. * @param width the width of the space to produce. * @param console true if the spacer is intended to be displayed on the console, false if it’s in game chat. * @return a component acting as a spacer of a specific width. */ public static Component customWidthSpace(int width, boolean console) { if (console) return Chat.text(" ".repeat(width)).getAdv(); return switch (width) { case 0, 1 -> Component.empty(); case 2 -> Chat.text(".").black().getAdv(); case 3 -> Chat.text("`").black().getAdv(); case 6 -> Chat.text(". ").black().getAdv(); case 7 -> Chat.text("` ").black().getAdv(); case 11 -> Chat.text("` ").black().getAdv(); default -> { int nbSpace = width / 4; int nbBold = width % 4; int nbNotBold = nbSpace - nbBold; if (nbNotBold > 0) { if (nbBold > 0) { yield Chat.text(" ".repeat(nbNotBold)).bold(false) .then(Chat.text(" ".repeat(nbBold)).bold(true)) .getAdv(); } else yield Chat.text(" ".repeat(nbNotBold)).bold(false).getAdv(); } else if (nbBold > 0) { yield Chat.text(" ".repeat(nbBold)).bold(true).getAdv(); } throw new IllegalStateException("Should not be here (width=" + width + "; nbSpace=" + nbSpace + "; nbBold=" + nbBold + "; nbNotBold=" + nbNotBold + ")"); } }; // "." is 2 px // "`" is 3 px // " " is 4 px // 0 "" // 1 "" // 2 "." // 3 "`" // 4 " " // 5 "§l " // 6 ". " // 7 "` " // 8 " " // 9 " §l " // 10 "§l " // 11 "` " // 12 " " } private static final String PROGRESS_BAR_START = "["; private static final String PROGRESS_BAR_END = "]"; private static final TextColor PROGRESS_BAR_EMPTY_COLOR = NamedTextColor.DARK_GRAY; private static final char PROGRESS_BAR_EMPTY_CHAR = '.'; private static final char PROGRESS_BAR_FULL_CHAR = '|'; /** * Generate a (eventually multi-part) progress bar using text. * @param values the values to render in the progress bar. * @param colors the colors attributed to each values. * @param total the total value of the progress bar. * @param width the width in which the progress bar should fit (in pixel for IG, in character count for console) * @param console true if the progress bar is intended to be displayed on the console, false if it’s in game chat. * @return a progress bar using text. */ public static Chat progressBar(double[] values, TextColor[] colors, double total, int width, boolean console) { // 1. Compute char size for each values int progressPixelWidth = width - strWidth(PROGRESS_BAR_START + PROGRESS_BAR_END, console, false); int charPixelWidth = charW(PROGRESS_BAR_EMPTY_CHAR, console, false); assert charPixelWidth == charW(PROGRESS_BAR_FULL_CHAR, console, false) : "PROGRESS_BAR_EMPTY_CHAR and PROGRESS_BAR_FULL_CHAR should have the same pixel width according to #charW(...)"; int progressCharWidth = progressPixelWidth / charPixelWidth; int[] sizes = new int[values.length]; double sumValuesBefore = 0; int sumSizesBefore = 0; for (int i = 0; i < values.length; i++) { sumValuesBefore += values[i]; int charPosition = Math.min((int) Math.round(progressCharWidth * sumValuesBefore / total), progressCharWidth); sizes[i] = charPosition - sumSizesBefore; sumSizesBefore += sizes[i]; } // 2. Generate rendered text Chat c = ChatStatic.text(PROGRESS_BAR_START); int sumSizes = 0; for (int i = 0; i < sizes.length; i++) { sumSizes += sizes[i]; FormatableChat subC = ChatStatic.text(repeatedChar(PROGRESS_BAR_FULL_CHAR, sizes[i])); if (colors != null && i < colors.length && colors[i] != null) subC.color(colors[i]); c.then(subC); } return c .then(ChatStatic.text(repeatedChar(PROGRESS_BAR_EMPTY_CHAR, progressCharWidth - sumSizes)) .color(PROGRESS_BAR_EMPTY_COLOR)) .thenText(PROGRESS_BAR_END); } /** * Generate a progress bar using text. * @param value the value to render in the progress bar. * @param color the color of the filled part of the bar. * @param total the total value of the progress bar. * @param width the width in which the progress bar should fit (in pixel for IG, in character count for console) * @param console true if the progress bar is intended to be displayed on the console, false if it’s in game chat. * @return a progress bar using text. */ public static Chat progressBar(double value, TextColor color, double total, int width, boolean console) { return progressBar(new double[] { value }, new TextColor[] { color }, total, width, console); } /** * Truncate an eventually too long prefix (like team prefix or permission group prefix), keep the last color and * format. * @param prefix the prefix that eventually needs truncation. * @param maxLength the maximum length of the prefix. * @return a truncated prefix, with the last color kept. */ public static String truncatePrefix(String prefix, int maxLength) { if (prefix.length() > maxLength) { String lastColor = ChatColorUtil.getLastColors(prefix); prefix = truncateAtLengthWithoutReset(prefix, maxLength); if (!ChatColorUtil.getLastColors(prefix).equals(lastColor)) prefix = truncateAtLengthWithoutReset(prefix, maxLength - lastColor.length()) + lastColor; } return prefix; } /** * Truncate an eventually too long string, also taking care of removing an eventual {@code §} character leftText alone * at the end. * @param str the string to eventually truncate. * @param maxLength the maximum length of the string. * @return a truncated string. */ public static String truncateAtLengthWithoutReset(String str, int maxLength) { if (str.length() > maxLength) { str = str.substring(0, maxLength); if (str.endsWith("§")) str = str.substring(0, str.length()-1); } return str; } }