PandaLib/pandalib-chat/src/main/java/fr/pandacube/lib/chat/ChatUtil.java

607 lines
24 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Integer, String> 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 doesnt have a mapping in this map, then its width is {@link #DEFAULT_CHAR_SIZE}.
*/
public static final Map<Character, Integer> CHAR_SIZES;
static {
Map<Character, Integer> 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<Integer> 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.
* <p>
* 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.
* <p>
* 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<Chat> 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.
* <p>
* 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<String> wrapInLimitedPixels(String legacyText, int pixelWidth) {
List<String> 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<Component> renderTable(List<List<Chat>> data, String space, boolean console) {
List<List<Component>> compRows = new ArrayList<>(data.size());
for (List<Chat> row : data) {
List<Component> 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<Component> renderTableComp(List<List<Component>> data, String space, boolean console) {
// determine columns width
List<Integer> nbPixelPerColumn = new ArrayList<>();
for (List<Component> 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<Component> spacedRows = new ArrayList<>(data.size());
for (List<Component> 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.
* <p>
* 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.
* <p>
* 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 its 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 its 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 its 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;
}
}