diff --git a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/BorderRadiusDialog.java b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/BorderRadiusDialog.java new file mode 100644 index 00000000000..6b9eeff5d54 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/BorderRadiusDialog.java @@ -0,0 +1,137 @@ +package de.hysky.skyblocker.skyblock.fancybars; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +import java.util.function.IntConsumer; + +/** + * A small dialog that lets the user type a border-radius value (0–20 px). + * Opened from the right-click edit panel on any status bar. + */ +public class BorderRadiusDialog extends Screen { + + private static final int MAX_RADIUS = 20; + + private final Screen parent; + private final int currentRadius; + private final IntConsumer onConfirm; + + private EditBox inputBox; + private Button confirmButton; + private Component errorMsg = Component.empty(); + + public BorderRadiusDialog(Screen parent, int currentRadius, IntConsumer onConfirm) { + super(Component.translatable("skyblocker.bars.config.borderRadius.dialog.title")); + this.parent = parent; + this.currentRadius = currentRadius; + this.onConfirm = onConfirm; + } + + @Override + protected void init() { + int cx = width / 2; + int cy = height / 2; + + inputBox = new EditBox(font, cx - 50, cy - 10, 100, 20, + Component.translatable("skyblocker.bars.config.borderRadius")); + inputBox.setMaxLength(3); + inputBox.setValue(String.valueOf(currentRadius)); + inputBox.setFilter(s -> s.isEmpty() || s.matches("\\d{0,3}")); + inputBox.setResponder(text -> { + errorMsg = validate(text) == -1 + ? Component.translatable("skyblocker.bars.config.borderRadius.dialog.error", MAX_RADIUS) + : Component.empty(); + if (confirmButton != null) confirmButton.active = (validate(text) != -1); + }); + addRenderableWidget(inputBox); + + confirmButton = addRenderableWidget(Button.builder( + Component.translatable("skyblocker.bars.config.borderRadius.dialog.confirm"), + btn -> confirm()) + .bounds(cx - 52, cy + 16, 50, 16) + .build()); + + addRenderableWidget(Button.builder( + Component.translatable("skyblocker.bars.config.borderRadius.dialog.cancel"), + btn -> minecraft.setScreen(parent)) + .bounds(cx + 2, cy + 16, 50, 16) + .build()); + + setFocused(inputBox); + } + + /** Returns the clamped value, or -1 if the text is invalid. */ + private int validate(String text) { + if (text == null || text.isBlank()) return 0; + try { + int v = Integer.parseInt(text.trim()); + if (v < 0 || v > MAX_RADIUS) return -1; + return v; + } catch (NumberFormatException e) { + return -1; + } + } + + private void confirm() { + int v = validate(inputBox.getValue()); + if (v == -1) return; + onConfirm.accept(v); + minecraft.setScreen(parent); + } + + @Override + public boolean keyPressed(KeyEvent keyEvent) { + int key = keyEvent.key(); + if (key == GLFW.GLFW_KEY_ENTER || key == GLFW.GLFW_KEY_KP_ENTER) { + confirm(); + return true; + } + if (key == GLFW.GLFW_KEY_ESCAPE) { + minecraft.setScreen(parent); + return true; + } + return super.keyPressed(keyEvent); + } + + @Override + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { + renderTransparentBackground(context); + + int cx = width / 2; + int cy = height / 2; + + // Draw a simple background panel + int panelW = 160, panelH = 80; + context.fill(cx - panelW / 2, cy - panelH / 2, cx + panelW / 2, cy + panelH / 2, 0xCC000000); + context.fill(cx - panelW / 2, cy - panelH / 2, cx + panelW / 2, cy - panelH / 2 + 1, 0xFF555555); + context.fill(cx - panelW / 2, cy + panelH / 2 - 1, cx + panelW / 2, cy + panelH / 2, 0xFF555555); + context.fill(cx - panelW / 2, cy - panelH / 2, cx - panelW / 2 + 1, cy + panelH / 2, 0xFF555555); + context.fill(cx + panelW / 2 - 1, cy - panelH / 2, cx + panelW / 2, cy + panelH / 2, 0xFF555555); + + // Title + context.drawCenteredString(font, getTitle(), cx, cy - panelH / 2 + 5, 0xFFFFFF); + + // Prompt + context.drawCenteredString(font, + Component.translatable("skyblocker.bars.config.borderRadius.dialog.prompt", MAX_RADIUS), + cx, cy - 22, 0xAAAAAA); + + // Error + if (!errorMsg.getString().isEmpty()) { + context.drawCenteredString(font, errorMsg, cx, cy + 36, 0xFF5555); + } + + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/EditBarWidget.java b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/EditBarWidget.java index ed65e380526..6da5139ccbb 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/EditBarWidget.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/EditBarWidget.java @@ -1,6 +1,5 @@ package de.hysky.skyblocker.skyblock.fancybars; -import de.hysky.skyblocker.utils.EnumUtils; import de.hysky.skyblocker.utils.render.HudHelper; import it.unimi.dsi.fastutil.booleans.BooleanConsumer; import java.awt.Color; @@ -25,332 +24,391 @@ public class EditBarWidget extends AbstractContainerWidget { - private final EnumCyclingOption iconOption; - private final EnumCyclingOption textOption; - - private final BooleanOption showMaxOption; - private final BooleanOption showOverflowOption; - - private final ColorOption color1; - private final ColorOption color2; - private final ColorOption textColor; - - private final RunnableOption hideOption; - - private final StringWidget nameWidget; - - private final List options; - - private int contentsWidth = 0; - - public EditBarWidget(int x, int y, Screen parent) { - super(x, y, 100, 99, Component.literal("Edit bar")); - - Font textRenderer = Minecraft.getInstance().font; - - nameWidget = new StringWidget(Component.empty(), textRenderer); - - MutableComponent translatable = Component.translatable("skyblocker.bars.config.icon"); - iconOption = new EnumCyclingOption<>(0, 11, getWidth(), translatable, StatusBar.IconPosition.class); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + iconOption.getLongestOptionWidth() + 10); - - translatable = Component.translatable("skyblocker.bars.config.text"); - textOption = new EnumCyclingOption<>(0, 22, getWidth(), translatable, StatusBar.TextPosition.class); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + textOption.getLongestOptionWidth() + 10); - - translatable = Component.translatable("skyblocker.bars.config.showMax"); - showMaxOption = new BooleanOption(0, 33, getWidth(), translatable); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); - - translatable = Component.translatable("skyblocker.bars.config.showOverflow"); - showOverflowOption = new BooleanOption(0, 44, getWidth(), translatable); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); - - // COLO(u)RS - translatable = Component.translatable("skyblocker.bars.config.mainColor"); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); - color1 = new ColorOption(0, 55, getWidth(), translatable, parent); - - translatable = Component.translatable("skyblocker.bars.config.overflowColor"); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); - color2 = new ColorOption(0, 66, getWidth(), translatable, parent); - - translatable = Component.translatable("skyblocker.bars.config.textColor"); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); - textColor = new ColorOption(0, 77, getWidth(), translatable, parent); - - translatable = Component.translatable("skyblocker.bars.config.hide"); - contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); - hideOption = new RunnableOption(0, 88, getWidth(), translatable); - - options = List.of(iconOption, textOption, showMaxOption, showOverflowOption, color1, color2, textColor, hideOption); - - setWidth(contentsWidth); - } - - @Override - public List children() { - return options; - } - - public int insideMouseX = 0; - public int insideMouseY = 0; - - @Override - protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { - if (isHovered()) { - insideMouseX = mouseX; - insideMouseY = mouseY; - } else { - int i = mouseX - insideMouseX; - int j = mouseY - insideMouseY; - if (i * i + j * j > 30 * 30) visible = false; - } - Matrix3x2fStack matrices = context.pose(); - matrices.pushMatrix(); - matrices.translate(getX(), getY()); - TooltipRenderUtil.renderTooltipBackground(context, 0, 0, getWidth(), getHeight(), null); - nameWidget.render(context, mouseX, mouseY, delta); - for (AbstractWidget option : options) option.render(context, mouseX - getX(), mouseY - getY(), delta); - matrices.popMatrix(); - } - - @Override - protected void updateWidgetNarration(NarrationElementOutput builder) { - } - - @Override - public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { - if (!visible) return false; - if (!isHovered()) visible = false; - return super.mouseClicked(new MouseButtonEvent(click.x() - getX(), click.y() - getY(), click.buttonInfo()), doubled); - } - - public void setStatusBar(StatusBar statusBar) { - iconOption.setCurrent(statusBar.getIconPosition()); - iconOption.setOnChange(statusBar::setIconPosition); - textOption.setCurrent(statusBar.getTextPosition()); - textOption.setOnChange(statusBar::setTextPosition); - - color1.setCurrent(statusBar.getColors()[0].getRGB()); - color1.setOnChange(color -> statusBar.getColors()[0] = color); - - showMaxOption.active = statusBar.hasMax(); - showMaxOption.setCurrent(statusBar.showMax); - showOverflowOption.active = statusBar.hasOverflow(); - showOverflowOption.setCurrent(statusBar.showOverflow); - showMaxOption.setOnChange(showMax -> statusBar.showMax = showMax); - showOverflowOption.setOnChange(showOverflow -> statusBar.showOverflow = showOverflow); - - color2.active = statusBar.hasOverflow(); - if (color2.active) { - color2.setCurrent(statusBar.getColors()[1].getRGB()); - color2.setOnChange(color -> statusBar.getColors()[1] = color); - } - - if (statusBar.getTextColor() != null) { - textColor.setCurrent(statusBar.getTextColor().getRGB()); - } - textColor.setOnChange(statusBar::setTextColor); - hideOption.active = statusBar.enabled; - hideOption.setRunnable(() -> { - if (statusBar.anchor != null) - FancyStatusBars.barPositioner.removeBar(statusBar.anchor, statusBar.gridY, statusBar); - statusBar.enabled = false; - FancyStatusBars.updatePositions(true); - }); - - MutableComponent formatted = statusBar.getName().copy().withStyle(ChatFormatting.BOLD); - nameWidget.setMessage(formatted); - setWidth(Math.max(Minecraft.getInstance().font.width(formatted), contentsWidth)); - } - - @Override - public void setWidth(int width) { - super.setWidth(width); - for (AbstractWidget option : options) option.setWidth(width); - nameWidget.setWidth(width); - - } - - public class RunnableOption extends AbstractWidget { - - private Runnable runnable; - - public RunnableOption(int x, int y, int width, Component message) { - super(x, y, width, 11, message); - } - - public void setRunnable(Runnable runnable) { - this.runnable = runnable; - } - - @Override - protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { - if (isMouseOver(mouseX, mouseY)) { - context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); - } - Font textRenderer = Minecraft.getInstance().font; - context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, active ? CommonColors.WHITE : CommonColors.GRAY, true); - } - - @Override - public void onClick(MouseButtonEvent click, boolean doubled) { - super.onClick(click, doubled); - EditBarWidget.this.visible = false; - if (runnable != null) runnable.run(); - } - - @Override - protected void updateWidgetNarration(NarrationElementOutput builder) {} - } - - public static class EnumCyclingOption> extends AbstractWidget { - - private T current; - private final T[] values; - private Consumer onChange = null; - - public EnumCyclingOption(int x, int y, int width, Component message, Class enumClass) { - super(x, y, width, 11, message); - values = enumClass.getEnumConstants(); - current = values[0]; - } - - @Override - protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { - if (isMouseOver(mouseX, mouseY)) { - context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); - } - Font textRenderer = Minecraft.getInstance().font; - context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, CommonColors.WHITE, true); - String string = current.toString(); - context.drawString(textRenderer, string, getRight() - textRenderer.width(string) - 1, getY() + 1, CommonColors.WHITE, true); - } - - public void setCurrent(T current) { - this.current = current; - } - - @Override - public void onClick(MouseButtonEvent click, boolean doubled) { - current = EnumUtils.cycle(current); - if (onChange != null) onChange.accept(current); - super.onClick(click, doubled); - } - - @Override - protected void updateWidgetNarration(NarrationElementOutput builder) { - } - - public void setOnChange(Consumer onChange) { - this.onChange = onChange; - } - - int getLongestOptionWidth() { - int m = 0; - for (T value : values) { - int i = Minecraft.getInstance().font.width(value.toString()); - m = Math.max(m, i); - } - return m; - } - } - - public static class BooleanOption extends AbstractWidget { - - private boolean current = false; - private BooleanConsumer onChange = null; - - public BooleanOption(int x, int y, int width, Component message) { - super(x, y, width, 11, message); - } - - @Override - protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { - if (isMouseOver(mouseX, mouseY)) { - context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); - } - Font textRenderer = Minecraft.getInstance().font; - context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, active ? -1 : CommonColors.GRAY, true); - HudHelper.drawBorder(context, getRight() - 10, getY() + 1, 9, 9, active ? -1 : CommonColors.GRAY); - if (current && active) context.fill(getRight() - 8, getY() + 3, getRight() - 3, getY() + 8, CommonColors.WHITE); - } - - @Override - public void onClick(MouseButtonEvent click, boolean doubled) { - current = !current; - if (onChange != null) onChange.accept(current); - super.onClick(click, doubled); - } - - @Override - protected void updateWidgetNarration(NarrationElementOutput builder) { - } - - public void setCurrent(boolean current) { - this.current = current; - } - - public void setOnChange(BooleanConsumer onChange) { - this.onChange = onChange; - } - } - - public static class ColorOption extends AbstractWidget { - - public void setCurrent(int current) { - this.current = current; - } - - private int current = 0; - private Consumer onChange = null; - private final Screen parent; - - public ColorOption(int x, int y, int width, Component message, Screen parent) { - super(x, y, width, 11, message); - this.parent = parent; - } - - @Override - protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { - if (isMouseOver(mouseX, mouseY)) { - context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); - } - Font textRenderer = Minecraft.getInstance().font; - context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, active ? -1 : CommonColors.GRAY, true); - HudHelper.drawBorder(context, getRight() - 10, getY() + 1, 9, 9, active ? -1 : CommonColors.GRAY); - context.fill(getRight() - 8, getY() + 3, getRight() - 3, getY() + 8, active ? current : CommonColors.GRAY); - } - - @Override - public void onClick(MouseButtonEvent click, boolean doubled) { - super.onClick(click, doubled); - Minecraft.getInstance().setScreen(new EditBarColorPopup(Component.literal("Edit ").append(getMessage()), parent, this::set)); - } - - private void set(Color color) { - current = color.getRGB(); - if (onChange != null) onChange.accept(color); - } - - @Override - protected void updateWidgetNarration(NarrationElementOutput builder) { - - } - - public void setOnChange(Consumer onChange) { - this.onChange = onChange; - } - } - - @Override - protected int contentHeight() { - return 0; - } - - @Override - protected double scrollRate() { - return 0; - } + private final EnumCyclingOption iconOption; + private final EnumCyclingOption textOption; + + private final BooleanOption showMaxOption; + private final BooleanOption showOverflowOption; + + private final ColorOption color1; + private final ColorOption color2; + private final ColorOption textColor; + + private final RunnableOption hideOption; + private final RunnableOption borderRadiusOption; + private final RunnableOption resetBarOption; + + private final StringWidget nameWidget; + + private final List options; + + private int contentsWidth = 0; + private final Screen parent; + + @SuppressWarnings("unchecked") + public EditBarWidget(int x, int y, Screen parent) { + super(x, y, 100, 121, Component.literal("Edit bar")); + this.parent = parent; + + Font textRenderer = Minecraft.getInstance().font; + + nameWidget = new StringWidget(Component.empty(), textRenderer); + + MutableComponent translatable = Component.translatable("skyblocker.bars.config.icon"); + // Icon cycles: OFF → LEFT → RIGHT → CUSTOM + StatusBar.IconPosition[] iconValues = { + StatusBar.IconPosition.OFF, + StatusBar.IconPosition.LEFT, + StatusBar.IconPosition.RIGHT, + StatusBar.IconPosition.CUSTOM + }; + iconOption = new EnumCyclingOption<>(0, 11, getWidth(), translatable, iconValues); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + iconOption.getLongestOptionWidth() + 10); + + translatable = Component.translatable("skyblocker.bars.config.text"); + // Text cycles: OFF → BAR LEFT → BAR CENTER → BAR RIGHT → CUSTOM (CENTER excluded) + StatusBar.TextPosition[] textValues = { + StatusBar.TextPosition.OFF, + StatusBar.TextPosition.LEFT, + StatusBar.TextPosition.BAR_CENTER, + StatusBar.TextPosition.RIGHT, + StatusBar.TextPosition.CUSTOM + }; + textOption = new EnumCyclingOption<>(0, 22, getWidth(), translatable, textValues); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + textOption.getLongestOptionWidth() + 10); + + translatable = Component.translatable("skyblocker.bars.config.showMax"); + showMaxOption = new BooleanOption(0, 33, getWidth(), translatable); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + + translatable = Component.translatable("skyblocker.bars.config.showOverflow"); + showOverflowOption = new BooleanOption(0, 44, getWidth(), translatable); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + + // COLO(u)RS + translatable = Component.translatable("skyblocker.bars.config.mainColor"); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + color1 = new ColorOption(0, 55, getWidth(), translatable, parent); + + translatable = Component.translatable("skyblocker.bars.config.overflowColor"); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + color2 = new ColorOption(0, 66, getWidth(), translatable, parent); + + translatable = Component.translatable("skyblocker.bars.config.textColor"); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + textColor = new ColorOption(0, 77, getWidth(), translatable, parent); + + // Account for both Hide and Show labels width + contentsWidth = Math.max(contentsWidth, textRenderer.width(Component.translatable("skyblocker.bars.config.hide")) + 9 + 10); + contentsWidth = Math.max(contentsWidth, textRenderer.width(Component.translatable("skyblocker.bars.config.show")) + 9 + 10); + hideOption = new RunnableOption(0, 88, getWidth(), Component.translatable("skyblocker.bars.config.hide")); + + translatable = Component.translatable("skyblocker.bars.config.borderRadius"); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + borderRadiusOption = new RunnableOption(0, 99, getWidth(), translatable); + + translatable = Component.translatable("skyblocker.bars.config.resetBar"); + contentsWidth = Math.max(contentsWidth, textRenderer.width(translatable) + 9 + 10); + resetBarOption = new RunnableOption(0, 110, getWidth(), translatable); + + options = List.of(iconOption, textOption, showMaxOption, showOverflowOption, color1, color2, textColor, hideOption, borderRadiusOption, resetBarOption); + + setWidth(contentsWidth); + } + + @Override + public List children() { + return options; + } + + public int insideMouseX = 0; + public int insideMouseY = 0; + + @Override + protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { + if (isHovered()) { + insideMouseX = mouseX; + insideMouseY = mouseY; + } else { + int i = mouseX - insideMouseX; + int j = mouseY - insideMouseY; + if (i * i + j * j > 30 * 30) visible = false; + } + Matrix3x2fStack matrices = context.pose(); + matrices.pushMatrix(); + matrices.translate(getX(), getY()); + TooltipRenderUtil.renderTooltipBackground(context, 0, 0, getWidth(), getHeight(), null); + nameWidget.render(context, mouseX, mouseY, delta); + for (AbstractWidget option : options) option.render(context, mouseX - getX(), mouseY - getY(), delta); + matrices.popMatrix(); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput builder) { + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + if (!visible) return false; + if (!isHovered()) visible = false; + return super.mouseClicked(new MouseButtonEvent(click.x() - getX(), click.y() - getY(), click.buttonInfo()), doubled); + } + + public void setStatusBar(StatusBar statusBar) { + iconOption.setCurrent(statusBar.getIconPosition()); + iconOption.setOnChange(statusBar::setIconPosition); + textOption.setCurrent(statusBar.getTextPosition()); + textOption.setOnChange(statusBar::setTextPosition); + + color1.setCurrent(statusBar.getColors()[0].getRGB()); + color1.setOnChange(color -> statusBar.getColors()[0] = color); + + showMaxOption.active = statusBar.hasMax(); + showMaxOption.setCurrent(statusBar.showMax); + showOverflowOption.active = statusBar.hasOverflow(); + showOverflowOption.setCurrent(statusBar.showOverflow); + showMaxOption.setOnChange(showMax -> statusBar.showMax = showMax); + showOverflowOption.setOnChange(showOverflow -> statusBar.showOverflow = showOverflow); + + color2.active = statusBar.hasOverflow(); + if (color2.active) { + color2.setCurrent(statusBar.getColors()[1].getRGB()); + color2.setOnChange(color -> statusBar.getColors()[1] = color); + } + + if (statusBar.getTextColor() != null) { + textColor.setCurrent(statusBar.getTextColor().getRGB()); + } + textColor.setOnChange(statusBar::setTextColor); + + // Toggle between Hide and Show based on current enabled state + hideOption.active = true; + if (statusBar.enabled) { + hideOption.setMessage(Component.translatable("skyblocker.bars.config.hide")); + hideOption.setRunnable(() -> { + if (statusBar.anchor != null) + FancyStatusBars.barPositioner.removeBar(statusBar.anchor, statusBar.gridY, statusBar); + statusBar.enabled = false; + FancyStatusBars.updatePositions(true); + }); + } else { + hideOption.setMessage(Component.translatable("skyblocker.bars.config.show")); + hideOption.setRunnable(() -> { + statusBar.enabled = true; + statusBar.anchor = null; // make free-floating + if (statusBar.width <= 0) statusBar.width = 0.2f; + FancyStatusBars.updatePositions(true); + }); + } + + borderRadiusOption.active = true; + borderRadiusOption.setRunnable(() -> { + Minecraft.getInstance().setScreen( + new BorderRadiusDialog(parent, statusBar.borderRadius, radius -> { + statusBar.borderRadius = radius; + })); + }); + + resetBarOption.active = true; + resetBarOption.setRunnable(() -> FancyStatusBars.resetSingleBar(statusBar)); + + MutableComponent formatted = statusBar.getName().copy().withStyle(ChatFormatting.BOLD); + nameWidget.setMessage(formatted); + setWidth(Math.max(Minecraft.getInstance().font.width(formatted), contentsWidth)); + } + + @Override + public void setWidth(int width) { + super.setWidth(width); + for (AbstractWidget option : options) option.setWidth(width); + nameWidget.setWidth(width); + + } + + public class RunnableOption extends AbstractWidget { + + private Runnable runnable; + + public RunnableOption(int x, int y, int width, Component message) { + super(x, y, width, 11, message); + } + + public void setRunnable(Runnable runnable) { + this.runnable = runnable; + } + + @Override + protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { + if (isMouseOver(mouseX, mouseY)) { + context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); + } + Font textRenderer = Minecraft.getInstance().font; + context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, active ? CommonColors.WHITE : CommonColors.GRAY, true); + } + + @Override + public void onClick(MouseButtonEvent click, boolean doubled) { + super.onClick(click, doubled); + EditBarWidget.this.visible = false; + if (runnable != null) runnable.run(); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput builder) {} + } + + public static class EnumCyclingOption> extends AbstractWidget { + + private T current; + private final T[] values; + private Consumer onChange = null; + + /** Constructor using a custom (possibly filtered) set of values to cycle through. */ + public EnumCyclingOption(int x, int y, int width, Component message, T[] values) { + super(x, y, width, 11, message); + this.values = values; + current = values[0]; + } + + @Override + protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { + if (isMouseOver(mouseX, mouseY)) { + context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); + } + Font textRenderer = Minecraft.getInstance().font; + context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, CommonColors.WHITE, true); + String string = current.toString(); + context.drawString(textRenderer, string, getRight() - textRenderer.width(string) - 1, getY() + 1, CommonColors.WHITE, true); + } + + public void setCurrent(T current) { + this.current = current; + } + + @Override + public void onClick(MouseButtonEvent click, boolean doubled) { + // Cycle only within the filtered values array + int idx = 0; + for (int i = 0; i < values.length; i++) { + if (values[i] == current) { idx = i; break; } + } + current = values[(idx + 1) % values.length]; + if (onChange != null) onChange.accept(current); + super.onClick(click, doubled); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput builder) { + } + + public void setOnChange(Consumer onChange) { + this.onChange = onChange; + } + + int getLongestOptionWidth() { + int m = 0; + for (T value : values) { + int i = Minecraft.getInstance().font.width(value.toString()); + m = Math.max(m, i); + } + return m; + } + } + + public static class BooleanOption extends AbstractWidget { + + private boolean current = false; + private BooleanConsumer onChange = null; + + public BooleanOption(int x, int y, int width, Component message) { + super(x, y, width, 11, message); + } + + @Override + protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { + if (isMouseOver(mouseX, mouseY)) { + context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); + } + Font textRenderer = Minecraft.getInstance().font; + context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, active ? -1 : CommonColors.GRAY, true); + HudHelper.drawBorder(context, getRight() - 10, getY() + 1, 9, 9, active ? -1 : CommonColors.GRAY); + if (current && active) context.fill(getRight() - 8, getY() + 3, getRight() - 3, getY() + 8, CommonColors.WHITE); + } + + @Override + public void onClick(MouseButtonEvent click, boolean doubled) { + current = !current; + if (onChange != null) onChange.accept(current); + super.onClick(click, doubled); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput builder) { + } + + public void setCurrent(boolean current) { + this.current = current; + } + + public void setOnChange(BooleanConsumer onChange) { + this.onChange = onChange; + } + } + + public static class ColorOption extends AbstractWidget { + + public void setCurrent(int current) { + this.current = current; + } + + private int current = 0; + private Consumer onChange = null; + private final Screen parent; + + public ColorOption(int x, int y, int width, Component message, Screen parent) { + super(x, y, width, 11, message); + this.parent = parent; + } + + @Override + protected void renderWidget(GuiGraphics context, int mouseX, int mouseY, float delta) { + if (isMouseOver(mouseX, mouseY)) { + context.fill(getX(), getY(), getRight(), getBottom(), 0x20FFFFFF); + } + Font textRenderer = Minecraft.getInstance().font; + context.drawString(textRenderer, getMessage(), getX() + 1, getY() + 1, active ? -1 : CommonColors.GRAY, true); + HudHelper.drawBorder(context, getRight() - 10, getY() + 1, 9, 9, active ? -1 : CommonColors.GRAY); + context.fill(getRight() - 8, getY() + 3, getRight() - 3, getY() + 8, active ? current : CommonColors.GRAY); + } + + @Override + public void onClick(MouseButtonEvent click, boolean doubled) { + super.onClick(click, doubled); + Minecraft.getInstance().setScreen(new EditBarColorPopup(Component.literal("Edit ").append(getMessage()), parent, this::set)); + } + + private void set(Color color) { + current = color.getRGB(); + if (onChange != null) onChange.accept(color); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput builder) { + + } + + public void setOnChange(Consumer onChange) { + this.onChange = onChange; + } + } + + @Override + protected int contentHeight() { + return 0; + } + + @Override + protected double scrollRate() { + return 0; + } } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/FancyStatusBars.java b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/FancyStatusBars.java index 0448dfeb3b9..cc83c200f65 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/FancyStatusBars.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/FancyStatusBars.java @@ -46,367 +46,487 @@ import java.util.function.Function; public class FancyStatusBars { - private static final Identifier HUD_LAYER = SkyblockerMod.id("fancy_status_bars"); - private static final Path FILE = SkyblockerMod.CONFIG_DIR.resolve("status_bars.json"); - private static final Logger LOGGER = LoggerFactory.getLogger(FancyStatusBars.class); - - public static BarPositioner barPositioner = new BarPositioner(); - public static Map statusBars = new EnumMap<>(StatusBarType.class); - private static boolean updatePositionsNextFrame; - - public static boolean isHealthFancyBarEnabled() { - return isBarEnabled(StatusBarType.HEALTH); - } - - public static boolean isExperienceFancyBarEnabled() { - return isBarEnabled(StatusBarType.EXPERIENCE); - } - - public static boolean isBarEnabled(StatusBarType type) { - StatusBar statusBar = statusBars.get(type); - return Debug.isTestEnvironment() || statusBar.enabled || statusBar.inMouse; - } - - @SuppressWarnings("deprecation") - @Init - public static void init() { - Function hideIfFancyStatusBarsEnabled = hudElement -> { - if (Utils.isOnSkyblock() && isEnabled()) - return (context, tickCounter) -> {}; - return hudElement; - }; - - HudElementRegistry.replaceElement(VanillaHudElements.HEALTH_BAR, hudElement -> { - if (!Utils.isOnSkyblock() || !isEnabled()) return hudElement; - if (isHealthFancyBarEnabled()) { - return (context, tickCounter) -> {}; - } else if (isExperienceFancyBarEnabled()) { - return (context, tickCounter) -> { - Matrix3x2fStack pose = context.pose(); - pose.pushMatrix(); - pose.translate(0, 6); - hudElement.render(context, tickCounter); - pose.popMatrix(); - }; - } - return hudElement; - }); - HudElementRegistry.replaceElement(VanillaHudElements.EXPERIENCE_LEVEL, hudElement -> { - if (!Utils.isOnSkyblock() || !isEnabled() || !isExperienceFancyBarEnabled()) return hudElement; - return (context, tickCounter) -> {}; - }); - HudElementRegistry.replaceElement(VanillaHudElements.INFO_BAR, hudElement -> { - if (!Utils.isOnSkyblock() || !isEnabled() || !isExperienceFancyBarEnabled()) return hudElement; - return (context, tickCounter) -> {}; - }); - HudElementRegistry.replaceElement(VanillaHudElements.ARMOR_BAR, hideIfFancyStatusBarsEnabled); - HudElementRegistry.replaceElement(VanillaHudElements.MOUNT_HEALTH, hideIfFancyStatusBarsEnabled); - HudElementRegistry.replaceElement(VanillaHudElements.FOOD_BAR, hideIfFancyStatusBarsEnabled); - HudElementRegistry.replaceElement(VanillaHudElements.AIR_BAR, hideIfFancyStatusBarsEnabled); - - HudElementRegistry.attachElementAfter(VanillaHudElements.HOTBAR, HUD_LAYER, (context, tickCounter) -> { - if (Utils.isOnSkyblock()) render(context, Minecraft.getInstance()); - }); - - statusBars.put(StatusBarType.HEALTH, StatusBarType.HEALTH.newStatusBar()); - statusBars.put(StatusBarType.INTELLIGENCE, StatusBarType.INTELLIGENCE.newStatusBar()); - statusBars.put(StatusBarType.DEFENSE, StatusBarType.DEFENSE.newStatusBar()); - statusBars.put(StatusBarType.EXPERIENCE, StatusBarType.EXPERIENCE.newStatusBar()); - statusBars.put(StatusBarType.SPEED, StatusBarType.SPEED.newStatusBar()); - statusBars.put(StatusBarType.AIR, StatusBarType.AIR.newStatusBar()); - - // Fetch from old status bar config - int[] counts = new int[3]; // counts for RIGHT, LAYER1, LAYER2 - UIAndVisualsConfig.LegacyBarPositions barPositions = SkyblockerConfigManager.get().uiAndVisuals.bars.barPositions; - initBarPosition(statusBars.get(StatusBarType.HEALTH), counts, barPositions.healthBarPosition); - initBarPosition(statusBars.get(StatusBarType.INTELLIGENCE), counts, barPositions.manaBarPosition); - initBarPosition(statusBars.get(StatusBarType.DEFENSE), counts, barPositions.defenceBarPosition); - initBarPosition(statusBars.get(StatusBarType.EXPERIENCE), counts, barPositions.experienceBarPosition); - initBarPosition(statusBars.get(StatusBarType.SPEED), counts, UIAndVisualsConfig.LegacyBarPosition.RIGHT); - initBarPosition(statusBars.get(StatusBarType.AIR), counts, UIAndVisualsConfig.LegacyBarPosition.RIGHT); - - CompletableFuture.supplyAsync(FancyStatusBars::loadBarConfig, Executors.newVirtualThreadPerTaskExecutor()).thenAccept(object -> { - if (object != null) { - for (String s : object.keySet()) { - StatusBarType type = StatusBarType.from(s); - if (statusBars.containsKey(type)) { - try { - statusBars.get(type).loadFromJson(object.get(s).getAsJsonObject()); - } catch (Exception e) { - LOGGER.error("[Skyblocker] Failed to load {} status bar", s, e); - } - } else { - LOGGER.warn("[Skyblocker] Unknown status bar: {}", s); - } - } - } - placeBarsInPositioner(); - configLoaded = true; - }).exceptionally(throwable -> { - LOGGER.error("[Skyblocker] Failed reading status bars config", throwable); - return null; - }); - ClientLifecycleEvents.CLIENT_STOPPING.register((client) -> saveBarConfig()); - - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register( - ClientCommandManager.literal(SkyblockerMod.NAMESPACE) - .then(ClientCommandManager.literal("bars").executes(Scheduler.queueOpenScreenCommand(StatusBarsConfigScreen::new))))); - - SkyblockEvents.LOCATION_CHANGE.register(location -> updatePositionsNextFrame = true); - } - - /** - * Loads the bar position from the old config. Should be used to initialize new bars too. - * - * @param bar the bar to load the position for - * @param counts the counts for each bar position (LAYER1, LAYER2, RIGHT) - * @param position the position to load - */ - @SuppressWarnings("incomplete-switch") - private static void initBarPosition(StatusBar bar, int[] counts, UIAndVisualsConfig.LegacyBarPosition position) { - switch (position) { - case RIGHT: - bar.anchor = BarPositioner.BarAnchor.HOTBAR_RIGHT; - bar.gridY = 0; - bar.gridX = counts[position.ordinal()]++; - break; - case LAYER1: - bar.anchor = BarPositioner.BarAnchor.HOTBAR_TOP; - bar.gridY = 0; - bar.gridX = counts[position.ordinal()]++; - break; - case LAYER2: - bar.anchor = BarPositioner.BarAnchor.HOTBAR_TOP; - bar.gridY = 1; - bar.gridX = counts[position.ordinal()]++; - break; - } - } - - private static boolean configLoaded = false; - - @VisibleForTesting - public static void placeBarsInPositioner() { - barPositioner.clear(); - for (BarPositioner.BarAnchor barAnchor : BarPositioner.BarAnchor.allAnchors()) { - List barList = statusBars.values().stream().filter(bar -> bar.anchor == barAnchor) - .sorted(Comparator.comparingInt(bar -> bar.gridY).thenComparingInt(bar -> bar.gridX)).toList(); - if (barList.isEmpty()) continue; - - int y = -1; - int rowNum = -1; - for (StatusBar statusBar : barList) { - if (statusBar.gridY > y) { - barPositioner.addRow(barAnchor); - rowNum++; - y = statusBar.gridY; - } - barPositioner.addBar(barAnchor, rowNum, statusBar); - } - } - } - - public static @Nullable JsonObject loadBarConfig() { - try (BufferedReader reader = Files.newBufferedReader(FILE)) { - return SkyblockerMod.GSON.fromJson(reader, JsonObject.class); - } catch (NoSuchFileException e) { - LOGGER.warn("[Skyblocker] No status bar config file found, using defaults"); - } catch (Exception e) { - LOGGER.error("[Skyblocker] Failed to load status bars config", e); - } - return null; - } - - public static void saveBarConfig() { - JsonObject output = new JsonObject(); - statusBars.forEach((s, statusBar) -> output.add(s.getSerializedName(), statusBar.toJson())); - try (BufferedWriter writer = Files.newBufferedWriter(FILE)) { - SkyblockerMod.GSON.toJson(output, writer); - LOGGER.info("[Skyblocker] Saved status bars config"); - } catch (IOException e) { - LOGGER.error("[Skyblocker] Failed to save status bars config", e); - } - } - - public static void updatePositions(boolean ignoreVisibility) { - if (!configLoaded) return; - final int width = Minecraft.getInstance().getWindow().getGuiScaledWidth(); - final int height = Minecraft.getInstance().getWindow().getGuiScaledHeight(); - - // Put these in the corner for the config screen - int offset = 0; - for (StatusBar statusBar : statusBars.values()) { - if (!statusBar.enabled) { - statusBar.setX(5); - statusBar.setY(50 + offset); - statusBar.setWidth(30); - offset += statusBar.getHeight(); - } else if (statusBar.anchor == null) { - statusBar.width = Math.clamp(statusBar.width, 30f / width, 1); - statusBar.x = Math.clamp(statusBar.x, 0, 1 - statusBar.width); - statusBar.y = Math.clamp(statusBar.y, 0, 1 - (float) statusBar.getHeight() / height); - statusBar.setX((int) (statusBar.x * width)); - statusBar.setY((int) (statusBar.y * height)); - statusBar.setWidth((int) (statusBar.width * width)); - } - } - - for (BarPositioner.BarAnchor barAnchor : BarPositioner.BarAnchor.allAnchors()) { - ScreenPosition anchorPosition = barAnchor.getAnchorPosition(width, height); - BarPositioner.SizeRule sizeRule = barAnchor.getSizeRule(); - - int targetSize = sizeRule.targetSize(); - boolean visibleHealthMove = barAnchor == BarPositioner.BarAnchor.HOTBAR_TOP && !isHealthFancyBarEnabled(); - if (visibleHealthMove) { - targetSize /= 2; - } - - if (sizeRule.isTargetSize()) { - for (int row = 0; row < barPositioner.getRowCount(barAnchor); row++) { - LinkedList barRow = barPositioner.getRow(barAnchor, row); - if (barRow.isEmpty()) continue; - - // FIX SIZES - int totalSize = 0; - for (StatusBar statusBar : barRow) - totalSize += (statusBar.size = Math.clamp(statusBar.size, sizeRule.minSize(), sizeRule.maxSize())); - - whileLoop: - while (totalSize != targetSize) { - if (totalSize > targetSize) { - for (StatusBar statusBar : barRow) { - if (statusBar.size > sizeRule.minSize()) { - statusBar.size--; - totalSize--; - if (totalSize == targetSize) break whileLoop; - } - } - } else { - for (StatusBar statusBar : barRow) { - if (statusBar.size < sizeRule.maxSize()) { - statusBar.size++; - totalSize++; - if (totalSize == targetSize) break whileLoop; - } - } - } - } - - } - } - - int row = 0; - for (int i = 0; i < barPositioner.getRowCount(barAnchor); i++) { - List barRow = new ArrayList<>(barPositioner.getRow(barAnchor, i)); - barRow.removeIf(statusBar -> !statusBar.visible && !ignoreVisibility); - if (barRow.isEmpty()) continue; - - - // Update the positions - float widthPerSize; - if (sizeRule.isTargetSize()) { - int size = 0; - for (StatusBar bar : barRow) size += bar.size; - widthPerSize = (float) sizeRule.totalWidth() / size; - - } - else - widthPerSize = sizeRule.widthPerSize(); - - if (visibleHealthMove) widthPerSize /= 2; - - int currSize = 0; - int rowSize = barRow.size(); - for (int j = 0; j < rowSize; j++) { - // A bit of a padding - int offsetX = 0; - int lessWidth = 0; - if (!sizeRule.isTargetSize()) { - offsetX = 1; - lessWidth = 2; - } else if (rowSize > 1) { // Technically bars in the middle of 3+ bars will be smaller than the 2 side ones but shh - if (j == 0) lessWidth = 1; - else if (j == rowSize - 1) { - lessWidth = 1; - offsetX = 1; - } else { - lessWidth = 2; - offsetX = 1; - } - } - StatusBar statusBar = barRow.get(j); - statusBar.size = Math.clamp(statusBar.size, sizeRule.minSize(), sizeRule.maxSize()); - - float x = barAnchor.isRight() ? - anchorPosition.x() + (visibleHealthMove ? sizeRule.totalWidth() / 2.f : 0) + currSize * widthPerSize : - anchorPosition.x() - currSize * widthPerSize - statusBar.size * widthPerSize; - statusBar.setX(Mth.ceil(x) + offsetX); - - int y = barAnchor.isUp() ? - anchorPosition.y() - (row + 1) * (statusBar.getHeight() + 1) : - anchorPosition.y() + row * (statusBar.getHeight() + 1); - statusBar.setY(y); - - statusBar.setWidth(Mth.floor(statusBar.size * widthPerSize) - lessWidth); - currSize += statusBar.size; - } - if (currSize > 0) row++; - } - - } - } - - public static boolean isEnabled() { - return SkyblockerConfigManager.get().uiAndVisuals.bars.enableBars && (!Utils.isInTheRift() || SkyblockerConfigManager.get().uiAndVisuals.bars.enableBarsRift); - } - - public static boolean render(GuiGraphics context, Minecraft client) { - LocalPlayer player = client.player; - if (!isEnabled() || player == null) return false; - - Collection barCollection = statusBars.values(); - for (StatusBar statusBar : barCollection) { - if (!statusBar.enabled || !statusBar.visible) continue; - statusBar.renderBar(context); - } - for (StatusBar statusBar : barCollection) { - if (!statusBar.enabled || !statusBar.visible) continue; - statusBar.renderText(context); - } - - if (Utils.isInTheRift()) { - final int div = SkyblockerConfigManager.get().uiAndVisuals.bars.riftHealthHP ? 1 : 2; - statusBars.get(StatusBarType.HEALTH).updateValues(Math.round(player.getHealth()) / player.getMaxHealth(), 0, Math.round(player.getHealth()) / div, Math.round(player.getMaxHealth()) / div, null); - statusBars.get(StatusBarType.DEFENSE).visible = false; - } else { - StatusBarTracker.Resource health = StatusBarTracker.getHealth(); - statusBars.get(StatusBarType.HEALTH).updateWithResource(health); - int defense = StatusBarTracker.getDefense(); - StatusBar defenseBar = statusBars.get(StatusBarType.DEFENSE); - defenseBar.visible = true; - defenseBar.updateValues(defense / (defense + 100.f), 0, defense, null, null); - } - - StatusBarTracker.Resource intelligence = StatusBarTracker.getMana(); - if (SkyblockerConfigManager.get().uiAndVisuals.bars.intelligenceDisplay == UIAndVisualsConfig.IntelligenceDisplay.ACCURATE) { - float totalIntelligence = (float) intelligence.max() + intelligence.overflow(); - statusBars.get(StatusBarType.INTELLIGENCE).updateValues(intelligence.value() / totalIntelligence + intelligence.overflow() / totalIntelligence, intelligence.overflow() / totalIntelligence, intelligence.value(), intelligence.max(), intelligence.overflow()); - } else statusBars.get(StatusBarType.INTELLIGENCE).updateWithResource(intelligence); - - StatusBarTracker.Resource speed = StatusBarTracker.getSpeed(); - statusBars.get(StatusBarType.SPEED).updateWithResource(speed); - statusBars.get(StatusBarType.EXPERIENCE).updateValues(player.experienceProgress, 0, player.experienceLevel, null, null); - StatusBarTracker.Resource air = StatusBarTracker.getAir(); - StatusBar airBar = statusBars.get(StatusBarType.AIR); - airBar.updateWithResource(air); - if (player.isUnderWater() != airBar.visible) { - airBar.visible = player.isUnderWater(); - updatePositionsNextFrame = true; - } - if (updatePositionsNextFrame) { - updatePositions(false); - updatePositionsNextFrame = false; - } - return true; - } + private static final Identifier HUD_LAYER = SkyblockerMod.id("fancy_status_bars"); + private static final Path FILE = SkyblockerMod.CONFIG_DIR.resolve("status_bars.json"); + private static final Logger LOGGER = LoggerFactory.getLogger(FancyStatusBars.class); + + public static BarPositioner barPositioner = new BarPositioner(); + public static Map statusBars = new EnumMap<>(StatusBarType.class); + private static boolean updatePositionsNextFrame; + + public static boolean isHealthFancyBarEnabled() { + return isBarEnabled(StatusBarType.HEALTH); + } + + public static boolean isExperienceFancyBarEnabled() { + return isBarEnabled(StatusBarType.EXPERIENCE); + } + + public static boolean isBarEnabled(StatusBarType type) { + StatusBar statusBar = statusBars.get(type); + return Debug.isTestEnvironment() || statusBar.enabled || statusBar.inMouse; + } + + @SuppressWarnings("deprecation") + @Init + public static void init() { + Function hideIfFancyStatusBarsEnabled = hudElement -> { + if (Utils.isOnSkyblock() && isEnabled()) + return (context, tickCounter) -> {}; + return hudElement; + }; + + HudElementRegistry.replaceElement(VanillaHudElements.HEALTH_BAR, hudElement -> { + if (!Utils.isOnSkyblock() || !isEnabled()) return hudElement; + if (isHealthFancyBarEnabled()) { + return (context, tickCounter) -> {}; + } else if (isExperienceFancyBarEnabled()) { + return (context, tickCounter) -> { + Matrix3x2fStack pose = context.pose(); + pose.pushMatrix(); + pose.translate(0, 6); + hudElement.render(context, tickCounter); + pose.popMatrix(); + }; + } + return hudElement; + }); + HudElementRegistry.replaceElement(VanillaHudElements.EXPERIENCE_LEVEL, hudElement -> { + if (!Utils.isOnSkyblock() || !isEnabled() || !isExperienceFancyBarEnabled()) return hudElement; + return (context, tickCounter) -> {}; + }); + HudElementRegistry.replaceElement(VanillaHudElements.INFO_BAR, hudElement -> { + if (!Utils.isOnSkyblock() || !isEnabled() || !isExperienceFancyBarEnabled()) return hudElement; + return (context, tickCounter) -> {}; + }); + HudElementRegistry.replaceElement(VanillaHudElements.ARMOR_BAR, hideIfFancyStatusBarsEnabled); + HudElementRegistry.replaceElement(VanillaHudElements.MOUNT_HEALTH, hideIfFancyStatusBarsEnabled); + HudElementRegistry.replaceElement(VanillaHudElements.FOOD_BAR, hideIfFancyStatusBarsEnabled); + HudElementRegistry.replaceElement(VanillaHudElements.AIR_BAR, hideIfFancyStatusBarsEnabled); + + HudElementRegistry.attachElementAfter(VanillaHudElements.HOTBAR, HUD_LAYER, (context, tickCounter) -> { + if (Utils.isOnSkyblock()) render(context, Minecraft.getInstance()); + }); + + statusBars.put(StatusBarType.HEALTH, StatusBarType.HEALTH.newStatusBar()); + statusBars.put(StatusBarType.INTELLIGENCE, StatusBarType.INTELLIGENCE.newStatusBar()); + statusBars.put(StatusBarType.DEFENSE, StatusBarType.DEFENSE.newStatusBar()); + statusBars.put(StatusBarType.EXPERIENCE, StatusBarType.EXPERIENCE.newStatusBar()); + statusBars.put(StatusBarType.SPEED, StatusBarType.SPEED.newStatusBar()); + statusBars.put(StatusBarType.AIR, StatusBarType.AIR.newStatusBar()); + + // Fetch from old status bar config + int[] counts = new int[3]; // counts for RIGHT, LAYER1, LAYER2 + UIAndVisualsConfig.LegacyBarPositions barPositions = SkyblockerConfigManager.get().uiAndVisuals.bars.barPositions; + initBarPosition(statusBars.get(StatusBarType.HEALTH), counts, barPositions.healthBarPosition); + initBarPosition(statusBars.get(StatusBarType.INTELLIGENCE), counts, barPositions.manaBarPosition); + initBarPosition(statusBars.get(StatusBarType.DEFENSE), counts, barPositions.defenceBarPosition); + initBarPosition(statusBars.get(StatusBarType.EXPERIENCE), counts, barPositions.experienceBarPosition); + initBarPosition(statusBars.get(StatusBarType.SPEED), counts, UIAndVisualsConfig.LegacyBarPosition.RIGHT); + initBarPosition(statusBars.get(StatusBarType.AIR), counts, UIAndVisualsConfig.LegacyBarPosition.RIGHT); + + CompletableFuture.supplyAsync(FancyStatusBars::loadBarConfig, Executors.newVirtualThreadPerTaskExecutor()).thenAccept(object -> { + if (object != null) { + for (String s : object.keySet()) { + StatusBarType type = StatusBarType.from(s); + if (statusBars.containsKey(type)) { + try { + statusBars.get(type).loadFromJson(object.get(s).getAsJsonObject()); + } catch (Exception e) { + LOGGER.error("[Skyblocker] Failed to load {} status bar", s, e); + } + } else { + LOGGER.warn("[Skyblocker] Unknown status bar: {}", s); + } + } + } else { + // No saved config — apply preferred default layout for first-time users + for (StatusBarType type : StatusBarType.values()) { + StatusBar bar = statusBars.get(type); + if (bar != null) applyPreferredBarDefaults(type, bar); + } + } + placeBarsInPositioner(); + configLoaded = true; + }).exceptionally(throwable -> { + LOGGER.error("[Skyblocker] Failed reading status bars config", throwable); + return null; + }); + ClientLifecycleEvents.CLIENT_STOPPING.register((client) -> saveBarConfig()); + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register( + ClientCommandManager.literal(SkyblockerMod.NAMESPACE) + .then(ClientCommandManager.literal("bars").executes(Scheduler.queueOpenScreenCommand(StatusBarsConfigScreen::new))))); + + SkyblockEvents.LOCATION_CHANGE.register(location -> updatePositionsNextFrame = true); + } + + /** + * Loads the bar position from the old config. Should be used to initialize new bars too. + * + * @param bar the bar to load the position for + * @param counts the counts for each bar position (LAYER1, LAYER2, RIGHT) + * @param position the position to load + */ + @SuppressWarnings("incomplete-switch") + private static void initBarPosition(StatusBar bar, int[] counts, UIAndVisualsConfig.LegacyBarPosition position) { + switch (position) { + case RIGHT: + bar.anchor = BarPositioner.BarAnchor.HOTBAR_RIGHT; + bar.gridY = 0; + bar.gridX = counts[position.ordinal()]++; + break; + case LAYER1: + bar.anchor = BarPositioner.BarAnchor.HOTBAR_TOP; + bar.gridY = 0; + bar.gridX = counts[position.ordinal()]++; + break; + case LAYER2: + bar.anchor = BarPositioner.BarAnchor.HOTBAR_TOP; + bar.gridY = 1; + bar.gridX = counts[position.ordinal()]++; + break; + } + } + + private static boolean configLoaded = false; + + /** + * Applies the preferred default layout for a single bar using hotbar-relative anchoring. + * Pixel offsets are in GUI pixels (scale-independent) so the layout tracks the hotbar + * correctly at every GUI scale. + */ + private static void applyPreferredBarDefaults(StatusBarType type, StatusBar bar) { + bar.anchor = null; + bar.hotbarRelative = true; + bar.gridX = 0; bar.gridY = 0; + bar.enabled = true; + bar.visible = true; + bar.barHeight = 9; + bar.showMax = false; + bar.showOverflow = false; + bar.textCustomScale = 1.0f; + bar.iconCustomOffX = 0; bar.iconCustomOffY = 0; + bar.iconCustomW = StatusBar.ICON_SIZE; bar.iconCustomH = StatusBar.ICON_SIZE; + bar.setIconPosition(StatusBar.IconPosition.LEFT); + // Hotbar-relative layout (GUI pixels from hotbar top-centre). + // offX from centre, offY from hotbar top (negative = above hotbar). + // Stat bars (H/I/D) tile across the hotbar width with 1px gaps. + // XP spans full hotbar width, 1px above hotbar. Speed/Air flank below. + switch (type) { + case HEALTH -> { + // left bar: spans -91 to -31 (width 60, 1px gap before intel) + bar.hotbarRelOffX = -91; bar.hotbarRelOffY = -21; bar.hotbarPixelWidth = 60; + bar.borderRadius = 10; + bar.setTextPosition(StatusBar.TextPosition.CUSTOM); + bar.textCustomOffX = 23; bar.textCustomOffY = -3; + } + case INTELLIGENCE -> { + // centre bar: spans -30 to 30 (width 60, 1px gaps either side) + bar.hotbarRelOffX = -30; bar.hotbarRelOffY = -21; bar.hotbarPixelWidth = 60; + bar.borderRadius = 10; + bar.setTextPosition(StatusBar.TextPosition.CUSTOM); + bar.textCustomOffX = 25; bar.textCustomOffY = -3; + } + case DEFENSE -> { + // right bar: spans 31 to 91 (width 60, 1px gap after intel) + bar.hotbarRelOffX = 31; bar.hotbarRelOffY = -21; bar.hotbarPixelWidth = 60; + bar.borderRadius = 10; + bar.setTextPosition(StatusBar.TextPosition.CUSTOM); + bar.textCustomOffX = 22; bar.textCustomOffY = -3; + } + case EXPERIENCE -> { + // full hotbar width, 1px above hotbar top (bar height 9 → offY = -(9+1) = -10) + bar.hotbarRelOffX = -91; bar.hotbarRelOffY = -10; bar.hotbarPixelWidth = 182; + bar.borderRadius = 0; + bar.setTextPosition(StatusBar.TextPosition.BAR_CENTER); + bar.textCustomOffX = 0; bar.textCustomOffY = 0; + } + case SPEED -> { + // below-hotbar, 2px left of hotbar left edge (-91 - 2 - 60 = -153) + bar.hotbarRelOffX = -153; bar.hotbarRelOffY = 6; bar.hotbarPixelWidth = 60; + bar.borderRadius = 10; + bar.setTextPosition(StatusBar.TextPosition.CUSTOM); + bar.textCustomOffX = 28; bar.textCustomOffY = -3; + } + case AIR -> { + // below-hotbar, 2px right of hotbar right edge (91 + 2 = 93) + bar.hotbarRelOffX = 93; bar.hotbarRelOffY = 6; bar.hotbarPixelWidth = 60; + bar.borderRadius = 10; + bar.setTextPosition(StatusBar.TextPosition.CUSTOM); + bar.textCustomOffX = 29; bar.textCustomOffY = -2; + } + } + } + + public static void resetToDefaults() { + barPositioner.clear(); + for (StatusBarType type : StatusBarType.values()) { + StatusBar bar = statusBars.get(type); + if (bar == null) continue; + applyPreferredBarDefaults(type, bar); + } + placeBarsInPositioner(); + updatePositions(true); + } + + /** + * Resets one bar to its default layout and visual settings without touching any other bar. + * Rebuilds the positioner so the bar re-occupies its default grid slot. + */ + public static void resetSingleBar(StatusBar target) { + StatusBarType type = null; + for (java.util.Map.Entry e : statusBars.entrySet()) { + if (e.getValue() == target) { type = e.getKey(); break; } + } + if (type == null) return; + + // Remove from positioner first (in case bar was anchored) + if (target.anchor != null) barPositioner.removeBar(target.anchor, target.gridY, target); + + // Apply preferred defaults for this bar + applyPreferredBarDefaults(type, target); + + placeBarsInPositioner(); + updatePositions(true); + } + + @VisibleForTesting + public static void placeBarsInPositioner() { + barPositioner.clear(); + for (BarPositioner.BarAnchor barAnchor : BarPositioner.BarAnchor.allAnchors()) { + List barList = statusBars.values().stream().filter(bar -> bar.anchor == barAnchor) + .sorted(Comparator.comparingInt(bar -> bar.gridY).thenComparingInt(bar -> bar.gridX)).toList(); + if (barList.isEmpty()) continue; + + int y = -1; + int rowNum = -1; + for (StatusBar statusBar : barList) { + if (statusBar.gridY > y) { + barPositioner.addRow(barAnchor); + rowNum++; + y = statusBar.gridY; + } + barPositioner.addBar(barAnchor, rowNum, statusBar); + } + } + } + + public static @Nullable JsonObject loadBarConfig() { + try (BufferedReader reader = Files.newBufferedReader(FILE)) { + return SkyblockerMod.GSON.fromJson(reader, JsonObject.class); + } catch (NoSuchFileException e) { + LOGGER.warn("[Skyblocker] No status bar config file found, using defaults"); + } catch (Exception e) { + LOGGER.error("[Skyblocker] Failed to load status bars config", e); + } + return null; + } + + public static void saveBarConfig() { + JsonObject output = new JsonObject(); + statusBars.forEach((s, statusBar) -> output.add(s.getSerializedName(), statusBar.toJson())); + try (BufferedWriter writer = Files.newBufferedWriter(FILE)) { + SkyblockerMod.GSON.toJson(output, writer); + LOGGER.info("[Skyblocker] Saved status bars config"); + } catch (IOException e) { + LOGGER.error("[Skyblocker] Failed to save status bars config", e); + } + } + + public static void updatePositions(boolean ignoreVisibility) { + if (!configLoaded) return; + final int width = Minecraft.getInstance().getWindow().getGuiScaledWidth(); + final int height = Minecraft.getInstance().getWindow().getGuiScaledHeight(); + + // Put these in the corner for the config screen + int offset = 0; + for (StatusBar statusBar : statusBars.values()) { + if (!statusBar.enabled) { + statusBar.setX(5); + statusBar.setY(50 + offset); + statusBar.setWidth(30); + offset += statusBar.getHeight(); + } else if (statusBar.hotbarRelative) { + // Hotbar-relative: pixel offsets from hotbar top-centre — scale-independent + int hotbarTopY = height - 22; + int hotbarCentX = width / 2; + statusBar.setX(hotbarCentX + statusBar.hotbarRelOffX); + statusBar.setY(hotbarTopY + statusBar.hotbarRelOffY); + statusBar.setWidth(statusBar.hotbarPixelWidth); + } else if (statusBar.anchor == null) { + statusBar.width = Math.clamp(statusBar.width, 30f / width, 1); + statusBar.x = Math.clamp(statusBar.x, 0, 1 - statusBar.width); + statusBar.y = Math.clamp(statusBar.y, 0, 1 - (float) statusBar.getHeight() / height); + statusBar.setX((int) (statusBar.x * width)); + statusBar.setY((int) (statusBar.y * height)); + statusBar.setWidth((int) (statusBar.width * width)); + } + } + + for (BarPositioner.BarAnchor barAnchor : BarPositioner.BarAnchor.allAnchors()) { + ScreenPosition anchorPosition = barAnchor.getAnchorPosition(width, height); + BarPositioner.SizeRule sizeRule = barAnchor.getSizeRule(); + + int targetSize = sizeRule.targetSize(); + boolean visibleHealthMove = barAnchor == BarPositioner.BarAnchor.HOTBAR_TOP && !isHealthFancyBarEnabled(); + if (visibleHealthMove) { + targetSize /= 2; + } + + if (sizeRule.isTargetSize()) { + for (int row = 0; row < barPositioner.getRowCount(barAnchor); row++) { + LinkedList barRow = barPositioner.getRow(barAnchor, row); + if (barRow.isEmpty()) continue; + + // FIX SIZES + int totalSize = 0; + for (StatusBar statusBar : barRow) + totalSize += (statusBar.size = Math.clamp(statusBar.size, sizeRule.minSize(), sizeRule.maxSize())); + + whileLoop: + while (totalSize != targetSize) { + if (totalSize > targetSize) { + for (StatusBar statusBar : barRow) { + if (statusBar.size > sizeRule.minSize()) { + statusBar.size--; + totalSize--; + if (totalSize == targetSize) break whileLoop; + } + } + } else { + for (StatusBar statusBar : barRow) { + if (statusBar.size < sizeRule.maxSize()) { + statusBar.size++; + totalSize++; + if (totalSize == targetSize) break whileLoop; + } + } + } + } + + } + } + + int row = 0; + for (int i = 0; i < barPositioner.getRowCount(barAnchor); i++) { + List barRow = new ArrayList<>(barPositioner.getRow(barAnchor, i)); + barRow.removeIf(statusBar -> !statusBar.visible && !ignoreVisibility); + if (barRow.isEmpty()) continue; + + + // Update the positions + float widthPerSize; + if (sizeRule.isTargetSize()) { + int size = 0; + for (StatusBar bar : barRow) size += bar.size; + widthPerSize = (float) sizeRule.totalWidth() / size; + + } + else + widthPerSize = sizeRule.widthPerSize(); + + if (visibleHealthMove) widthPerSize /= 2; + + int currSize = 0; + int rowSize = barRow.size(); + for (int j = 0; j < rowSize; j++) { + // A bit of a padding + int offsetX = 0; + int lessWidth = 0; + if (!sizeRule.isTargetSize()) { + offsetX = 1; + lessWidth = 2; + } else if (rowSize > 1) { // Technically bars in the middle of 3+ bars will be smaller than the 2 side ones but shh + if (j == 0) lessWidth = 1; + else if (j == rowSize - 1) { + lessWidth = 1; + offsetX = 1; + } else { + lessWidth = 2; + offsetX = 1; + } + } + StatusBar statusBar = barRow.get(j); + statusBar.size = Math.clamp(statusBar.size, sizeRule.minSize(), sizeRule.maxSize()); + + float x = barAnchor.isRight() ? + anchorPosition.x() + (visibleHealthMove ? sizeRule.totalWidth() / 2.f : 0) + currSize * widthPerSize : + anchorPosition.x() - currSize * widthPerSize - statusBar.size * widthPerSize; + statusBar.setX(Mth.ceil(x) + offsetX); + + int y = barAnchor.isUp() ? + anchorPosition.y() - (row + 1) * (statusBar.getHeight() + 1) : + anchorPosition.y() + row * (statusBar.getHeight() + 1); + statusBar.setY(y); + + statusBar.setWidth(Mth.floor(statusBar.size * widthPerSize) - lessWidth); + currSize += statusBar.size; + } + if (currSize > 0) row++; + } + + } + } + + public static boolean isEnabled() { + return SkyblockerConfigManager.get().uiAndVisuals.bars.enableBars && (!Utils.isInTheRift() || SkyblockerConfigManager.get().uiAndVisuals.bars.enableBarsRift); + } + + public static boolean render(GuiGraphics context, Minecraft client) { + LocalPlayer player = client.player; + if (!isEnabled() || player == null) return false; + + Collection barCollection = statusBars.values(); + for (StatusBar statusBar : barCollection) { + if (!statusBar.enabled || !statusBar.visible) continue; + statusBar.renderBar(context); + } + // Custom-positioned icons render AFTER all bars so they appear on top of everything + for (StatusBar statusBar : barCollection) { + if (!statusBar.enabled || !statusBar.visible) continue; + if (statusBar.getIconPosition() == StatusBar.IconPosition.CUSTOM) { + statusBar.renderCustomIcon(context); + } + } + for (StatusBar statusBar : barCollection) { + if (!statusBar.enabled || !statusBar.visible) continue; + statusBar.renderText(context); + } + + if (Utils.isInTheRift()) { + final int div = SkyblockerConfigManager.get().uiAndVisuals.bars.riftHealthHP ? 1 : 2; + statusBars.get(StatusBarType.HEALTH).updateValues(Math.round(player.getHealth()) / player.getMaxHealth(), 0, Math.round(player.getHealth()) / div, Math.round(player.getMaxHealth()) / div, null); + statusBars.get(StatusBarType.DEFENSE).visible = false; + } else { + StatusBarTracker.Resource health = StatusBarTracker.getHealth(); + statusBars.get(StatusBarType.HEALTH).updateWithResource(health); + int defense = StatusBarTracker.getDefense(); + StatusBar defenseBar = statusBars.get(StatusBarType.DEFENSE); + defenseBar.visible = true; + defenseBar.updateValues(defense / (defense + 100.f), 0, defense, null, null); + } + + StatusBarTracker.Resource intelligence = StatusBarTracker.getMana(); + if (SkyblockerConfigManager.get().uiAndVisuals.bars.intelligenceDisplay == UIAndVisualsConfig.IntelligenceDisplay.ACCURATE) { + float totalIntelligence = (float) intelligence.max() + intelligence.overflow(); + statusBars.get(StatusBarType.INTELLIGENCE).updateValues(intelligence.value() / totalIntelligence + intelligence.overflow() / totalIntelligence, intelligence.overflow() / totalIntelligence, intelligence.value(), intelligence.max(), intelligence.overflow()); + } else statusBars.get(StatusBarType.INTELLIGENCE).updateWithResource(intelligence); + + StatusBarTracker.Resource speed = StatusBarTracker.getSpeed(); + statusBars.get(StatusBarType.SPEED).updateWithResource(speed); + statusBars.get(StatusBarType.EXPERIENCE).updateValues(player.experienceProgress, 0, player.experienceLevel, null, null); + StatusBarTracker.Resource air = StatusBarTracker.getAir(); + StatusBar airBar = statusBars.get(StatusBarType.AIR); + airBar.updateWithResource(air); + if (player.isUnderWater() != airBar.visible) { + airBar.visible = player.isUnderWater(); + updatePositionsNextFrame = true; + } + if (updatePositionsNextFrame) { + updatePositions(false); + updatePositionsNextFrame = false; + } + return true; + } } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBar.java b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBar.java index 5b042cc0929..d1a34f14b42 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBar.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBar.java @@ -35,449 +35,648 @@ import net.minecraft.util.StringRepresentable; public class StatusBar implements LayoutElement, Renderable, GuiEventListener, NarratableEntry { - private static final Identifier BAR_FILL = SkyblockerMod.id("bars/bar_fill"); - private static final Identifier BAR_BACK = SkyblockerMod.id("bars/bar_back"); - - public static final int ICON_SIZE = 9; - - private final Identifier icon; - private final StatusBarType type; - private Color[] colors; - private @Nullable Color textColor; - - public Color[] getColors() { - return colors; - } - - public boolean hasOverflow() { - return type.hasOverflow(); - } - - public boolean hasMax() { - return type.hasMax(); - } - - public @Nullable Color getTextColor() { - return textColor; - } - - public void setTextColor(@Nullable Color textColor) { - this.textColor = textColor; - } - - public Component getName() { - return type.getName(); - } - - private @Nullable OnClick onClick = null; - public int gridX = 0; - public int gridY = 0; - public float x = 0; - public float y = 0; - public float width = 0; - public BarPositioner.@Nullable BarAnchor anchor = null; - - public int size = 1; - - public float fill = 0; - public float overflowFill = 0; - public boolean inMouse = false; - /** - * Used to hide the bar dynamically, like the oxygen bar - */ - public boolean visible = true; - public boolean enabled = true; - - private Object value = "???"; - private @Nullable Object max = "???"; - private @Nullable Object overflow = "???"; - - private int renderX = 0; - private int renderY = 0; - private int renderWidth = 0; - - private IconPosition iconPosition = IconPosition.LEFT; - private TextPosition textPosition = TextPosition.BAR_CENTER; - - public boolean showMax = false; - public boolean showOverflow = false; - - public StatusBar(StatusBarType type) { - this.icon = SkyblockerMod.id("bars/icons/" + type.getSerializedName()); - this.colors = type.getColors(); - this.textColor = type.getTextColor(); - this.type = type; - } - - protected int transparency(int color) { - if (inMouse) return (color & 0x00FFFFFF) | 0x44_000000; - return color; - } - - @Override - public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { - renderBar(context); - if (enabled) renderText(context); - } - - protected Identifier getIcon() { - return icon; - } - - @SuppressWarnings("incomplete-switch") - public void renderBar(GuiGraphics context) { - if (renderWidth <= 0) return; - int transparency = transparency(-1); - switch (iconPosition) { - case LEFT -> context.blitSprite(RenderPipelines.GUI_TEXTURED, getIcon(), renderX, renderY, ICON_SIZE, ICON_SIZE, transparency); - case RIGHT -> context.blitSprite(RenderPipelines.GUI_TEXTURED, getIcon(), renderX + renderWidth - ICON_SIZE, renderY, ICON_SIZE, ICON_SIZE, transparency); - } - - int barWidth = iconPosition.equals(IconPosition.OFF) ? renderWidth : renderWidth - ICON_SIZE - 1; - int barX = iconPosition.equals(IconPosition.LEFT) ? renderX + ICON_SIZE + 1 : renderX; - context.blitSprite(RenderPipelines.GUI_TEXTURED, BAR_BACK, barX, renderY + 1, barWidth, 7, transparency); - drawBarFill(context, barX, barWidth); - //context.drawText(MinecraftClient.getInstance().textRenderer, gridX + " " + gridY + " s:" + size , x, y-9, Colors.WHITE, true); - } - - protected void drawBarFill(GuiGraphics context, int barX, int barWith) { - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, renderY + 2, (int) ((barWith - 2) * fill), 5, transparency(colors[0].getRGB())); - - if (hasOverflow() && overflowFill > 0) { - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, renderY + 2, (int) ((barWith - 2) * Math.min(overflowFill, 1)), 5, transparency(colors[1].getRGB())); - } - } - - public void updateValues(float fill, float overflowFill, Object text, @Nullable Object max, @Nullable Object overflow) { - this.value = text; - this.fill = Math.clamp(fill, 0, 1); - this.overflowFill = Math.clamp(overflowFill, 0, 1); - this.max = max; - this.overflow = overflow; - } - - public void updateWithResource(StatusBarTracker.Resource resource) { - updateValues(resource.value() / (float) resource.max(), resource.overflow() / (float) resource.max(), resource.value(), resource.max(), resource.overflow() > 0 ? resource.overflow() : null); - } - - public void renderText(GuiGraphics context) { - if (!showText()) return; - Font textRenderer = Minecraft.getInstance().font; - int barWidth = iconPosition.equals(IconPosition.OFF) ? renderWidth : renderWidth - ICON_SIZE - 1; - int barX = iconPosition.equals(IconPosition.LEFT) ? renderX + ICON_SIZE + 2 : renderX; - String stringValue = this.value.toString(); - MutableComponent text = Component.literal(stringValue).withStyle(style -> style.withColor((textColor == null ? colors[0] : textColor).getRGB())); - - if (hasMax() && showMax && max != null) { - text.append("/").append(max.toString()); - } - if (hasOverflow() && showOverflow && overflow != null) { - MutableComponent literal = Component.literal(" + ").withStyle(style -> style.withColor(colors[1].getRGB())); - literal.append(overflow.toString()); - text.append(literal); - } - - int textWidth = textRenderer.width(text); - int x; - switch (textPosition) { - case RIGHT -> x = barX + barWidth - textWidth; - case CENTER -> x = this.renderX + (renderWidth - textWidth) / 2; - case BAR_CENTER -> x = barX + (barWidth - textWidth) / 2; - default -> x = barX; // Put on the left by default because I said so. - } - int y = this.renderY - 3; - - int color = transparency((textColor == null ? colors[0] : textColor).getRGB()); - int outlineColor = transparency(CommonColors.BLACK); - - HudHelper.drawOutlinedText(context, Component.translationArg(text), x, y, color, outlineColor); - } - - public void renderCursor(GuiGraphics context, int mouseX, int mouseY, float delta) { - int temp_x = renderX; - int temp_y = renderY; - boolean temp_ghost = inMouse; - - renderX = mouseX; - renderY = mouseY; - inMouse = false; - - render(context, mouseX, mouseY, delta); - - renderX = temp_x; - renderY = temp_y; - inMouse = temp_ghost; - } - - // GUI shenanigans - - @Override - public void setX(int x) { - this.renderX = x; - } - - @Override - public void setY(int y) { - this.renderY = y; - } - - @Override - public int getX() { - return renderX; - } - - @Override - public int getY() { - return renderY; - } - - @Override - public int getWidth() { - return renderWidth; - } - - public void setWidth(int width) { - this.renderWidth = width; - } - - @Override - public int getHeight() { - return 9; - } - - @Override - public ScreenRectangle getRectangle() { - return LayoutElement.super.getRectangle(); - } - - @Override - public boolean isMouseOver(double mouseX, double mouseY) { - return mouseX >= renderX && mouseX <= renderX + getWidth() && mouseY >= renderY && mouseY <= renderY + getHeight(); - } - - @Override - public void visitWidgets(Consumer consumer) { - } - - @Override - public void setFocused(boolean focused) { - } - - @Override - public boolean isFocused() { - return false; - } - - @Override - public NarrationPriority narrationPriority() { - return NarrationPriority.NONE; - } - - @Override - public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { - if (!isMouseOver(click.x(), click.y())) return false; - if (onClick != null) { - onClick.onClick(this, click); - } - return true; - } - - public void setOnClick(@Nullable OnClick onClick) { - this.onClick = onClick; - } - - @Override - public void updateNarration(NarrationElementOutput builder) { - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("name", getName()) - .append("gridX", gridX) - .append("gridY", gridY) - .append("size", size) - .append("x", renderX) - .append("y", renderY) - .append("width", renderWidth) - .append("anchor", anchor) - .toString(); - } - - public IconPosition getIconPosition() { - return iconPosition; - } - - public void setIconPosition(IconPosition iconPosition) { - this.iconPosition = iconPosition; - } - - public boolean showText() { - return textPosition != TextPosition.OFF; - } - - public TextPosition getTextPosition() { - return textPosition; - } - - public void setTextPosition(TextPosition textPosition) { - this.textPosition = textPosition; - } - - public enum IconPosition implements StringRepresentable { - LEFT, - RIGHT, - OFF; - - @Override - public String getSerializedName() { - return name(); - } - - @Override - public String toString() { - return I18n.get("skyblocker.bars.config.commonPosition." + name()); - } - } - - public enum TextPosition implements StringRepresentable { - LEFT, - CENTER, - BAR_CENTER, - RIGHT, - OFF; - - @Override - public String getSerializedName() { - return name(); - } - - @Override - public String toString() { - if (this == CENTER || this == BAR_CENTER) return I18n.get("skyblocker.bars.config.textPosition." + name()); - return I18n.get("skyblocker.bars.config.commonPosition." + name()); - } - } - - @FunctionalInterface - public interface OnClick { - void onClick(StatusBar statusBar, MouseButtonEvent click); - } - - public void loadFromJson(JsonObject object) { - // Make colors optional, so it's easy to reset to default - if (object.has("colors")) { - JsonArray colors1 = object.get("colors").getAsJsonArray(); - if (colors1.size() < 2 && hasOverflow()) { - throw new IllegalStateException("Missing second color of bar that has overflow"); - } - Color[] newColors = new Color[colors1.size()]; - for (int i = 0; i < colors1.size(); i++) { - JsonElement jsonElement = colors1.get(i); - newColors[i] = new Color(Integer.parseInt(jsonElement.getAsString(), 16)); - } - this.colors = newColors; - } - - if (object.has("text_color")) this.textColor = new Color(Integer.parseInt(object.get("text_color").getAsString(), 16)); - - String maybeAnchor = object.get("anchor").getAsString().trim(); - this.anchor = maybeAnchor.equals("null") ? null : BarPositioner.BarAnchor.valueOf(maybeAnchor); - if (!object.has("enabled")) { - enabled = anchor != null; - } else enabled = object.get("enabled").getAsBoolean(); - if (anchor != null) { - this.size = object.get("size").getAsInt(); - this.gridX = object.get("x").getAsInt(); - this.gridY = object.get("y").getAsInt(); - } else { - this.width = object.get("size").getAsFloat(); - this.x = object.get("x").getAsFloat(); - this.y = object.get("y").getAsFloat(); - } - // these are optional too, why not - if (object.has("icon_position")) this.iconPosition = IconPosition.valueOf(object.get("icon_position").getAsString().trim()); - // backwards compat teehee - if (object.has("show_text")) this.textPosition = object.get("show_text").getAsBoolean() ? TextPosition.BAR_CENTER : TextPosition.OFF; - if (object.has("text_position")) this.textPosition = TextPosition.valueOf(object.get("text_position").getAsString().trim()); - if (object.has("show_max")) this.showMax = object.get("show_max").getAsBoolean(); - if (object.has("show_overflow")) this.showOverflow = object.get("show_overflow").getAsBoolean(); - } - - public JsonObject toJson() { - JsonObject object = new JsonObject(); - JsonArray colors1 = new JsonArray(); - for (Color color : colors) { - colors1.add(Integer.toHexString(color.getRGB()).substring(2)); - } - object.add("colors", colors1); - if (textColor != null) { - object.addProperty("text_color", Integer.toHexString(textColor.getRGB()).substring(2)); - } - if (anchor != null) { - object.addProperty("anchor", anchor.toString()); - } else object.addProperty("anchor", "null"); - if (anchor != null) { - object.addProperty("x", gridX); - object.addProperty("y", gridY); - object.addProperty("size", size); - } else { - object.addProperty("size", width); - object.addProperty("x", x); - object.addProperty("y", y); - } - object.addProperty("icon_position", iconPosition.getSerializedName()); - object.addProperty("text_position", textPosition.getSerializedName()); - object.addProperty("show_max", showMax); - object.addProperty("show_overflow", showOverflow); - object.addProperty("enabled", enabled); - return object; - } - - public static class ManaStatusBar extends StatusBar { - - public ManaStatusBar(StatusBarType type) { - super(type); - } - - @Override - protected void drawBarFill(GuiGraphics context, int barX, int barWith) { - if (hasOverflow() && overflowFill > 0) { - if (overflowFill > fill && SkyblockerConfigManager.get().uiAndVisuals.bars.intelligenceDisplay == UIAndVisualsConfig.IntelligenceDisplay.IN_FRONT) { - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, getY() + 2, (int) ((barWith - 2) * Math.min(overflowFill, 1)), 5, transparency(getColors()[1].getRGB())); - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, getY() + 2, (int) ((barWith - 2) * fill), 5, transparency(getColors()[0].getRGB())); - } else { - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, getY() + 2, (int) ((barWith - 2) * fill), 5, transparency(getColors()[0].getRGB())); - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, getY() + 2, (int) ((barWith - 2) * Math.min(overflowFill, 1)), 5, transparency(getColors()[1].getRGB())); - } - } else { - HudHelper.renderNineSliceColored(context, BAR_FILL, barX + 1, getY() + 2, (int) ((barWith - 2) * fill), 5, transparency(getColors()[0].getRGB())); - } - } - - @Override - public void updateValues(float fill, float overflowFill, Object text, @Nullable Object max, @Nullable Object overflow) { - super.updateValues(fill, overflowFill, StatusBarTracker.isManaEstimated() ? "~" + text : text, max, overflow); - } - } - - public static class ExperienceStatusBar extends StatusBar { - private static final Identifier CLOCK_ICON = SkyblockerMod.id("bars/icons/rift_time"); - public ExperienceStatusBar(StatusBarType type) { - super(type); - } - - @Override - protected Identifier getIcon() { - return Utils.isInTheRift() ? CLOCK_ICON : super.getIcon(); - } - - @Override - public void updateValues(float fill, float overflowFill, Object text, @Nullable Object max, @Nullable Object overflow) { - if (Utils.isInTheRift() && text instanceof Integer time) { - text = time < 60 ? time + "s" : String.format("%dm%02ds", time / 60, time % 60); - } - super.updateValues(fill, overflowFill, text, max, overflow); - } - } + public static final int ICON_SIZE = 9; + public static final int BAR_HEIGHT = 14; + public static final int MIN_BAR_HEIGHT = 9; + private static final int BAR_BORDER_COLOR = 0xFF1A1A1A; + private static final int BAR_BACKGROUND_COLOR = 0xFF2D2D2D; + + private final Identifier icon; + private final StatusBarType type; + private Color[] colors; + private @Nullable Color textColor; + + public Color[] getColors() { + return colors; + } + + public void setColors(Color[] colors) { + this.colors = colors; + } + + public boolean hasOverflow() { + return type.hasOverflow(); + } + + public boolean hasMax() { + return type.hasMax(); + } + + public @Nullable Color getTextColor() { + return textColor; + } + + public void setTextColor(@Nullable Color textColor) { + this.textColor = textColor; + } + + public Component getName() { + return type.getName(); + } + + private @Nullable OnClick onClick = null; + public int gridX = 0; + public int gridY = 0; + public float x = 0; + public float y = 0; + public float width = 0; + public BarPositioner.@Nullable BarAnchor anchor = null; + + public int size = 1; + public int barHeight = BAR_HEIGHT; + public int borderRadius = 0; + + /** + * When true the bar is positioned relative to the hotbar top-centre in + * GUI-pixel offsets that are scale-independent. Once the user drags the + * bar this flag is cleared and the bar becomes free-floating (anchor = null). + */ + public boolean hotbarRelative = false; + /** GUI-pixel offset from hotbar top-centre X (screenWidth/2). */ + public int hotbarRelOffX = 0; + /** GUI-pixel offset from hotbar top Y (screenHeight - 22). Negative = above. */ + public int hotbarRelOffY = 0; + /** Bar width in GUI pixels when in hotbar-relative mode. */ + public int hotbarPixelWidth = 60; + + public float fill = 0; + public float overflowFill = 0; + public boolean inMouse = false; + /** + * Used to hide the bar dynamically, like the oxygen bar + */ + public boolean visible = true; + public boolean enabled = true; + + private Object value = "???"; + private @Nullable Object max = "???"; + private @Nullable Object overflow = "???"; + + private int renderX = 0; + private int renderY = 0; + private int renderWidth = 0; + + private IconPosition iconPosition = IconPosition.LEFT; + private TextPosition textPosition = TextPosition.BAR_CENTER; + + // Custom sub-element offsets (pixels, relative to bar origin) + public int textCustomOffX = 0; + public int textCustomOffY = 0; + public int iconCustomOffX = 0; + public int iconCustomOffY = 0; + // Custom sub-element scale / size + public float textCustomScale = 1.0f; + public int iconCustomW = ICON_SIZE; + public int iconCustomH = ICON_SIZE; + + public boolean showMax = false; + public boolean showOverflow = false; + + public StatusBar(StatusBarType type) { + this.icon = SkyblockerMod.id("bars/icons/" + type.getSerializedName()); + this.colors = type.getColors(); + this.textColor = type.getTextColor(); + this.type = type; + } + + protected int transparency(int color) { + if (inMouse) return (color & 0x00FFFFFF) | 0x44_000000; + return color; + } + + @Override + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { + renderBar(context); + if (iconPosition == IconPosition.CUSTOM) renderCustomIcon(context); + if (enabled) renderText(context); + } + + protected Identifier getIcon() { + return icon; + } + + @SuppressWarnings("incomplete-switch") + public void renderBar(GuiGraphics context) { + if (renderWidth <= 0) return; + int transparency = transparency(-1); + int iconY = renderY + (barHeight - ICON_SIZE) / 2; + switch (iconPosition) { + case LEFT -> context.blitSprite(RenderPipelines.GUI_TEXTURED, getIcon(), renderX, iconY, ICON_SIZE, ICON_SIZE, transparency); + case RIGHT -> context.blitSprite(RenderPipelines.GUI_TEXTURED, getIcon(), renderX + renderWidth - ICON_SIZE, iconY, ICON_SIZE, ICON_SIZE, transparency); + // CUSTOM: rendered separately via renderCustomIcon() so it appears above all bars + } + + boolean iconTakesSpace = iconPosition == IconPosition.LEFT || iconPosition == IconPosition.RIGHT; + int barWidth = iconTakesSpace ? renderWidth - ICON_SIZE - 1 : renderWidth; + int barX = iconPosition == IconPosition.LEFT ? renderX + ICON_SIZE + 1 : renderX; + + int r = Math.min(borderRadius, Math.min(barWidth, barHeight) / 2); + fillRounded(context, barX, renderY, barWidth, barHeight, r, transparency(BAR_BORDER_COLOR)); + fillRounded(context, barX + 1, renderY + 1, barWidth - 2, barHeight - 2, Math.max(0, r - 1), transparency(BAR_BACKGROUND_COLOR)); + drawBarFill(context, barX, barWidth); + } + + /** Renders the icon at its custom position. Called AFTER all bars render, so it appears on top. */ + public void renderCustomIcon(GuiGraphics context) { + if (renderWidth <= 0 || iconPosition != IconPosition.CUSTOM) return; + context.blitSprite(RenderPipelines.GUI_TEXTURED, getIcon(), + renderX + iconCustomOffX, renderY + iconCustomOffY, iconCustomW, iconCustomH, transparency(-1)); + } + + protected void drawBarFill(GuiGraphics context, int barX, int barWidth) { + int innerW = barWidth - 2; + int innerH = barHeight - 2; + int ir = Math.max(0, Math.min(borderRadius, Math.min(barWidth, barHeight) / 2) - 1); + int fillPx = (int) (innerW * fill); + if (fillPx > 0) { + fillRoundedClipped(context, barX + 1, getY() + 1, innerW, innerH, ir, fillPx, transparency(colors[0].getRGB())); + } + if (hasOverflow() && overflowFill > 0) { + int overflowPx = (int) (innerW * Math.min(overflowFill, 1)); + if (overflowPx > 0) { + fillRoundedClipped(context, barX + 1, getY() + 1, innerW, innerH, ir, overflowPx, transparency(colors[1].getRGB())); + } + } + } + + // ────────────── Rounded fill helpers ────────────── + + /** + * Draws a filled rounded rectangle. When r==0 falls back to a plain fill. + * Each row is computed from the circle formula so corners are pixel-perfect. + */ + protected static void fillRounded(GuiGraphics ctx, int x, int y, int w, int h, int r, int color) { + if (w <= 0 || h <= 0) return; + if (r <= 0) { ctx.fill(x, y, x + w, y + h, color); return; } + int cr = Math.min(r, Math.min(w, h) / 2); + for (int row = 0; row < h; row++) { + double py = row + 0.5; + int xOff = 0; + if (py < cr) { + double cy = cr - py; + xOff = (int) (cr - Math.sqrt(Math.max(0.0, (double) cr * cr - cy * cy)) + 0.5); + } else if (py > h - cr) { + double cy = py - (h - cr); + xOff = (int) (cr - Math.sqrt(Math.max(0.0, (double) cr * cr - cy * cy)) + 0.5); + } + int rx1 = x + xOff, rx2 = x + w - xOff; + if (rx1 < rx2) ctx.fill(rx1, y + row, rx2, y + row + 1, color); + } + } + + /** + * Draws a rounded rectangle clipped to fillW pixels wide. The right edge is + * a straight cut when fillW < w, and rounds naturally when fillW ≥ w. + * Used so bar fills respect rounded corners without over-drawing. + */ + protected static void fillRoundedClipped(GuiGraphics ctx, int x, int y, int w, int h, int r, int fillW, int color) { + if (w <= 0 || h <= 0 || fillW <= 0) return; + int cr = Math.min(r, Math.min(w, h) / 2); + for (int row = 0; row < h; row++) { + double py = row + 0.5; + int xOff = 0; + if (py < cr) { + double cy = cr - py; + xOff = (int) (cr - Math.sqrt(Math.max(0.0, (double) cr * cr - cy * cy)) + 0.5); + } else if (py > h - cr) { + double cy = py - (h - cr); + xOff = (int) (cr - Math.sqrt(Math.max(0.0, (double) cr * cr - cy * cy)) + 0.5); + } + int rx1 = x + xOff; + // Right edge: whichever is smaller — the fill level or the rounded right boundary + int rx2 = Math.min(x + fillW, x + w - xOff); + if (rx1 < rx2) ctx.fill(rx1, y + row, rx2, y + row + 1, color); + } + } + + public void updateValues(float fill, float overflowFill, Object text, @Nullable Object max, @Nullable Object overflow) { + this.value = text; + this.fill = Math.clamp(fill, 0, 1); + this.overflowFill = Math.clamp(overflowFill, 0, 1); + this.max = max; + this.overflow = overflow; + } + + public void updateWithResource(StatusBarTracker.Resource resource) { + updateValues(resource.value() / (float) resource.max(), resource.overflow() / (float) resource.max(), resource.value(), resource.max(), resource.overflow() > 0 ? resource.overflow() : null); + } + + public void renderText(GuiGraphics context) { + if (!showText()) return; + Font textRenderer = Minecraft.getInstance().font; + + boolean iconTakesSpace = iconPosition == IconPosition.LEFT || iconPosition == IconPosition.RIGHT; + int barWidth = iconTakesSpace ? renderWidth - ICON_SIZE - 1 : renderWidth; + int barX = iconPosition == IconPosition.LEFT ? renderX + ICON_SIZE + 2 : renderX; + + String stringValue = this.value.toString(); + // Use white text inside the bar for maximum contrast; fall back to type text color + int textArgb = textColor != null ? textColor.getRGB() : 0xFFFFFFFF; + MutableComponent text = Component.literal(stringValue).withStyle(style -> style.withColor(textArgb)); + + if (hasMax() && showMax && max != null) { + text.append("/").append(max.toString()); + } + if (hasOverflow() && showOverflow && overflow != null) { + MutableComponent literal = Component.literal(" + ").withStyle(style -> style.withColor(colors[1].getRGB())); + literal.append(overflow.toString()); + text.append(literal); + } + + int textWidth = textRenderer.width(text); + int x; + int y; + switch (textPosition) { + case RIGHT -> { x = barX + barWidth - textWidth - 2; y = renderY + (barHeight - 9) / 2 + 1; } + case BAR_CENTER -> { x = barX + (barWidth - textWidth) / 2; y = renderY + (barHeight - 9) / 2 + 1; } + case CUSTOM -> { x = renderX + textCustomOffX; y = renderY + textCustomOffY; } + default -> { x = barX + 2; y = renderY + (barHeight - 9) / 2 + 1; } // LEFT is the default + } + + int color = transparency(textArgb); + int outlineColor = transparency(CommonColors.BLACK); + + if (textPosition == TextPosition.CUSTOM && textCustomScale != 1.0f) { + context.pose().pushMatrix(); + context.pose().translate((float) x, (float) y); + context.pose().scale(textCustomScale, textCustomScale); + HudHelper.drawOutlinedText(context, Component.translationArg(text), 0, 0, color, outlineColor); + context.pose().popMatrix(); + } else { + HudHelper.drawOutlinedText(context, Component.translationArg(text), x, y, color, outlineColor); + } + } + + public void renderCursor(GuiGraphics context, int mouseX, int mouseY, float delta) { + int temp_x = renderX; + int temp_y = renderY; + boolean temp_ghost = inMouse; + + renderX = mouseX; + renderY = mouseY; + inMouse = false; + + render(context, mouseX, mouseY, delta); + + renderX = temp_x; + renderY = temp_y; + inMouse = temp_ghost; + } + + // GUI shenanigans + + @Override + public void setX(int x) { + this.renderX = x; + } + + @Override + public void setY(int y) { + this.renderY = y; + } + + @Override + public int getX() { + return renderX; + } + + @Override + public int getY() { + return renderY; + } + + @Override + public int getWidth() { + return renderWidth; + } + + public void setWidth(int width) { + this.renderWidth = width; + } + + @Override + public int getHeight() { + return barHeight; + } + + @Override + public ScreenRectangle getRectangle() { + return LayoutElement.super.getRectangle(); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return mouseX >= renderX && mouseX <= renderX + getWidth() && mouseY >= renderY && mouseY <= renderY + getHeight(); + } + + @Override + public void visitWidgets(Consumer consumer) { + } + + @Override + public void setFocused(boolean focused) { + } + + @Override + public boolean isFocused() { + return false; + } + + @Override + public NarrationPriority narrationPriority() { + return NarrationPriority.NONE; + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + if (!isMouseOver(click.x(), click.y())) return false; + if (onClick != null) { + onClick.onClick(this, click); + } + return true; + } + + public void setOnClick(@Nullable OnClick onClick) { + this.onClick = onClick; + } + + @Override + public void updateNarration(NarrationElementOutput builder) { + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("gridX", gridX) + .append("gridY", gridY) + .append("size", size) + .append("x", renderX) + .append("y", renderY) + .append("width", renderWidth) + .append("anchor", anchor) + .toString(); + } + + public IconPosition getIconPosition() { + return iconPosition; + } + + public void setIconPosition(IconPosition iconPosition) { + this.iconPosition = iconPosition; + } + + public boolean showText() { + return textPosition != TextPosition.OFF; + } + + public TextPosition getTextPosition() { + return textPosition; + } + + public void setTextPosition(TextPosition textPosition) { + this.textPosition = textPosition; + } + + /** + * Exact visible bounds of the custom text element. Used for outline drawing in the config screen. + */ + public ScreenRectangle getTextVisualArea(Font font) { + int tx = renderX + textCustomOffX; + int ty = renderY + textCustomOffY; + String sample = value.toString() + (showMax && max != null ? "/" + max : ""); + int tw = Math.max(8, (int) (font.width(sample) * textCustomScale)); + int th = Math.max(4, (int) (9 * textCustomScale)); + return new ScreenRectangle(tx, ty, tw, th); + } + + /** + * Exact visible bounds of the custom icon element. Used for outline drawing in the config screen. + */ + public ScreenRectangle getIconVisualArea() { + return new ScreenRectangle(renderX + iconCustomOffX, renderY + iconCustomOffY, + Math.max(4, iconCustomW), Math.max(4, iconCustomH)); + } + + /** Expanded hit area for clicking the custom text element (visual area + 8px padding on each side). */ + public ScreenRectangle getTextHitArea(Font font) { + ScreenRectangle v = getTextVisualArea(font); + int pad = 8; + return new ScreenRectangle(v.position().x() - pad, v.position().y() - pad, + v.width() + pad * 2, v.height() + pad * 2); + } + + /** Expanded hit area for clicking the custom icon element (visual area + 8px padding on each side). */ + public ScreenRectangle getIconHitArea() { + ScreenRectangle v = getIconVisualArea(); + int pad = 8; + return new ScreenRectangle(v.position().x() - pad, v.position().y() - pad, + v.width() + pad * 2, v.height() + pad * 2); + } + + public enum IconPosition implements StringRepresentable { + LEFT, + RIGHT, + OFF, + CUSTOM; + + @Override + public String getSerializedName() { + return name(); + } + + @Override + public String toString() { + if (this == CUSTOM) return I18n.get("skyblocker.bars.config.commonPosition.CUSTOM"); + return I18n.get("skyblocker.bars.config.commonPosition." + name()); + } + } + + public enum TextPosition implements StringRepresentable { + LEFT, + CENTER, + BAR_CENTER, + RIGHT, + OFF, + CUSTOM; + + @Override + public String getSerializedName() { + return name(); + } + + @Override + public String toString() { + return switch (this) { + case BAR_CENTER -> I18n.get("skyblocker.bars.config.textPosition.BAR_CENTER"); + case LEFT -> I18n.get("skyblocker.bars.config.textPosition.LEFT"); + case RIGHT -> I18n.get("skyblocker.bars.config.textPosition.RIGHT"); + case CUSTOM -> I18n.get("skyblocker.bars.config.textPosition.CUSTOM"); + case CENTER -> I18n.get("skyblocker.bars.config.textPosition.BAR_CENTER"); // legacy + default -> I18n.get("skyblocker.bars.config.commonPosition." + name()); + }; + } + } + + @FunctionalInterface + public interface OnClick { + void onClick(StatusBar statusBar, MouseButtonEvent click); + } + + public void loadFromJson(JsonObject object) { + // Make colors optional, so it's easy to reset to default + if (object.has("colors")) { + JsonArray colors1 = object.get("colors").getAsJsonArray(); + if (colors1.size() < 2 && hasOverflow()) { + throw new IllegalStateException("Missing second color of bar that has overflow"); + } + Color[] newColors = new Color[colors1.size()]; + for (int i = 0; i < colors1.size(); i++) { + JsonElement jsonElement = colors1.get(i); + newColors[i] = new Color(Integer.parseInt(jsonElement.getAsString(), 16)); + } + this.colors = newColors; + } + + if (object.has("text_color")) this.textColor = new Color(Integer.parseInt(object.get("text_color").getAsString(), 16)); + + String maybeAnchor = object.get("anchor").getAsString().trim(); + if (maybeAnchor.equals("HOTBAR_RELATIVE")) { + this.anchor = null; + this.hotbarRelative = true; + this.hotbarRelOffX = object.has("hotbar_off_x") ? object.get("hotbar_off_x").getAsInt() : 0; + this.hotbarRelOffY = object.has("hotbar_off_y") ? object.get("hotbar_off_y").getAsInt() : 0; + this.hotbarPixelWidth = object.has("hotbar_pixel_w") ? object.get("hotbar_pixel_w").getAsInt() : 60; + enabled = object.has("enabled") ? object.get("enabled").getAsBoolean() : true; + } else { + this.hotbarRelative = false; + this.anchor = maybeAnchor.equals("null") ? null : BarPositioner.BarAnchor.valueOf(maybeAnchor); + if (!object.has("enabled")) { + enabled = anchor != null; + } else enabled = object.get("enabled").getAsBoolean(); + if (anchor != null) { + this.size = object.get("size").getAsInt(); + this.gridX = object.get("x").getAsInt(); + this.gridY = object.get("y").getAsInt(); + } else { + this.width = object.get("size").getAsFloat(); + this.x = object.get("x").getAsFloat(); + this.y = object.get("y").getAsFloat(); + } + } + // these are optional too, why not + if (object.has("icon_position")) this.iconPosition = IconPosition.valueOf(object.get("icon_position").getAsString().trim()); + // backwards compat teehee + if (object.has("show_text")) this.textPosition = object.get("show_text").getAsBoolean() ? TextPosition.BAR_CENTER : TextPosition.OFF; + if (object.has("text_position")) { + TextPosition tp = TextPosition.valueOf(object.get("text_position").getAsString().trim()); + this.textPosition = tp == TextPosition.CENTER ? TextPosition.BAR_CENTER : tp; + } + if (object.has("show_max")) this.showMax = object.get("show_max").getAsBoolean(); + if (object.has("show_overflow")) this.showOverflow = object.get("show_overflow").getAsBoolean(); + if (object.has("bar_height")) this.barHeight = Math.max(MIN_BAR_HEIGHT, object.get("bar_height").getAsInt()); + if (object.has("border_radius")) this.borderRadius = Math.max(0, object.get("border_radius").getAsInt()); + if (object.has("text_custom_off_x")) this.textCustomOffX = object.get("text_custom_off_x").getAsInt(); + if (object.has("text_custom_off_y")) this.textCustomOffY = object.get("text_custom_off_y").getAsInt(); + if (object.has("icon_custom_off_x")) this.iconCustomOffX = object.get("icon_custom_off_x").getAsInt(); + if (object.has("icon_custom_off_y")) this.iconCustomOffY = object.get("icon_custom_off_y").getAsInt(); + if (object.has("text_custom_scale")) this.textCustomScale = Math.max(0.5f, Math.min(4.0f, object.get("text_custom_scale").getAsFloat())); + if (object.has("icon_custom_w")) this.iconCustomW = Math.max(4, Math.min(64, object.get("icon_custom_w").getAsInt())); + if (object.has("icon_custom_h")) this.iconCustomH = Math.max(4, Math.min(64, object.get("icon_custom_h").getAsInt())); + } + + public JsonObject toJson() { + JsonObject object = new JsonObject(); + JsonArray colors1 = new JsonArray(); + for (Color color : colors) { + colors1.add(Integer.toHexString(color.getRGB()).substring(2)); + } + object.add("colors", colors1); + if (textColor != null) { + object.addProperty("text_color", Integer.toHexString(textColor.getRGB()).substring(2)); + } + if (hotbarRelative) { + object.addProperty("anchor", "HOTBAR_RELATIVE"); + object.addProperty("hotbar_off_x", hotbarRelOffX); + object.addProperty("hotbar_off_y", hotbarRelOffY); + object.addProperty("hotbar_pixel_w", hotbarPixelWidth); + } else if (anchor != null) { + object.addProperty("anchor", anchor.toString()); + object.addProperty("x", gridX); + object.addProperty("y", gridY); + object.addProperty("size", size); + } else { + object.addProperty("anchor", "null"); + object.addProperty("size", width); + object.addProperty("x", x); + object.addProperty("y", y); + } + object.addProperty("icon_position", iconPosition.getSerializedName()); + object.addProperty("text_position", textPosition.getSerializedName()); + object.addProperty("show_max", showMax); + object.addProperty("show_overflow", showOverflow); + object.addProperty("enabled", enabled); + object.addProperty("bar_height", barHeight); + object.addProperty("border_radius", borderRadius); + if (iconPosition == IconPosition.CUSTOM) { + object.addProperty("icon_custom_off_x", iconCustomOffX); + object.addProperty("icon_custom_off_y", iconCustomOffY); + object.addProperty("icon_custom_w", iconCustomW); + object.addProperty("icon_custom_h", iconCustomH); + } + if (textPosition == TextPosition.CUSTOM) { + object.addProperty("text_custom_off_x", textCustomOffX); + object.addProperty("text_custom_off_y", textCustomOffY); + object.addProperty("text_custom_scale", textCustomScale); + } + return object; + } + + public static class ManaStatusBar extends StatusBar { + + public ManaStatusBar(StatusBarType type) { + super(type); + } + + @Override + protected void drawBarFill(GuiGraphics context, int barX, int barWith) { + int innerW = barWith - 2; + int innerH = barHeight - 2; + int ir = Math.max(0, Math.min(borderRadius, Math.min(barWith, barHeight) / 2) - 1); + int bx = barX + 1; + int by = getY() + 1; + if (hasOverflow() && overflowFill > 0) { + if (overflowFill > fill && SkyblockerConfigManager.get().uiAndVisuals.bars.intelligenceDisplay == UIAndVisualsConfig.IntelligenceDisplay.IN_FRONT) { + int ovPx = (int) (innerW * Math.min(overflowFill, 1)); + if (ovPx > 0) fillRoundedClipped(context, bx, by, innerW, innerH, ir, ovPx, transparency(getColors()[1].getRGB())); + int fillPx = (int) (innerW * fill); + if (fillPx > 0) fillRoundedClipped(context, bx, by, innerW, innerH, ir, fillPx, transparency(getColors()[0].getRGB())); + } else { + int fillPx = (int) (innerW * fill); + if (fillPx > 0) fillRoundedClipped(context, bx, by, innerW, innerH, ir, fillPx, transparency(getColors()[0].getRGB())); + int ovPx = (int) (innerW * Math.min(overflowFill, 1)); + if (ovPx > 0) fillRoundedClipped(context, bx, by, innerW, innerH, ir, ovPx, transparency(getColors()[1].getRGB())); + } + } else { + int fillPx = (int) (innerW * fill); + if (fillPx > 0) fillRoundedClipped(context, bx, by, innerW, innerH, ir, fillPx, transparency(getColors()[0].getRGB())); + } + } + + @Override + public void updateValues(float fill, float overflowFill, Object text, @Nullable Object max, @Nullable Object overflow) { + super.updateValues(fill, overflowFill, StatusBarTracker.isManaEstimated() ? "~" + text : text, max, overflow); + } + } + + public static class ExperienceStatusBar extends StatusBar { + private static final Identifier CLOCK_ICON = SkyblockerMod.id("bars/icons/rift_time"); + public ExperienceStatusBar(StatusBarType type) { + super(type); + } + + @Override + protected Identifier getIcon() { + return Utils.isInTheRift() ? CLOCK_ICON : super.getIcon(); + } + } } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBarsConfigScreen.java b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBarsConfigScreen.java index 4a7d3b86470..6a0643ed99f 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBarsConfigScreen.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/StatusBarsConfigScreen.java @@ -12,13 +12,11 @@ import java.util.Collection; import java.util.HashMap; -import java.util.LinkedList; import java.util.Map; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.PopupScreen; -import net.minecraft.client.gui.navigation.ScreenAxis; import net.minecraft.client.gui.navigation.ScreenDirection; +import net.minecraft.client.input.KeyEvent; import net.minecraft.client.gui.navigation.ScreenPosition; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.screens.Screen; @@ -28,375 +26,820 @@ import net.minecraft.resources.Identifier; public class StatusBarsConfigScreen extends Screen { - private static final Identifier HOTBAR_TEXTURE = Identifier.withDefaultNamespace("hud/hotbar"); - private static final int HOTBAR_WIDTH = 182; - private static final float RESIZE_THRESHOLD = 0.75f; - private static final int BAR_MINIMUM_WIDTH = 30; - // prioritize left and right cuz they are much smaller than up and down - private static final ScreenDirection[] DIRECTION_CHECK_ORDER = new ScreenDirection[]{ScreenDirection.LEFT, ScreenDirection.RIGHT, ScreenDirection.UP, ScreenDirection.DOWN}; - - private final Map> rectToBar = new HashMap<>(); - /** - * Contains the hovered bar and a boolean that is true if hovering the right side or false otherwise. - */ - private final ObjectBooleanPair<@Nullable StatusBar> resizeHover = new ObjectBooleanMutablePair<>(null, false); - private final Pair<@Nullable StatusBar, @Nullable StatusBar> resizedBars = ObjectObjectMutablePair.of(null, null); - - private @Nullable StatusBar cursorBar = null; - private ScreenPosition cursorOffset = new ScreenPosition(0, 0); - private BarLocation currentInsertLocation = new BarLocation(null, 0, 0); - - private boolean resizing = false; - private EditBarWidget editBarWidget; - - public StatusBarsConfigScreen() { - super(Component.nullToEmpty("Status Bars Config")); - } - - - @Override - public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { - super.render(context, mouseX, mouseY, delta); - context.blitSprite(RenderPipelines.GUI_TEXTURED, HOTBAR_TEXTURE, width / 2 - HOTBAR_WIDTH / 2, height - 22, HOTBAR_WIDTH, 22); - editBarWidget.render(context, mouseX, mouseY, delta); - - Window window = minecraft.getWindow(); - int scaleFactor = window.calculateScale(0, minecraft.isEnforceUnicode()) - window.getGuiScale() + 3; - if ((scaleFactor & 2) == 0) scaleFactor++; - - ScreenRectangle mouseRect = new ScreenRectangle(new ScreenPosition(mouseX - scaleFactor / 2, mouseY - scaleFactor / 2), scaleFactor, scaleFactor); - - if (cursorBar != null) { - cursorBar.renderCursor(context, mouseX + cursorOffset.x(), mouseY + cursorOffset.y(), delta); - boolean inserted = false; - boolean updatePositions = false; - rectLoop: - for (ScreenRectangle screenRect : rectToBar.keySet()) { - for (ScreenDirection direction : DIRECTION_CHECK_ORDER) { - boolean overlaps = screenRect.getBorder(direction).step(direction).overlaps(mouseRect); - - if (overlaps) { - Pair barPair = rectToBar.get(screenRect); - BarLocation barSnap = barPair.right(); - if (barSnap.barAnchor() == null) break; - if (direction.getAxis().equals(ScreenAxis.VERTICAL)) { - int neighborInsertY = getNeighborInsertY(barSnap, !direction.isPositive()); - inserted = true; - if (!currentInsertLocation.equals(barSnap.barAnchor(), barSnap.x(), neighborInsertY)) { - if (cursorBar.anchor != null) - FancyStatusBars.barPositioner.removeBar(cursorBar.anchor, cursorBar.gridY, cursorBar); - FancyStatusBars.barPositioner.addRow(barSnap.barAnchor(), neighborInsertY); - FancyStatusBars.barPositioner.addBar(barSnap.barAnchor(), neighborInsertY, cursorBar); - currentInsertLocation = BarLocation.of(cursorBar); - updatePositions = true; - } - } else { - int neighborInsertX = getNeighborInsertX(barSnap, direction.isPositive()); - inserted = true; - if (!currentInsertLocation.equals(barSnap.barAnchor(), neighborInsertX, barSnap.y())) { - if (cursorBar.anchor != null) - FancyStatusBars.barPositioner.removeBar(cursorBar.anchor, cursorBar.gridY, cursorBar); - FancyStatusBars.barPositioner.addBar(barSnap.barAnchor(), barSnap.y(), neighborInsertX, cursorBar); - currentInsertLocation = BarLocation.of(cursorBar); - updatePositions = true; - } - } - break rectLoop; - } - } - } - if (updatePositions) { - FancyStatusBars.updatePositions(true); - return; - } - // check for hovering empty anchors - for (BarPositioner.BarAnchor barAnchor : BarPositioner.BarAnchor.allAnchors()) { - ScreenRectangle anchorHitbox = barAnchor.getAnchorHitbox(barAnchor.getAnchorPosition(width, height)); - if (FancyStatusBars.barPositioner.getRowCount(barAnchor) != 0) { - // this fixes flickering - if (FancyStatusBars.barPositioner.getRowCount(barAnchor) == 1) { - LinkedList row = FancyStatusBars.barPositioner.getRow(barAnchor, 0); - if (row.size() == 1 && row.getFirst() == cursorBar && anchorHitbox.overlaps(mouseRect)) inserted = true; - } - continue; - } - - context.fill(anchorHitbox.left(), anchorHitbox.top(), anchorHitbox.right(), anchorHitbox.bottom(), 0x99FFFFFF); - if (anchorHitbox.overlaps(mouseRect)) { - inserted = true; - if (currentInsertLocation.barAnchor() == barAnchor) continue; - if (cursorBar.anchor != null) - FancyStatusBars.barPositioner.removeBar(cursorBar.anchor, cursorBar.gridY, cursorBar); - FancyStatusBars.barPositioner.addRow(barAnchor); - FancyStatusBars.barPositioner.addBar(barAnchor, 0, cursorBar); - currentInsertLocation = BarLocation.of(cursorBar); - FancyStatusBars.updatePositions(true); - } - } - if (!inserted) { - if (cursorBar.anchor != null) FancyStatusBars.barPositioner.removeBar(cursorBar.anchor, cursorBar.gridY, cursorBar); - currentInsertLocation = BarLocation.NULL; - FancyStatusBars.updatePositions(true); - cursorBar.setX(width + 5); - } - } else { // Not dragging around a bar - if (resizing) { // actively resizing one or 2 bars - int middleX; // the point between the 2 bars - - StatusBar rightBar = resizedBars.right(); - StatusBar leftBar = resizedBars.left(); - boolean hasRight = rightBar != null; - boolean hasLeft = leftBar != null; - BarPositioner.BarAnchor barAnchor; - if (!hasRight) { - barAnchor = leftBar.anchor; - middleX = leftBar.getX() + leftBar.getWidth(); - } else { - barAnchor = rightBar.anchor; - middleX = rightBar.getX(); - } - - if (barAnchor != null) { // If is on an anchor - BarPositioner.SizeRule sizeRule = barAnchor.getSizeRule(); - boolean doResize = true; - - float widthPerSize; - if (sizeRule.isTargetSize()) - widthPerSize = (float) sizeRule.totalWidth() / sizeRule.targetSize(); - else - widthPerSize = sizeRule.widthPerSize(); - - // resize towards the left - if (mouseX < middleX) { - if (middleX - mouseX > widthPerSize / RESIZE_THRESHOLD) { - if (hasRight) { - if (rightBar.size + 1 > sizeRule.maxSize()) doResize = false; - } - if (hasLeft) { - if (leftBar.size - 1 < sizeRule.minSize()) doResize = false; - } - - if (doResize) { - if (hasRight) rightBar.size++; - if (hasLeft) leftBar.size--; - FancyStatusBars.updatePositions(true); - } - } - } else { // towards the right - if (mouseX - middleX > widthPerSize / RESIZE_THRESHOLD) { - if (hasRight) { - if (rightBar.size - 1 < sizeRule.minSize()) doResize = false; - } - if (hasLeft) { - if (leftBar.size + 1 > sizeRule.maxSize()) doResize = false; - } - - if (doResize) { - if (hasRight) rightBar.size--; - if (hasLeft) leftBar.size++; - FancyStatusBars.updatePositions(true); - } - } - } - } else { // Freely moving around - if (hasLeft) { - leftBar.setWidth(Math.max(BAR_MINIMUM_WIDTH, mouseX - leftBar.getX())); - } else if (hasRight) { - int endX = rightBar.getX() + rightBar.getWidth(); - rightBar.setX(Math.min(endX - BAR_MINIMUM_WIDTH, mouseX)); - rightBar.setWidth(endX - rightBar.getX()); - } - } - - } else { // hovering bars - rectLoop: - for (ScreenRectangle screenRect : rectToBar.keySet()) { - for (ScreenDirection direction : new ScreenDirection[]{ScreenDirection.LEFT, ScreenDirection.RIGHT}) { - boolean overlaps = screenRect.getBorder(direction).step(direction).overlaps(mouseRect); - - if (overlaps && !editBarWidget.isMouseOver(mouseX, mouseY)) { - Pair barPair = rectToBar.get(screenRect); - BarLocation barLocation = barPair.right(); - StatusBar bar = barPair.left(); - if (!bar.enabled) break; - boolean right = direction.equals(ScreenDirection.RIGHT); - if (barLocation.barAnchor() != null) { - if (barLocation.barAnchor().getSizeRule().isTargetSize() && !FancyStatusBars.barPositioner.hasNeighbor(barLocation.barAnchor(), barLocation.y(), barLocation.x(), right)) { - break; - } - if (!barLocation.barAnchor().getSizeRule().isTargetSize() && barLocation.x() == 0 && barLocation.barAnchor().isRight() != right) - break; - } - resizeHover.first(bar); - resizeHover.right(right); - context.requestCursor(CursorTypes.RESIZE_EW); - break rectLoop; - } else { - resizeHover.first(null); - } - } - } - } - } - } - - private static int getNeighborInsertX(BarLocation barLocation, boolean right) { - BarPositioner.BarAnchor barAnchor = barLocation.barAnchor(); - int gridX = barLocation.x(); - if (barAnchor == null) return 0; - if (right) { - return barAnchor.isRight() ? gridX + 1 : gridX; - } else { - return barAnchor.isRight() ? gridX : gridX + 1; - } - } - - private static int getNeighborInsertY(BarLocation barLocation, boolean up) { - BarPositioner.BarAnchor barAnchor = barLocation.barAnchor(); - int gridY = barLocation.y(); - if (barAnchor == null) return 0; - if (up) { - return barAnchor.isUp() ? gridY + 1 : gridY; - } else { - return barAnchor.isUp() ? gridY : gridY + 1; - } - } - - @Override - protected void init() { - super.init(); - FancyStatusBars.updatePositions(true); - editBarWidget = new EditBarWidget(0, 0, this); - editBarWidget.visible = false; - addWidget(editBarWidget); // rendering separately to have it above hotbar - Collection values = FancyStatusBars.statusBars.values(); - values.forEach(this::setup); - updateScreenRects(); - this.addRenderableWidget(Button.builder(Component.literal("?"), - button -> minecraft.setScreen(new PopupScreen.Builder(this, Component.translatable("skyblocker.bars.config.explanationTitle")) - .addButton(Component.translatable("gui.ok"), PopupScreen::onClose) - .setMessage(Component.translatable("skyblocker.bars.config.explanation")) - .build())) - .bounds(width - 20, (height - 15) / 2, 15, 15) - .build()); - } - - private void setup(StatusBar statusBar) { - this.addRenderableWidget(statusBar); - statusBar.setOnClick(this::onBarClick); - } - - @Override - public void removed() { - super.removed(); - FancyStatusBars.statusBars.values().forEach(statusBar -> statusBar.setOnClick(null)); - if (cursorBar != null) cursorBar.inMouse = false; - FancyStatusBars.updatePositions(false); - FancyStatusBars.saveBarConfig(); - } - - @Override - public boolean isPauseScreen() { - return false; - } - - private void onBarClick(StatusBar statusBar, MouseButtonEvent click) { - if (click.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT) { - cursorOffset = new ScreenPosition((int) (statusBar.getX() - click.x()), (int) (statusBar.getY() - click.y())); - cursorBar = statusBar; - cursorBar.inMouse = true; - cursorBar.enabled = true; - currentInsertLocation = BarLocation.of(cursorBar); - if (statusBar.anchor != null) - FancyStatusBars.barPositioner.removeBar(statusBar.anchor, statusBar.gridY, statusBar); - FancyStatusBars.updatePositions(true); - cursorBar.setX(width + 5); // send it to limbo lol - updateScreenRects(); - } else if (click.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { - int x = (int) Math.min(click.x() - 1, width - editBarWidget.getWidth()); - int y = (int) Math.min(click.y() - 1, height - editBarWidget.getHeight()); - editBarWidget.visible = true; - editBarWidget.setStatusBar(statusBar); - editBarWidget.setX(x); - editBarWidget.setY(y); - } - } - - private void updateScreenRects() { - rectToBar.clear(); - FancyStatusBars.statusBars.values().forEach(statusBar1 -> { - if (!statusBar1.enabled) return; - rectToBar.put( - new ScreenRectangle(new ScreenPosition(statusBar1.getX(), statusBar1.getY()), statusBar1.getWidth(), statusBar1.getHeight()), - Pair.of(statusBar1, BarLocation.of(statusBar1))); - }); - } - - @Override - public boolean mouseReleased(MouseButtonEvent click) { - if (cursorBar != null) { - cursorBar.inMouse = false; - if (currentInsertLocation == BarLocation.NULL) { - cursorBar.x = (float) ((click.x() + cursorOffset.x()) / width); - cursorBar.y = (float) ((click.y() + cursorOffset.y()) / height); - cursorBar.width = Math.clamp(cursorBar.width, (float) BAR_MINIMUM_WIDTH / width, 1); - } - currentInsertLocation = BarLocation.NULL; - cursorBar = null; - FancyStatusBars.updatePositions(true); - updateScreenRects(); - return true; - } else if (resizing) { - resizing = false; - - // update x and width if bar has no anchor - StatusBar bar = null; - if (resizedBars.left() != null) bar = resizedBars.left(); - else if (resizedBars.right() != null) bar = resizedBars.right(); - if (bar != null && bar.anchor == null) { - bar.x = (float) bar.getX() / width; - bar.width = (float) bar.getWidth() / width; - } - resizedBars.left(null); - resizedBars.right(null); - updateScreenRects(); - return true; - } - return super.mouseReleased(click); - } - - @Override - public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { - StatusBar first = resizeHover.first(); - // want the right click thing to have priority - if (!editBarWidget.isMouseOver(click.x(), click.y()) && click.button() == 0 && first != null) { - BarPositioner.BarAnchor barAnchor = first.anchor; - if (barAnchor != null) { - if (resizeHover.rightBoolean()) { - resizedBars.left(first); - - if (FancyStatusBars.barPositioner.hasNeighbor(barAnchor, first.gridY, first.gridX, true)) { - resizedBars.right(FancyStatusBars.barPositioner.getBar(barAnchor, first.gridY, first.gridX + (barAnchor.isRight() ? 1 : -1))); - } else resizedBars.right(null); - } else { - resizedBars.right(first); - - if (FancyStatusBars.barPositioner.hasNeighbor(barAnchor, first.gridY, first.gridX, false)) { - resizedBars.left(FancyStatusBars.barPositioner.getBar(barAnchor, first.gridY, first.gridX + (barAnchor.isRight() ? -1 : 1))); - } else resizedBars.left(null); - } - } else { // if they have no anchor no need to do any checking - if (resizeHover.rightBoolean()) { - resizedBars.left(first); - resizedBars.right(null); - } else { - resizedBars.right(first); - resizedBars.left(null); - } - } - resizing = true; - return true; - } - return super.mouseClicked(click, doubled); - } + private static final Identifier HOTBAR_TEXTURE = Identifier.withDefaultNamespace("hud/hotbar"); + private static final int HOTBAR_WIDTH = 182; + private static final float RESIZE_THRESHOLD = 0.75f; + private static final int BAR_MINIMUM_WIDTH = 30; + private static final int DRAG_THRESHOLD = 5; + private static final int EDGE_TOLERANCE = 5; + /** How far outside bar bounds the arrows extend (used for "extended zone" hit test). */ + private static final int ARROW_EXT = 14; + + /** Cyan: CUSTOM element handles */ + private static final int HANDLE_COLOR = 0xFF55FFFF; + /** Yellow: selected bar outline */ + private static final int BAR_SEL_COLOR = 0xFFFFFF55; + + private final Map> rectToBar = new HashMap<>(); + /** Hovered bar + boolean: true = right edge, false = left edge */ + private final ObjectBooleanPair<@Nullable StatusBar> resizeHover = new ObjectBooleanMutablePair<>(null, false); + /** Hovered bar + boolean: true = top edge, false = bottom edge (height resize) */ + private final ObjectBooleanPair<@Nullable StatusBar> resizeHeightHover = new ObjectBooleanMutablePair<>(null, false); + private final Pair<@Nullable StatusBar, @Nullable StatusBar> resizedBars = ObjectObjectMutablePair.of(null, null); + + private @Nullable StatusBar cursorBar = null; + /** The currently "active" bar (for outline / right-click menu). */ + private @Nullable StatusBar selectedBar = null; + /** + * Which sub-element within the selected bar is selected. + * null = the bar itself; "text" = custom text element; "icon" = custom icon element. + * When non-null the yellow bar outline is hidden and only cyan handles for that element are shown. + */ + private @Nullable String selectedSubElement = null; + + private ScreenPosition cursorOffset = new ScreenPosition(0, 0); + + private boolean resizing = false; + private boolean mouseButtonHeld = false; + private int dragStartX = 0; + private int dragStartY = 0; + + // ── Height resize (bars) ── + private boolean resizingHeight = false; + private boolean heightResizeFromTop = false; + private @Nullable StatusBar heightResizeBar = null; + private int heightResizeInitialY = 0; + private int heightResizeInitialHeight = 0; + + // ── CUSTOM sub-element move drag ── + private boolean draggingSubElement = false; + private boolean draggingText = false; + private int subDragStartMouseX = 0; + private int subDragStartMouseY = 0; + private int subDragStartOffX = 0; + private int subDragStartOffY = 0; + + // ── Position overlay timer (shown after keyboard nudge) ── + private int nudgeOverlayTimer = 0; + + // ── CUSTOM sub-element resize ── + private enum SubElementEdge { NONE, TEXT_RIGHT, ICON_RIGHT, ICON_BOTTOM } + private SubElementEdge subElementEdgeHover = SubElementEdge.NONE; + private @Nullable StatusBar subElementEdgeBar = null; + + private boolean resizingSubElement = false; + private boolean resizeSubIsText = false; + private boolean resizeSubIsHoriz = true; + private @Nullable StatusBar resizeSubBar = null; + private int resizeSubStartMouse = 0; + private float resizeSubStartScale = 1.0f; + private int resizeSubStartPx = 0; + + private EditBarWidget editBarWidget; + + public StatusBarsConfigScreen() { + super(Component.nullToEmpty("Status Bars Config")); + } + + // ─────────────────────── Helpers ─────────────────────── + + /** True if (x, y) is inside the bar body + the outer arrow zone. */ + private static boolean isInBarExtendedZone(StatusBar bar, int x, int y) { + return x >= bar.getX() - ARROW_EXT && x <= bar.getX() + bar.getWidth() + ARROW_EXT + && y >= bar.getY() - ARROW_EXT && y <= bar.getY() + bar.getHeight() + ARROW_EXT; + } + + /** True if (x, y) is inside the bar body only (not in the arrow gutter). */ + private static boolean isInBarBody(StatusBar bar, int x, int y) { + return x >= bar.getX() && x <= bar.getX() + bar.getWidth() + && y >= bar.getY() && y <= bar.getY() + bar.getHeight(); + } + + private void clearSelection() { + selectedBar = null; + selectedSubElement = null; + editBarWidget.visible = false; + } + + // ─────────────────────── Drag helpers ─────────────────────── + + private void startBarDrag(StatusBar statusBar) { + cursorBar = statusBar; + cursorBar.inMouse = true; + cursorBar.enabled = true; + if (statusBar.anchor != null) + FancyStatusBars.barPositioner.removeBar(statusBar.anchor, statusBar.gridY, statusBar); + // Detach from hotbar-relative anchor → convert current pixel position to screen fractions + if (statusBar.hotbarRelative) { + statusBar.hotbarRelative = false; + statusBar.x = (float) statusBar.getX() / this.width; + statusBar.y = (float) statusBar.getY() / this.height; + statusBar.width = (float) statusBar.getWidth() / this.width; + } else if (statusBar.getWidth() > 0) { + statusBar.width = (float) statusBar.getWidth() / this.width; + } + statusBar.anchor = null; + FancyStatusBars.updatePositions(true); + cursorBar.setX(width + 5); + updateScreenRects(); + editBarWidget.visible = false; + } + + /** Tries to start a sub-element RESIZE at (cx, cy). Returns true if started. */ + private boolean tryStartSubElementResize(StatusBar bar, int cx, int cy) { + if (subElementEdgeHover != SubElementEdge.NONE && subElementEdgeBar == bar) { + resizingSubElement = true; + resizeSubBar = bar; + mouseButtonHeld = true; + editBarWidget.visible = false; + switch (subElementEdgeHover) { + case TEXT_RIGHT -> { + resizeSubIsText = true; + resizeSubIsHoriz = true; + resizeSubStartMouse = cx; + resizeSubStartScale = bar.textCustomScale; + } + case ICON_RIGHT -> { + resizeSubIsText = false; + resizeSubIsHoriz = true; + resizeSubStartMouse = cx; + resizeSubStartPx = bar.iconCustomW; + } + case ICON_BOTTOM -> { + resizeSubIsText = false; + resizeSubIsHoriz = false; + resizeSubStartMouse = cy; + resizeSubStartPx = bar.iconCustomH; + } + default -> { resizingSubElement = false; return false; } + } + return true; + } + return false; + } + + // ─────────────────────── Render ─────────────────────── + + @Override + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + // Sub-element MOVE drag + if (draggingSubElement && selectedBar != null && mouseButtonHeld) { + int dx = mouseX - subDragStartMouseX; + int dy = mouseY - subDragStartMouseY; + if (draggingText) { + selectedBar.textCustomOffX = subDragStartOffX + dx; + selectedBar.textCustomOffY = subDragStartOffY + dy; + } else { + selectedBar.iconCustomOffX = subDragStartOffX + dx; + selectedBar.iconCustomOffY = subDragStartOffY + dy; + } + } + + // Sub-element RESIZE drag + if (resizingSubElement && resizeSubBar != null && mouseButtonHeld) { + if (resizeSubIsText) { + int d = mouseX - resizeSubStartMouse; + resizeSubBar.textCustomScale = Math.max(0.5f, Math.min(4.0f, resizeSubStartScale + d * 0.02f)); + } else if (resizeSubIsHoriz) { + int d = mouseX - resizeSubStartMouse; + resizeSubBar.iconCustomW = Math.max(4, Math.min(64, resizeSubStartPx + d)); + } else { + int d = mouseY - resizeSubStartMouse; + resizeSubBar.iconCustomH = Math.max(4, Math.min(64, resizeSubStartPx + d)); + } + } + + // Bar drag threshold check (only when bar itself is selected, not a sub-element) + if (mouseButtonHeld && selectedBar != null && selectedSubElement == null + && cursorBar == null && !resizing && !resizingHeight + && !draggingSubElement && !resizingSubElement) { + double dx = mouseX - dragStartX; + double dy = mouseY - dragStartY; + if (dx * dx + dy * dy > (double) DRAG_THRESHOLD * DRAG_THRESHOLD) { + startBarDrag(selectedBar); + } + } + + context.blitSprite(RenderPipelines.GUI_TEXTURED, HOTBAR_TEXTURE, width / 2 - HOTBAR_WIDTH / 2, height - 22, HOTBAR_WIDTH, 22); + editBarWidget.render(context, mouseX, mouseY, delta); + + Window window = minecraft.getWindow(); + int scaleFactor = window.calculateScale(0, minecraft.isEnforceUnicode()) - window.getGuiScale() + 3; + if ((scaleFactor & 2) == 0) scaleFactor++; + ScreenRectangle mouseRect = new ScreenRectangle(new ScreenPosition(mouseX - scaleFactor / 2, mouseY - scaleFactor / 2), scaleFactor, scaleFactor); + + // Draw selection visuals + if (selectedBar != null && cursorBar == null && selectedBar.enabled) { + if (selectedSubElement == null) { + // Bar selected: yellow outline with arrows + drawBarOutlineWithArrows(context, selectedBar); + } else if ("text".equals(selectedSubElement) + && selectedBar.getTextPosition() == StatusBar.TextPosition.CUSTOM) { + // Text sub-element selected: cyan handles only + drawCustomElementHandles(context, selectedBar.getTextVisualArea(minecraft.font), true); + } else if ("icon".equals(selectedSubElement) + && selectedBar.getIconPosition() == StatusBar.IconPosition.CUSTOM) { + // Icon sub-element selected: cyan handles only + drawCustomElementHandles(context, selectedBar.getIconVisualArea(), false); + } + } + + // Decrement nudge overlay timer each frame + if (nudgeOverlayTimer > 0) nudgeOverlayTimer--; + + if (cursorBar != null) { + int bx = mouseX + cursorOffset.x(); + int by = mouseY + cursorOffset.y(); + cursorBar.renderCursor(context, bx, by, delta); + + // Position overlay: X from left, Y from bottom-left (bottom of bar = Y 0) + int posX = bx; + int posY = this.height - by - cursorBar.getHeight(); + String posText = "X: " + posX + " Y: " + posY; + int labelX = bx + cursorBar.getWidth() / 2 - minecraft.font.width(posText) / 2; + int labelY = by - 14; + // Dark background for readability + context.fill(labelX - 2, labelY - 1, labelX + minecraft.font.width(posText) + 2, labelY + 10, 0xAA000000); + context.drawString(minecraft.font, posText, labelX, labelY, BAR_SEL_COLOR, false); + + } else if (draggingSubElement && selectedBar != null) { + // Position overlay while dragging a text or icon sub-element + String overlayText; + int centerX, aboveY; + if (draggingText) { + overlayText = "offX: " + selectedBar.textCustomOffX + " offY: " + selectedBar.textCustomOffY; + ScreenRectangle vis = selectedBar.getTextVisualArea(minecraft.font); + centerX = vis.position().x() + vis.width() / 2; + aboveY = vis.position().y() - 14; + } else { + overlayText = "offX: " + selectedBar.iconCustomOffX + " offY: " + selectedBar.iconCustomOffY; + ScreenRectangle vis = selectedBar.getIconVisualArea(); + centerX = vis.position().x() + vis.width() / 2; + aboveY = vis.position().y() - 14; + } + int lx = centerX - minecraft.font.width(overlayText) / 2; + context.fill(lx - 2, aboveY - 1, lx + minecraft.font.width(overlayText) + 2, aboveY + 10, 0xAA000000); + context.drawString(minecraft.font, overlayText, lx, aboveY, BAR_SEL_COLOR, false); + + } else if (nudgeOverlayTimer > 0 && selectedBar != null) { + // Position overlay after a keyboard nudge — show for ~80 frames then fade out + String overlayText; + int centerX, aboveY; + if (selectedSubElement == null) { + // Bar position + overlayText = "X: " + selectedBar.getX() + " Y: " + (this.height - selectedBar.getY() - selectedBar.getHeight()); + centerX = selectedBar.getX() + selectedBar.getWidth() / 2; + aboveY = selectedBar.getY() - 14; + } else if ("text".equals(selectedSubElement)) { + overlayText = "offX: " + selectedBar.textCustomOffX + " offY: " + selectedBar.textCustomOffY; + ScreenRectangle vis = selectedBar.getTextVisualArea(minecraft.font); + centerX = vis.position().x() + vis.width() / 2; + aboveY = vis.position().y() - 14; + } else { + overlayText = "offX: " + selectedBar.iconCustomOffX + " offY: " + selectedBar.iconCustomOffY; + ScreenRectangle vis = selectedBar.getIconVisualArea(); + centerX = vis.position().x() + vis.width() / 2; + aboveY = vis.position().y() - 14; + } + int lx = centerX - minecraft.font.width(overlayText) / 2; + context.fill(lx - 2, aboveY - 1, lx + minecraft.font.width(overlayText) + 2, aboveY + 10, 0xAA000000); + context.drawString(minecraft.font, overlayText, lx, aboveY, BAR_SEL_COLOR, false); + + } else { + if (resizing) { + int middleX; + StatusBar rightBar = resizedBars.right(); + StatusBar leftBar = resizedBars.left(); + boolean hasRight = rightBar != null; + boolean hasLeft = leftBar != null; + BarPositioner.BarAnchor barAnchor; + if (!hasRight) { barAnchor = leftBar.anchor; middleX = leftBar.getX() + leftBar.getWidth(); } + else { barAnchor = rightBar.anchor; middleX = rightBar.getX(); } + + if (barAnchor != null) { + BarPositioner.SizeRule sizeRule = barAnchor.getSizeRule(); + boolean doResize = true; + float widthPerSize = sizeRule.isTargetSize() + ? (float) sizeRule.totalWidth() / sizeRule.targetSize() + : sizeRule.widthPerSize(); + if (mouseX < middleX) { + if (middleX - mouseX > widthPerSize / RESIZE_THRESHOLD) { + if (hasRight && rightBar.size + 1 > sizeRule.maxSize()) doResize = false; + if (hasLeft && leftBar.size - 1 < sizeRule.minSize()) doResize = false; + if (doResize) { if (hasRight) rightBar.size++; if (hasLeft) leftBar.size--; FancyStatusBars.updatePositions(true); } + } + } else { + if (mouseX - middleX > widthPerSize / RESIZE_THRESHOLD) { + if (hasRight && rightBar.size - 1 < sizeRule.minSize()) doResize = false; + if (hasLeft && leftBar.size + 1 > sizeRule.maxSize()) doResize = false; + if (doResize) { if (hasRight) rightBar.size--; if (hasLeft) leftBar.size++; FancyStatusBars.updatePositions(true); } + } + } + } else { + if (hasLeft) leftBar.setWidth(Math.max(BAR_MINIMUM_WIDTH, mouseX - leftBar.getX())); + else if (hasRight) { int endX = rightBar.getX() + rightBar.getWidth(); rightBar.setX(Math.min(endX - BAR_MINIMUM_WIDTH, mouseX)); rightBar.setWidth(endX - rightBar.getX()); } + } + + } else if (resizingHeight && heightResizeBar != null) { + if (heightResizeFromTop) { + int bottom = heightResizeInitialY + heightResizeInitialHeight; + int newTop = Math.min(bottom - StatusBar.MIN_BAR_HEIGHT, mouseY); + heightResizeBar.setY(newTop); + heightResizeBar.barHeight = bottom - newTop; + heightResizeBar.y = (float) newTop / height; + } else { + heightResizeBar.barHeight = Math.max(StatusBar.MIN_BAR_HEIGHT, mouseY - heightResizeBar.getY()); + } + + } else if (resizingSubElement) { + context.requestCursor(resizeSubIsHoriz ? CursorTypes.RESIZE_EW : CursorTypes.RESIZE_NS); + + } else { + // Hover detection: sub-element resize edges (highest priority when sub-element selected) + subElementEdgeHover = SubElementEdge.NONE; + subElementEdgeBar = null; + if (selectedBar != null && selectedBar.enabled && selectedSubElement != null) { + if ("text".equals(selectedSubElement) && selectedBar.getTextPosition() == StatusBar.TextPosition.CUSTOM) { + ScreenRectangle vis = selectedBar.getTextVisualArea(minecraft.font); + int rx = vis.position().x() + vis.width(); + int ly = vis.position().y(), by_ = ly + vis.height(); + if (Math.abs(mouseX - rx) <= EDGE_TOLERANCE && mouseY >= ly && mouseY <= by_) { + subElementEdgeHover = SubElementEdge.TEXT_RIGHT; + subElementEdgeBar = selectedBar; + context.requestCursor(CursorTypes.RESIZE_EW); + } + } + if (subElementEdgeHover == SubElementEdge.NONE && "icon".equals(selectedSubElement) + && selectedBar.getIconPosition() == StatusBar.IconPosition.CUSTOM) { + ScreenRectangle vis = selectedBar.getIconVisualArea(); + int rx = vis.position().x() + vis.width(); + int byx = vis.position().y() + vis.height(); + int lx = vis.position().x(), ly = vis.position().y(); + if (Math.abs(mouseX - rx) <= EDGE_TOLERANCE && mouseY >= ly && mouseY <= byx) { + subElementEdgeHover = SubElementEdge.ICON_RIGHT; subElementEdgeBar = selectedBar; + context.requestCursor(CursorTypes.RESIZE_EW); + } else if (Math.abs(mouseY - byx) <= EDGE_TOLERANCE && mouseX >= lx && mouseX <= rx) { + subElementEdgeHover = SubElementEdge.ICON_BOTTOM; subElementEdgeBar = selectedBar; + context.requestCursor(CursorTypes.RESIZE_NS); + } + } + } + + if (subElementEdgeHover == SubElementEdge.NONE) { + // Bar horizontal resize edges + boolean foundHorizontal = false; + rectLoop: + for (ScreenRectangle screenRect : rectToBar.keySet()) { + for (ScreenDirection direction : new ScreenDirection[]{ScreenDirection.LEFT, ScreenDirection.RIGHT}) { + if (screenRect.getBorder(direction).step(direction).overlaps(mouseRect) && !editBarWidget.isMouseOver(mouseX, mouseY)) { + Pair barPair = rectToBar.get(screenRect); + BarLocation barLocation = barPair.right(); + StatusBar bar = barPair.left(); + if (!bar.enabled) break; + // Only resize the selected bar + if (bar != selectedBar || selectedSubElement != null) { resizeHover.first(null); break; } + boolean right = direction.equals(ScreenDirection.RIGHT); + if (barLocation.barAnchor() != null) { + if (barLocation.barAnchor().getSizeRule().isTargetSize() && !FancyStatusBars.barPositioner.hasNeighbor(barLocation.barAnchor(), barLocation.y(), barLocation.x(), right)) break; + if (!barLocation.barAnchor().getSizeRule().isTargetSize() && barLocation.x() == 0 && barLocation.barAnchor().isRight() != right) break; + } + resizeHover.first(bar); resizeHover.right(right); + resizeHeightHover.first(null); + foundHorizontal = true; + context.requestCursor(CursorTypes.RESIZE_EW); + break rectLoop; + } else { resizeHover.first(null); } + } + } + + if (!foundHorizontal) { + resizeHover.first(null); + heightLoop: + for (ScreenRectangle screenRect : rectToBar.keySet()) { + Pair barPair = rectToBar.get(screenRect); + StatusBar bar = barPair.left(); + if (!bar.enabled || bar.anchor != null) continue; + // Only resize the selected bar + if (bar != selectedBar || selectedSubElement != null) continue; + for (ScreenDirection direction : new ScreenDirection[]{ScreenDirection.UP, ScreenDirection.DOWN}) { + if (screenRect.getBorder(direction).step(direction).overlaps(mouseRect) && !editBarWidget.isMouseOver(mouseX, mouseY)) { + resizeHeightHover.first(bar); + resizeHeightHover.right(direction.equals(ScreenDirection.UP)); + context.requestCursor(CursorTypes.RESIZE_NS); + break heightLoop; + } else { resizeHeightHover.first(null); } + } + } + } else { resizeHeightHover.first(null); } + } + } + } + } + + // ─────────────────────── Outline / arrow drawing ─────────────────────── + + /** Yellow selection outline + directional move arrows around the bar. */ + private void drawBarOutlineWithArrows(GuiGraphics ctx, StatusBar bar) { + int x = bar.getX(), y = bar.getY(); + int w = bar.getWidth(), h = bar.getHeight(); + // 1px inner border (drawn ON the bar edge, bar still fully visible underneath) + ctx.fill(x, y, x + w, y + 1, BAR_SEL_COLOR); + ctx.fill(x, y + h - 1, x + w, y + h, BAR_SEL_COLOR); + ctx.fill(x, y, x + 1, y + h, BAR_SEL_COLOR); + ctx.fill(x + w - 1, y, x + w, y + h, BAR_SEL_COLOR); + // Arrows at midpoints of each edge (outside the bar) + int mx = x + w / 2, my = y + h / 2; + fillLeftArrow(ctx, x - 7, my, BAR_SEL_COLOR); + fillRightArrow(ctx, x + w + 6, my, BAR_SEL_COLOR); + fillUpArrow(ctx, mx, y - 7, BAR_SEL_COLOR); + fillDownArrow(ctx, mx, y + h + 6, BAR_SEL_COLOR); + } + + /** + * Cyan border + outward move arrows for a CUSTOM text or icon sub-element. + * Also shows white resize handle squares at resize edges. + * @param isText true=text (right-edge resize only), false=icon (right + bottom resize) + */ + private void drawCustomElementHandles(GuiGraphics ctx, ScreenRectangle area, boolean isText) { + int x = area.position().x(), y = area.position().y(); + int w = area.width(), h = area.height(); + // 1px border + ctx.fill(x, y, x + w, y + 1, HANDLE_COLOR); + ctx.fill(x, y + h - 1, x + w, y + h, HANDLE_COLOR); + ctx.fill(x, y, x + 1, y + h, HANDLE_COLOR); + ctx.fill(x + w - 1, y, x + w, y + h, HANDLE_COLOR); + // Move arrows + int mx = x + w / 2, my = y + h / 2; + fillLeftArrow(ctx, x - 7, my, HANDLE_COLOR); + fillRightArrow(ctx, x + w + 6, my, HANDLE_COLOR); + fillUpArrow(ctx, mx, y - 7, HANDLE_COLOR); + fillDownArrow(ctx, mx, y + h + 6, HANDLE_COLOR); + // Right-edge resize handle (white square) + ctx.fill(x + w + 2, my - 3, x + w + 7, my + 3, 0xFFFFFFFF); + // Bottom-edge resize handle (icon only) + if (!isText) ctx.fill(mx - 3, y + h + 2, mx + 3, y + h + 7, 0xFFFFFFFF); + } + + private static void fillLeftArrow(GuiGraphics ctx, int tipX, int midY, int color) { + ctx.fill(tipX, midY, tipX + 1, midY + 1, color); + ctx.fill(tipX + 1, midY - 1, tipX + 2, midY + 2, color); + ctx.fill(tipX + 2, midY - 2, tipX + 5, midY + 3, color); + } + private static void fillRightArrow(GuiGraphics ctx, int tipX, int midY, int color) { + ctx.fill(tipX, midY, tipX + 1, midY + 1, color); + ctx.fill(tipX - 1, midY - 1, tipX, midY + 2, color); + ctx.fill(tipX - 4, midY - 2, tipX - 1, midY + 3, color); + } + private static void fillUpArrow(GuiGraphics ctx, int midX, int tipY, int color) { + ctx.fill(midX, tipY, midX + 1, tipY + 1, color); + ctx.fill(midX - 1, tipY + 1, midX + 2, tipY + 2, color); + ctx.fill(midX - 2, tipY + 2, midX + 3, tipY + 5, color); + } + private static void fillDownArrow(GuiGraphics ctx, int midX, int tipY, int color) { + ctx.fill(midX, tipY, midX + 1, tipY + 1, color); + ctx.fill(midX - 1, tipY - 1, midX + 2, tipY, color); + ctx.fill(midX - 2, tipY - 4, midX + 3, tipY - 1, color); + } + + // ─────────────────────── Screen lifecycle ─────────────────────── + + @Override + protected void init() { + super.init(); + FancyStatusBars.updatePositions(true); + editBarWidget = new EditBarWidget(0, 0, this); + editBarWidget.visible = false; + addWidget(editBarWidget); + Collection values = FancyStatusBars.statusBars.values(); + values.forEach(this::setup); + updateScreenRects(); + this.addRenderableWidget(Button.builder(Component.literal("?"), + button -> minecraft.setScreen(new TipsScreen(this))) + .bounds(width - 20, (height - 15) / 2, 15, 15) + .build()); + this.addRenderableWidget(Button.builder(Component.translatable("skyblocker.bars.config.resetToDefault"), + button -> { + FancyStatusBars.resetToDefaults(); + clearSelection(); + updateScreenRects(); + }) + .bounds(5, 5, 110, 14) + .build()); + } + + private void setup(StatusBar statusBar) { + this.addRenderableWidget(statusBar); + statusBar.setOnClick(this::onBarClick); + } + + @Override + public void removed() { + super.removed(); + FancyStatusBars.statusBars.values().forEach(sb -> sb.setOnClick(null)); + if (cursorBar != null) cursorBar.inMouse = false; + FancyStatusBars.updatePositions(false); + FancyStatusBars.saveBarConfig(); + } + + @Override + public boolean isPauseScreen() { return false; } + + // ─────────────────────── Click handlers ─────────────────────── + + private void onBarClick(StatusBar statusBar, MouseButtonEvent click) { + if (click.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + int cx = (int) click.x(), cy = (int) click.y(); + ScreenRectangle clickRect = new ScreenRectangle(cx, cy, 1, 1); + + // ── Check for sub-element resize edge (only when that sub-element is already selected) ── + if (selectedBar == statusBar && selectedSubElement != null) { + if (tryStartSubElementResize(statusBar, cx, cy)) return; + } + + // ── Check if click lands on a CUSTOM text element ── + if (statusBar.getTextPosition() == StatusBar.TextPosition.CUSTOM + && statusBar.getTextHitArea(minecraft.font).overlaps(clickRect)) { + boolean alreadySelected = selectedBar == statusBar && "text".equals(selectedSubElement); + selectedBar = statusBar; + selectedSubElement = "text"; + editBarWidget.visible = false; + if (alreadySelected) { + mouseButtonHeld = true; + subDragStartMouseX = cx; subDragStartMouseY = cy; + subDragStartOffX = statusBar.textCustomOffX; subDragStartOffY = statusBar.textCustomOffY; + draggingSubElement = true; draggingText = true; + } + return; + } + + // ── Check if click lands on a CUSTOM icon element ── + if (statusBar.getIconPosition() == StatusBar.IconPosition.CUSTOM + && statusBar.getIconHitArea().overlaps(clickRect)) { + boolean alreadySelected = selectedBar == statusBar && "icon".equals(selectedSubElement); + selectedBar = statusBar; + selectedSubElement = "icon"; + editBarWidget.visible = false; + if (alreadySelected) { + mouseButtonHeld = true; + subDragStartMouseX = cx; subDragStartMouseY = cy; + subDragStartOffX = statusBar.iconCustomOffX; subDragStartOffY = statusBar.iconCustomOffY; + draggingSubElement = true; draggingText = false; + } + return; + } + + // ── Click on bar body → select if not yet; drag only if already selected ── + boolean alreadyBarSelected = selectedBar == statusBar && selectedSubElement == null; + selectedBar = statusBar; + selectedSubElement = null; + editBarWidget.visible = false; + if (alreadyBarSelected) { + mouseButtonHeld = true; + dragStartX = cx; dragStartY = cy; + cursorOffset = new ScreenPosition(statusBar.getX() - cx, statusBar.getY() - cy); + } + + } else if (click.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + selectedBar = statusBar; + selectedSubElement = null; + int x = (int) Math.min(click.x() + 5, width - editBarWidget.getWidth()); + int y = (int) Math.min(click.y() + 5, height - editBarWidget.getHeight()); + editBarWidget.insideMouseX = (int) click.x(); + editBarWidget.insideMouseY = (int) click.y(); + editBarWidget.visible = true; + editBarWidget.setStatusBar(statusBar); + editBarWidget.setX(x); editBarWidget.setY(y); + } + } + + private void updateScreenRects() { + rectToBar.clear(); + FancyStatusBars.statusBars.values().forEach(sb -> { + if (!sb.enabled) return; + rectToBar.put(new ScreenRectangle(new ScreenPosition(sb.getX(), sb.getY()), sb.getWidth(), sb.getHeight()), + Pair.of(sb, BarLocation.of(sb))); + }); + } + + @Override + public boolean mouseReleased(MouseButtonEvent click) { + mouseButtonHeld = false; + + if (resizingSubElement) { + resizingSubElement = false; resizeSubBar = null; + return true; + } + if (draggingSubElement) { + draggingSubElement = false; + return true; + } + + if (cursorBar != null) { + cursorBar.inMouse = false; + cursorBar.anchor = null; + cursorBar.x = (float) ((click.x() + cursorOffset.x()) / width); + cursorBar.y = (float) ((click.y() + cursorOffset.y()) / height); + cursorBar.width = Math.clamp(cursorBar.width, (float) BAR_MINIMUM_WIDTH / width, 1); + cursorBar = null; + FancyStatusBars.updatePositions(true); + updateScreenRects(); + return true; + } else if (resizing) { + resizing = false; + StatusBar bar = resizedBars.left() != null ? resizedBars.left() : resizedBars.right(); + if (bar != null && bar.anchor == null) { bar.x = (float) bar.getX() / width; bar.width = (float) bar.getWidth() / width; } + resizedBars.left(null); resizedBars.right(null); + updateScreenRects(); + return true; + } else if (resizingHeight) { + resizingHeight = false; + if (heightResizeBar != null) { heightResizeBar.y = (float) heightResizeBar.getY() / height; heightResizeBar = null; } + updateScreenRects(); + return true; + } + return super.mouseReleased(click); + } + + // ─────────────────────── Keyboard nudge ─────────────────────── + + @Override + public boolean keyPressed(KeyEvent keyEvent) { + int key = keyEvent.key(); + boolean arrow = key == GLFW.GLFW_KEY_LEFT || key == GLFW.GLFW_KEY_RIGHT + || key == GLFW.GLFW_KEY_UP || key == GLFW.GLFW_KEY_DOWN; + + int mods = keyEvent.modifiers(); + boolean shiftHeld = (mods & GLFW.GLFW_MOD_SHIFT) != 0; + boolean altHeld = (mods & GLFW.GLFW_MOD_ALT) != 0; + + if (arrow && selectedBar != null && (shiftHeld || altHeld)) { + boolean shift = shiftHeld; + boolean alt = altHeld; + boolean left = key == GLFW.GLFW_KEY_LEFT; + boolean right = key == GLFW.GLFW_KEY_RIGHT; + boolean up = key == GLFW.GLFW_KEY_UP; + boolean down = key == GLFW.GLFW_KEY_DOWN; + + if (selectedSubElement == null) { + // ── Bar ── + if (shift && selectedBar.anchor == null) { + // Nudge position (floating bars only) + if (left) selectedBar.x -= 1.0f / width; + if (right) selectedBar.x += 1.0f / width; + if (up) selectedBar.y -= 1.0f / height; + if (down) selectedBar.y += 1.0f / height; + } + if (alt) { + // Resize bar + if ((left || right) && selectedBar.anchor == null) { + int newW = selectedBar.getWidth() + (right ? 1 : -1); + selectedBar.setWidth(Math.max(BAR_MINIMUM_WIDTH, newW)); + selectedBar.width = (float) selectedBar.getWidth() / width; + } + if (up || down) { + selectedBar.barHeight = Math.max(StatusBar.MIN_BAR_HEIGHT, + selectedBar.barHeight + (down ? 1 : -1)); + } + } + } else if ("text".equals(selectedSubElement)) { + // ── Custom text ── + if (shift) { + if (left) selectedBar.textCustomOffX--; + if (right) selectedBar.textCustomOffX++; + if (up) selectedBar.textCustomOffY--; + if (down) selectedBar.textCustomOffY++; + } + if (alt) { + // LEFT/RIGHT scales text; UP/DOWN currently unused for text + float step = 0.05f; + if (left) selectedBar.textCustomScale = Math.max(0.5f, selectedBar.textCustomScale - step); + if (right) selectedBar.textCustomScale = Math.min(4.0f, selectedBar.textCustomScale + step); + } + } else if ("icon".equals(selectedSubElement)) { + // ── Custom icon ── + if (shift) { + if (left) selectedBar.iconCustomOffX--; + if (right) selectedBar.iconCustomOffX++; + if (up) selectedBar.iconCustomOffY--; + if (down) selectedBar.iconCustomOffY++; + } + if (alt) { + if (left) selectedBar.iconCustomW = Math.max(4, selectedBar.iconCustomW - 1); + if (right) selectedBar.iconCustomW = Math.min(64, selectedBar.iconCustomW + 1); + if (up) selectedBar.iconCustomH = Math.max(4, selectedBar.iconCustomH - 1); + if (down) selectedBar.iconCustomH = Math.min(64, selectedBar.iconCustomH + 1); + } + } + + FancyStatusBars.updatePositions(true); + updateScreenRects(); + nudgeOverlayTimer = 80; + return true; + } + + return super.keyPressed(keyEvent); + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + int cx = (int) click.x(), cy = (int) click.y(); + + // Sub-element edge resize click (highest priority, only for selected sub-element) + if (click.button() == 0 && subElementEdgeHover != SubElementEdge.NONE && subElementEdgeBar != null) { + if (tryStartSubElementResize(subElementEdgeBar, cx, cy)) return true; + } + + // Height resize click + StatusBar heightBar = resizeHeightHover.first(); + if (!editBarWidget.isMouseOver(click.x(), click.y()) && click.button() == 0 && heightBar != null) { + resizingHeight = true; + heightResizeFromTop = resizeHeightHover.rightBoolean(); + heightResizeBar = heightBar; + heightResizeInitialY = heightBar.getY(); + heightResizeInitialHeight = heightBar.barHeight; + mouseButtonHeld = false; + return true; + } + + // Width resize click + StatusBar first = resizeHover.first(); + if (!editBarWidget.isMouseOver(click.x(), click.y()) && click.button() == 0 && first != null) { + BarPositioner.BarAnchor barAnchor = first.anchor; + if (barAnchor != null) { + if (resizeHover.rightBoolean()) { + resizedBars.left(first); + resizedBars.right(FancyStatusBars.barPositioner.hasNeighbor(barAnchor, first.gridY, first.gridX, true) + ? FancyStatusBars.barPositioner.getBar(barAnchor, first.gridY, first.gridX + (barAnchor.isRight() ? 1 : -1)) : null); + } else { + resizedBars.right(first); + resizedBars.left(FancyStatusBars.barPositioner.hasNeighbor(barAnchor, first.gridY, first.gridX, false) + ? FancyStatusBars.barPositioner.getBar(barAnchor, first.gridY, first.gridX + (barAnchor.isRight() ? -1 : 1)) : null); + } + } else { + if (resizeHover.rightBoolean()) { resizedBars.left(first); resizedBars.right(null); } + else { resizedBars.right(first); resizedBars.left(null); } + } + resizing = true; + return true; + } + + // Global CUSTOM element hit detection (text/icon may be outside bar bounds) + if (click.button() == 0 && !editBarWidget.isMouseOver(click.x(), click.y())) { + ScreenRectangle clickRect = new ScreenRectangle(cx, cy, 1, 1); + for (StatusBar bar : FancyStatusBars.statusBars.values()) { + if (!bar.enabled) continue; + if (bar.getTextPosition() == StatusBar.TextPosition.CUSTOM + && bar.getTextHitArea(minecraft.font).overlaps(clickRect) + && !isInBarBody(bar, cx, cy)) { + boolean alreadyTextSelected = selectedBar == bar && "text".equals(selectedSubElement); + if (alreadyTextSelected && tryStartSubElementResize(bar, cx, cy)) return true; + selectedBar = bar; selectedSubElement = "text"; + editBarWidget.visible = false; + if (alreadyTextSelected) { + mouseButtonHeld = true; + subDragStartMouseX = cx; subDragStartMouseY = cy; + subDragStartOffX = bar.textCustomOffX; subDragStartOffY = bar.textCustomOffY; + draggingSubElement = true; draggingText = true; + } + return true; + } + if (bar.getIconPosition() == StatusBar.IconPosition.CUSTOM + && bar.getIconHitArea().overlaps(clickRect) + && !isInBarBody(bar, cx, cy)) { + boolean alreadyIconSelected = selectedBar == bar && "icon".equals(selectedSubElement); + if (alreadyIconSelected && tryStartSubElementResize(bar, cx, cy)) return true; + selectedBar = bar; selectedSubElement = "icon"; + editBarWidget.visible = false; + if (alreadyIconSelected) { + mouseButtonHeld = true; + subDragStartMouseX = cx; subDragStartMouseY = cy; + subDragStartOffX = bar.iconCustomOffX; subDragStartOffY = bar.iconCustomOffY; + draggingSubElement = true; draggingText = false; + } + return true; + } + } + } + + boolean handled = super.mouseClicked(click, doubled); + + // If nothing handled the click, check if we should keep or clear selection + if (!handled && !editBarWidget.isMouseOver(click.x(), click.y()) && click.button() == 0) { + if (selectedBar != null && isInBarExtendedZone(selectedBar, cx, cy)) { + // Click is in the arrow zone of the selected bar — keep selection and start drag + if (selectedSubElement == null) { + mouseButtonHeld = true; + dragStartX = cx; dragStartY = cy; + cursorOffset = new ScreenPosition(selectedBar.getX() - cx, selectedBar.getY() - cy); + } + // (sub-element already selected — do nothing special, just don't deselect) + } else { + clearSelection(); + } + } + + return handled; + } } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/fancybars/TipsScreen.java b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/TipsScreen.java new file mode 100644 index 00000000000..d1537f74d89 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/fancybars/TipsScreen.java @@ -0,0 +1,152 @@ +package de.hysky.skyblocker.skyblock.fancybars; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; + +import java.util.List; + +/** + * Paginated tips dialog for the FancyStatusBars config screen. + * Shows one tip at a time with ← / → navigation and a Close button. + */ +public class TipsScreen extends Screen { + + private static final int DIALOG_W = 300; + private static final int DIALOG_H = 190; + private static final int PAD = 10; + + private static final int COLOR_BG = 0xFF111122; + private static final int COLOR_BORDER = 0xFFFFFF55; + private static final int COLOR_DIV = 0xFF444466; + private static final int COLOR_TITLE = 0xFFFFFF55; + private static final int COLOR_PAGE = 0xFFAAAAAA; + private static final int COLOR_TEXT = 0xFFEEEEEE; + + private static final List TIPS = List.of( + "LEFT-CLICK a bar to select it — a yellow outline with arrows appears. " + + "Hold and drag to pick it up and place it anywhere on screen.", + + "RIGHT-CLICK any bar to open its options panel. " + + "You can change colors, bar height, border radius, text/icon position, " + + "show-max, show-overflow, and more.", + + "Set Text or Icon position to CUSTOM in the options panel. " + + "Then LEFT-CLICK that text or icon element (cyan outline) to select it " + + "and drag it anywhere — even outside the bar.", + + "Hold SHIFT + Arrow Keys to nudge the selected bar, text, or icon " + + "exactly 1 pixel at a time. Great for pixel-perfect alignment without touching the mouse.", + + "Hold ALT + Arrow Keys to resize the selected element by 1 pixel: " + + "LEFT / RIGHT changes width (or text scale for Custom text), " + + "UP / DOWN changes height.", + + "When a CUSTOM text or icon is selected (cyan outline), drag the white " + + "resize square on its right edge to scale / resize. " + + "Icons also have a bottom-edge handle for height.", + + "Hover over the border between two side-by-side bars until a resize " + + "cursor appears, then drag to redistribute their widths.", + + "While dragging a bar, a yellow label shows its live position. " + + "X = pixels from the left edge. Y = pixels from the bottom " + + "(Y = 0 means the bar's bottom is at the very bottom of the screen).", + + "Use the 'Reset to Default' button to instantly restore every bar " + + "to its original position, size, colors, and all other settings." + ); + + private final Screen parent; + private int currentTip = 0; + private Button prevButton; + private Button nextButton; + + public TipsScreen(Screen parent) { + super(Component.literal("Tips & Tricks")); + this.parent = parent; + } + + private int dlgX() { return (width - DIALOG_W) / 2; } + private int dlgY() { return (height - DIALOG_H) / 2; } + + @Override + protected void init() { + super.init(); + int dx = dlgX(), dy = dlgY(); + int btnY = dy + DIALOG_H - 24; + + prevButton = addRenderableWidget( + Button.builder(Component.literal("←"), b -> navigate(-1)) + .bounds(dx + PAD, btnY, 26, 16) + .build()); + + addRenderableWidget( + Button.builder(Component.literal("Close"), b -> minecraft.setScreen(parent)) + .bounds(dx + DIALOG_W / 2 - 26, btnY, 52, 16) + .build()); + + nextButton = addRenderableWidget( + Button.builder(Component.literal("→"), b -> navigate(+1)) + .bounds(dx + DIALOG_W - PAD - 26, btnY, 26, 16) + .build()); + + updateNavButtons(); + } + + private void navigate(int delta) { + currentTip = Math.floorMod(currentTip + delta, TIPS.size()); + updateNavButtons(); + } + + private void updateNavButtons() { + boolean multi = TIPS.size() > 1; + prevButton.active = multi; + nextButton.active = multi; + } + + @Override + public void render(GuiGraphics ctx, int mouseX, int mouseY, float delta) { + renderTransparentBackground(ctx); + + int dx = dlgX(), dy = dlgY(); + + // Outer border (yellow, 1px) + ctx.fill(dx - 1, dy - 1, dx + DIALOG_W + 1, dy + DIALOG_H + 1, COLOR_BORDER); + // Background panel + ctx.fill(dx, dy, dx + DIALOG_W, dy + DIALOG_H, COLOR_BG); + + // Title + ctx.drawCenteredString(font, "Tips & Tricks", dx + DIALOG_W / 2, dy + PAD, COLOR_TITLE); + + // Divider under title + ctx.fill(dx + PAD, dy + 22, dx + DIALOG_W - PAD, dy + 23, COLOR_DIV); + + // Page indicator + String pageStr = "Tip " + (currentTip + 1) + " of " + TIPS.size(); + ctx.drawCenteredString(font, pageStr, dx + DIALOG_W / 2, dy + 27, COLOR_PAGE); + + // Tip text — word-wrapped + List lines = font.split( + Component.literal(TIPS.get(currentTip)), DIALOG_W - PAD * 2); + int textY = dy + 40; + for (FormattedCharSequence line : lines) { + ctx.drawString(font, line, dx + PAD, textY, COLOR_TEXT, false); + textY += font.lineHeight + 2; + } + + // Render buttons on top + super.render(ctx, mouseX, mouseY, delta); + } + + @Override + public boolean isPauseScreen() { return false; } + + @Override + public boolean shouldCloseOnEsc() { return true; } + + @Override + public void onClose() { minecraft.setScreen(parent); } +} diff --git a/src/main/resources/assets/skyblocker/lang/en_us.json b/src/main/resources/assets/skyblocker/lang/en_us.json index 8a9b2760f02..a2d29642b51 100644 --- a/src/main/resources/assets/skyblocker/lang/en_us.json +++ b/src/main/resources/assets/skyblocker/lang/en_us.json @@ -38,6 +38,7 @@ "skyblocker.api.token.noProfileKeys": "Failed to get your profile keys! Some features of the mod may not work temporarily :( (Has your game been open for more than 24 hours?). To reactivate these features, restart the game.", "skyblocker.bars.config.air": "Air", + "skyblocker.bars.config.commonPosition.CUSTOM": "Custom", "skyblocker.bars.config.commonPosition.LEFT": "Left", "skyblocker.bars.config.commonPosition.OFF": "Off", "skyblocker.bars.config.commonPosition.RIGHT": "Right", @@ -45,8 +46,17 @@ "skyblocker.bars.config.experience": "Experience", "skyblocker.bars.config.explanation": "Welcome to the status bars config screen!\n\nDrag and drop the bars to snap them to an anchor (white squares), existing bars or you can put them anywhere.\nYou can right click them to edit a bunch of properties.\nBy hovering your mouse between 2 bars (your cursor should change), you can resize them.\n\nEverything is saved when you leave.", "skyblocker.bars.config.explanationTitle": "What is this?", + "skyblocker.bars.config.resetToDefault": "Reset to Default", "skyblocker.bars.config.health": "Health", + "skyblocker.bars.config.borderRadius": "Border Radius", + "skyblocker.bars.config.borderRadius.dialog.title": "Set Border Radius", + "skyblocker.bars.config.borderRadius.dialog.prompt": "Enter radius (0–%s px):", + "skyblocker.bars.config.borderRadius.dialog.confirm": "Confirm", + "skyblocker.bars.config.borderRadius.dialog.cancel": "Cancel", + "skyblocker.bars.config.borderRadius.dialog.error": "Must be 0–%s", "skyblocker.bars.config.hide": "Hide", + "skyblocker.bars.config.show": "Show", + "skyblocker.bars.config.resetBar": "Reset This Bar", "skyblocker.bars.config.icon": "Icon", "skyblocker.bars.config.intelligence": "Intelligence", "skyblocker.bars.config.mainColor": "Main Color", @@ -58,6 +68,9 @@ "skyblocker.bars.config.textColor": "Text Color", "skyblocker.bars.config.textPosition.BAR_CENTER": "Bar Center", "skyblocker.bars.config.textPosition.CENTER": "Center", + "skyblocker.bars.config.textPosition.CUSTOM": "Custom", + "skyblocker.bars.config.textPosition.LEFT": "Bar Left", + "skyblocker.bars.config.textPosition.RIGHT": "Bar Right", "skyblocker.chat.confirmationPromptNotification": "Click anywhere on screen within 60 seconds to accept the prompt.",