diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/Layout.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/Layout.java index 620411c89f..7ef3a98069 100644 --- a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/Layout.java +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/Layout.java @@ -3,9 +3,9 @@ public class Layout { public static final int BUTTON_SHORT = 20; public static final int BUTTON_LONG = 65; - public static final int OUTER_MARGIN = 10; - public static final int BUTTON_SHORT_BOTTOM_Y = BUTTON_SHORT + OUTER_MARGIN; public static final int INNER_MARGIN = 5; + public static final int BOTTOM_MARGIN = INNER_MARGIN + 1; + public static final int BUTTON_SHORT_BOTTOM_Y = BUTTON_SHORT + BOTTOM_MARGIN; public static final int SCROLLBAR_WIDTH = 5; public static final int TEXT_LEFT_PADDING = 8; public static final int TEXT_PARAGRAPH_SPACING = 8; diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/VideoSettingsScreen.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/VideoSettingsScreen.java index 749cb72a24..100f8c5b69 100644 --- a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/VideoSettingsScreen.java +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/VideoSettingsScreen.java @@ -1,6 +1,5 @@ package net.caffeinemc.mods.sodium.client.gui; -import net.caffeinemc.mods.sodium.api.config.option.OptionImpact; import net.caffeinemc.mods.sodium.client.SodiumClientMod; import net.caffeinemc.mods.sodium.client.config.ConfigManager; import net.caffeinemc.mods.sodium.client.config.structure.IntegerOption; @@ -12,22 +11,19 @@ import net.caffeinemc.mods.sodium.client.gui.prompt.ScreenPrompt; import net.caffeinemc.mods.sodium.client.gui.prompt.ScreenPromptable; import net.caffeinemc.mods.sodium.client.gui.screen.ConfigCorruptedScreen; -import net.caffeinemc.mods.sodium.client.gui.widgets.FlatButtonWidget; -import net.caffeinemc.mods.sodium.client.gui.widgets.OptionListWidget; -import net.caffeinemc.mods.sodium.client.gui.widgets.PageListWidget; -import net.caffeinemc.mods.sodium.client.gui.widgets.ScrollbarWidget; +import net.caffeinemc.mods.sodium.client.gui.widgets.*; import net.caffeinemc.mods.sodium.client.services.PlatformRuntimeInformation; import net.caffeinemc.mods.sodium.client.util.Dim2i; -import net.minecraft.ChatFormatting; import net.minecraft.Util; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.FormattedText; import net.minecraft.resources.ResourceLocation; -import net.minecraft.util.FormattedCharSequence; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; @@ -60,12 +56,11 @@ public class VideoSettingsScreen extends Screen implements ScreenPromptable { private OptionListWidget optionList; private FlatButtonWidget applyButton, closeButton, undoButton; - private FlatButtonWidget donateButton, hideDonateButton; + private DonationButtonWidget donateButton; private boolean hasPendingChanges; - private ControlElement hoveredElement; - private @Nullable ScrollbarWidget tooltipScrollbar; + private final ScrollableTooltip tooltip = new ScrollableTooltip(this); private @Nullable ScreenPrompt prompt; @@ -195,47 +190,25 @@ private void rebuildGUI() { this.pageList = new PageListWidget(this, new Dim2i(0, 0, 125, this.height)); - this.undoButton = new FlatButtonWidget(new Dim2i(270, Layout.INNER_MARGIN, Layout.BUTTON_LONG, Layout.BUTTON_SHORT), Component.translatable("sodium.options.buttons.undo"), this::undoChanges, true, false); - this.applyButton = new FlatButtonWidget(new Dim2i(130, Layout.INNER_MARGIN, Layout.BUTTON_LONG, Layout.BUTTON_SHORT), Component.translatable("sodium.options.buttons.apply"), ConfigManager.CONFIG::applyAllOptions, true, false); - this.closeButton = new FlatButtonWidget(new Dim2i(200, Layout.INNER_MARGIN, Layout.BUTTON_LONG, Layout.BUTTON_SHORT), Component.translatable("gui.done"), this::onClose, true, false); + this.applyButton = new FlatButtonWidget(new Dim2i(this.pageList.getLimitX() + Layout.INNER_MARGIN, Layout.INNER_MARGIN, Layout.BUTTON_LONG, Layout.BUTTON_SHORT), Component.translatable("sodium.options.buttons.apply"), ConfigManager.CONFIG::applyAllOptions, true, false); + this.closeButton = new FlatButtonWidget(new Dim2i(this.applyButton.getLimitX() + Layout.INNER_MARGIN, Layout.INNER_MARGIN, Layout.BUTTON_LONG, Layout.BUTTON_SHORT), Component.translatable("gui.done"), this::onClose, true, false); + this.undoButton = new FlatButtonWidget(new Dim2i(this.closeButton.getLimitX() + Layout.INNER_MARGIN, Layout.INNER_MARGIN, Layout.BUTTON_LONG, Layout.BUTTON_SHORT), Component.translatable("sodium.options.buttons.undo"), this::undoChanges, true, false); - this.donateButton = new FlatButtonWidget(new Dim2i(this.width - 128, Layout.INNER_MARGIN, 100, Layout.BUTTON_SHORT), Component.translatable("sodium.options.buttons.donate"), this::openDonationPage, true, false); - this.hideDonateButton = new FlatButtonWidget(new Dim2i(this.width - 26, Layout.INNER_MARGIN, Layout.BUTTON_SHORT, Layout.BUTTON_SHORT), Component.literal("x"), this::hideDonationButton, true, false); - - if (SodiumClientMod.options().notifications.hasClearedDonationButton) { - // TODO: fix, this is for debugging - // this.setDonationButtonVisibility(false); - } + this.donateButton = new DonationButtonWidget(List.of(this.applyButton, this.closeButton, this.undoButton), this.width, this::openDonationPage, this::addRenderableWidget); this.addRenderableWidget(this.pageList); this.addRenderableWidget(this.undoButton); this.addRenderableWidget(this.applyButton); this.addRenderableWidget(this.closeButton); - this.addRenderableWidget(this.donateButton); - this.addRenderableWidget(this.hideDonateButton); - } - - private void setDonationButtonVisibility(boolean value) { - this.donateButton.setVisible(value); - this.hideDonateButton.setVisible(value); - } - - private void hideDonationButton() { - SodiumOptions options = SodiumClientMod.options(); - options.notifications.hasClearedDonationButton = true; - - try { - SodiumOptions.writeToDisk(options); - } catch (IOException e) { - throw new RuntimeException("Failed to save configuration", e); - } - - this.setDonationButtonVisibility(false); } private void rebuildGUIOptions() { this.removeWidget(this.optionList); - this.optionList = this.addRenderableWidget(new OptionListWidget(this, new Dim2i(130, Layout.INNER_MARGIN * 2 + Layout.BUTTON_SHORT, 210, this.height - (Layout.INNER_MARGIN * 2 + Layout.OUTER_MARGIN + Layout.BUTTON_SHORT)), this.currentPage, this.currentMod.theme())); + this.optionList = this.addRenderableWidget(new OptionListWidget(this, new Dim2i( + 130, Layout.INNER_MARGIN * 2 + Layout.BUTTON_SHORT, + 210, this.height - (Layout.INNER_MARGIN * 2 + Layout.BOTTOM_MARGIN + Layout.BUTTON_SHORT)), + this.currentPage, this.currentMod.theme() + )); } @Override @@ -244,9 +217,7 @@ public void render(@NotNull GuiGraphics graphics, int mouseX, int mouseY, float super.render(graphics, this.prompt != null ? -1 : mouseX, this.prompt != null ? -1 : mouseY, delta); - if (this.hoveredElement != null) { - this.renderOptionTooltip(graphics, this.hoveredElement); - } + this.tooltip.render(graphics); if (this.prompt != null) { this.prompt.render(graphics, mouseX, mouseY, delta); @@ -274,118 +245,17 @@ private void updateControls(int mouseX, int mouseY) { this.undoButton.setVisible(hasChanges); this.closeButton.setEnabled(!hasChanges); + this.donateButton.updateDisplay(); + this.hasPendingChanges = hasChanges; - this.updateHoveredElement(hovered, mouseX, mouseY); + this.tooltip.onControlHover(hovered, mouseX, mouseY); } private Stream getActiveControls() { return this.optionList.getControls().stream(); } - private void updateHoveredElement(ControlElement hovered, int mouseX, int mouseY) { - if (this.hoveredElement == hovered) { - return; - } - - if (hovered != null) { - this.hoveredElement = hovered; - - if (this.tooltipScrollbar != null) { - this.removeWidget(this.tooltipScrollbar); - this.tooltipScrollbar = null; - } - - Dim2i dimensions = this.getTooltipDimensions(hovered, this.getTooltip(hovered)); - if (dimensions.height() > this.height) { - this.tooltipScrollbar = this.addRenderableWidget(new ScrollbarWidget(new Dim2i( - dimensions.getLimitX() - 5, - dimensions.y(), - 5, - this.height - ))); - this.tooltipScrollbar.setScrollbarContext(this.height, dimensions.height()); - } - } else if (this.shouldUnHoverElement(this.hoveredElement, mouseX, mouseY)) { - this.hoveredElement = null; - - if (this.tooltipScrollbar != null) { - this.removeWidget(this.tooltipScrollbar); - this.tooltipScrollbar = null; - } - } - } - - private boolean shouldUnHoverElement(ControlElement element, int mouseX, int mouseY) { - Dim2i dimensions = this.getTooltipDimensions(element, this.getTooltip(element)); - - // handle the space between options and their tooltip - if (mouseX >= element.getLimitX() && mouseX < dimensions.x() && mouseY >= element.getY() && mouseY < element.getLimitY()) { - return false; - } - return !dimensions.containsCursor(mouseX, mouseY); - } - - private void renderOptionTooltip(GuiGraphics graphics, ControlElement element) { - int textPadding = Layout.INNER_MARGIN; - int lineHeight = this.font.lineHeight + 3; - - List tooltip = this.getTooltip(element); - Dim2i dimensions = this.getTooltipDimensions(element, tooltip); - - int scrollAmount = 0; - if (this.tooltipScrollbar != null) { - scrollAmount = this.tooltipScrollbar.getScrollAmount(); - } - - graphics.fill(dimensions.x(), dimensions.y(), dimensions.getLimitX(), dimensions.getLimitY(), 0x40000000); - for (int i = 0; i < tooltip.size(); i++) { - graphics.drawString(this.font, tooltip.get(i), dimensions.x() + textPadding, dimensions.y() + textPadding + (i * lineHeight) - scrollAmount, Colors.FOREGROUND); - } - } - - private List getTooltip(ControlElement element) { - int textPadding = Layout.INNER_MARGIN; - - int boxWidth = Math.min(200, this.width - element.getLimitX()); - - var option = element.getOption(); - var splitWidth = boxWidth - (textPadding * 2); - - List tooltip = new ArrayList<>(this.font.split(option.getTooltip(), splitWidth)); - OptionImpact impact = option.getImpact(); - - if (impact != null) { - var impactText = Component.translatable("sodium.options.performance_impact_string", impact.getName()); - tooltip.addAll(this.font.split(impactText.withStyle(ChatFormatting.GRAY), splitWidth)); - } - return tooltip; - } - - private Dim2i getTooltipDimensions(ControlElement element, List tooltip) { - int boxMargin = Layout.INNER_MARGIN; - int lineHeight = this.font.lineHeight + 3; - - int boxY = element.getY(); - int boxX = element.getLimitX() + boxMargin; - - int boxWidth = Math.min(200, this.width - boxX - boxMargin); - - int boxHeight = (tooltip.size() * lineHeight) + boxMargin; - int boxYLimit = boxY + boxHeight; - int boxYCutoff = this.height - Layout.INNER_MARGIN; - - // If the box is going to be cut off on the Y-axis, move it back up the difference - if (boxYLimit > boxYCutoff) { - boxY -= boxYLimit - boxYCutoff; - } - if (boxY < 0) { - boxY = 0; - } - - return new Dim2i(boxX, boxY, boxWidth, boxHeight); - } - private void undoChanges() { ConfigManager.CONFIG.resetAllOptions(); } @@ -427,7 +297,8 @@ public boolean mouseClicked(double mouseX, double mouseY, int button) { } @Override - public boolean mouseScrolled(double d, double e, double f, double amount) { + public boolean mouseScrolled(double x, double y, double f, double amount) { + // change the gui scale with scrolling if the control key is held if (Screen.hasControlDown()) { var location = ResourceLocation.parse("sodium:general.gui_scale"); var option = ConfigManager.CONFIG.getOption(location); @@ -457,11 +328,22 @@ public boolean mouseScrolled(double d, double e, double f, double amount) { } return false; } - if (this.tooltipScrollbar != null && this.getTooltipDimensions(this.hoveredElement, this.getTooltip(this.hoveredElement)).containsCursor(d, e)) { - this.tooltipScrollbar.scroll((int) (-amount * 10)); + + if (this.tooltip.mouseScrolled(x, y, amount)) { return true; } - return super.mouseScrolled(d, e, f, amount); + + return super.mouseScrolled(x, y, f, amount); + } + + @Override + public T addRenderableWidget(T guiEventListener) { + return super.addRenderableWidget(guiEventListener); + } + + @Override + public void removeWidget(GuiEventListener guiEventListener) { + super.removeWidget(guiEventListener); } @Override diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/widgets/DonationButtonWidget.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/widgets/DonationButtonWidget.java new file mode 100644 index 0000000000..c56bb9ecb7 --- /dev/null +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/widgets/DonationButtonWidget.java @@ -0,0 +1,139 @@ +package net.caffeinemc.mods.sodium.client.gui.widgets; + +import net.caffeinemc.mods.sodium.client.SodiumClientMod; +import net.caffeinemc.mods.sodium.client.gui.ButtonTheme; +import net.caffeinemc.mods.sodium.client.gui.Layout; +import net.caffeinemc.mods.sodium.client.gui.SodiumOptions; +import net.caffeinemc.mods.sodium.client.util.Dim2i; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.Collection; +import java.util.function.Consumer; + +public class DonationButtonWidget { + private static final int DONATE_BUTTON_WIDTH = 100; + private static final int CLOSE_BUTTON_MARGIN = 3; + + private static final ResourceLocation CUP_TEXTURE = ResourceLocation.fromNamespaceAndPath("sodium", "textures/gui/coffee_cup.png"); + private static final int CUP_SPRITE_SIZE = 10; + + private final FlatButtonWidget hideDonateButton; + private final FlatButtonWidget donateButtonLong; + private final FlatButtonWidget donateButtonCompact; + private boolean donateButtonEnabled = !SodiumClientMod.options().notifications.hasClearedDonationButton; + + private final Collection colliders; + + public DonationButtonWidget(Collection colliders, int width, Runnable openDonationPage, Consumer widgetConsumer) { + this.colliders = colliders; + + this.hideDonateButton = new FlatButtonWidget(new Dim2i(width - Layout.BUTTON_SHORT - Layout.INNER_MARGIN, Layout.INNER_MARGIN, Layout.BUTTON_SHORT, Layout.BUTTON_SHORT), Component.literal("x"), this::hideDonationButton, true, false); + var infoButtonOffset = this.hideDonateButton.getX() - CLOSE_BUTTON_MARGIN; + + this.donateButtonLong = new FlatButtonWidget(new Dim2i(infoButtonOffset - DONATE_BUTTON_WIDTH, Layout.INNER_MARGIN, DONATE_BUTTON_WIDTH, Layout.BUTTON_SHORT), Component.translatable("sodium.options.buttons.donate"), openDonationPage, true, false); + this.donateButtonCompact = new IconButtonWidget(new Dim2i(infoButtonOffset - Layout.BUTTON_SHORT, Layout.INNER_MARGIN, Layout.BUTTON_SHORT, Layout.BUTTON_SHORT), CUP_TEXTURE, CUP_SPRITE_SIZE, openDonationPage, true, false); + + widgetConsumer.accept(this.hideDonateButton); + widgetConsumer.accept(this.donateButtonLong); + widgetConsumer.accept(this.donateButtonCompact); + + this.updateDisplay(); + } + + public void updateDisplay() { + if (!this.donateButtonEnabled) { + this.setButtonState(ButtonState.HIDDEN); + return; + } + + var maxCollidingX = 0; + for (var collider : this.colliders) { + if (collider.isVisible()) { + maxCollidingX = Math.max(maxCollidingX, collider.getLimitX()); + } + } + maxCollidingX += Layout.INNER_MARGIN; + + if (maxCollidingX <= this.donateButtonLong.getX()) { + this.setButtonState(ButtonState.LONG); + } else if (maxCollidingX <= this.donateButtonCompact.getX()) { + this.setButtonState(ButtonState.COMPACT); + } else { + this.setButtonState(ButtonState.HIDDEN); + } + } + + private enum ButtonState { + HIDDEN, + LONG, + COMPACT + } + + private void setButtonState(ButtonState state) { + switch (state) { + case HIDDEN: + this.hideDonateButton.setVisible(false); + this.donateButtonLong.setVisible(false); + this.donateButtonCompact.setVisible(false); + break; + case LONG: + this.hideDonateButton.setVisible(true); + this.donateButtonLong.setVisible(true); + this.donateButtonCompact.setVisible(false); + break; + case COMPACT: + this.hideDonateButton.setVisible(true); + this.donateButtonLong.setVisible(false); + this.donateButtonCompact.setVisible(true); + break; + } + } + + private void hideDonationButton() { + SodiumOptions options = SodiumClientMod.options(); + options.notifications.hasClearedDonationButton = true; + + try { + SodiumOptions.writeToDisk(options); + } catch (IOException e) { + throw new RuntimeException("Failed to save configuration", e); + } + + this.donateButtonEnabled = false; + } + + private static class IconButtonWidget extends FlatButtonWidget { + private final ResourceLocation sprite; + private final int spriteSize; + + public IconButtonWidget(Dim2i dim, ResourceLocation sprite, int spriteSize, Runnable action, boolean drawBackground, boolean leftAlign, ButtonTheme theme) { + super(dim, null, action, drawBackground, leftAlign, theme); + + this.sprite = sprite; + this.spriteSize = spriteSize; + } + + public IconButtonWidget(Dim2i dim, ResourceLocation sprite, int spriteSize, Runnable action, boolean drawBackground, boolean leftAlign) { + super(dim, null, action, drawBackground, leftAlign); + + this.sprite = sprite; + this.spriteSize = spriteSize; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + super.render(graphics, mouseX, mouseY, delta); + + if (!this.isVisible()) { + return; + } + + var halfSpriteSize = this.spriteSize / 2; + graphics.blit(RenderType::guiTextured, this.sprite, this.getCenterX() - halfSpriteSize, this.getCenterY() - halfSpriteSize, 0, 0, this.spriteSize, this.spriteSize, this.spriteSize, this.spriteSize, this.getTextColor()); + } + } +} diff --git a/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/widgets/ScrollableTooltip.java b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/widgets/ScrollableTooltip.java new file mode 100644 index 0000000000..da2b97f279 --- /dev/null +++ b/common/src/main/java/net/caffeinemc/mods/sodium/client/gui/widgets/ScrollableTooltip.java @@ -0,0 +1,176 @@ +package net.caffeinemc.mods.sodium.client.gui.widgets; + +import net.caffeinemc.mods.sodium.api.config.option.OptionImpact; +import net.caffeinemc.mods.sodium.client.gui.Colors; +import net.caffeinemc.mods.sodium.client.gui.Layout; +import net.caffeinemc.mods.sodium.client.gui.VideoSettingsScreen; +import net.caffeinemc.mods.sodium.client.gui.options.control.ControlElement; +import net.caffeinemc.mods.sodium.client.util.Dim2i; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Mth; +import org.jetbrains.annotations.NotNull; +import org.joml.Vector2i; + +import java.util.ArrayList; +import java.util.List; + +// TODO: is narration of the tooltip already handled by the screen or is there no narration at all? +public class ScrollableTooltip { + private static final ResourceLocation ARROW_TEXTURE = ResourceLocation.fromNamespaceAndPath("sodium", "textures/gui/tooltip_arrows.png"); + private static final int ARROW_WIDTH = 5; + private static final int SPRITE_WIDTH = 10; + private static final int ARROW_HEIGHT = 9; + + private static final int MIN_TOOLTIP_WIDTH = 20; + private static final int MAX_TOOLTIP_WIDTH = 200; + private static final int TEXT_HORIZONTAL_PADDING = Layout.INNER_MARGIN - 1; + private static final int TEXT_VERTICAL_PADDING = TEXT_HORIZONTAL_PADDING; + private static final int INNER_BOX_MARGIN = ARROW_WIDTH; // arrow includes one pixel of margin + private static final int OUTER_BOX_MARGIN = 3; + private static final int BOTTOM_BOX_MARGIN = OUTER_BOX_MARGIN; + private static final int UPPER_BOX_MARGIN = Layout.BUTTON_SHORT + Layout.INNER_MARGIN * 2; + + private final Font font = Minecraft.getInstance().font; + private ControlElement hoveredElement; + private ScrollbarWidget scrollbar; + private Vector2i contentSize; + private Dim2i visibleDim; + private boolean needsScrolling; + private final List content = new ArrayList<>(); + private final VideoSettingsScreen screen; + + public ScrollableTooltip(VideoSettingsScreen screen) { + this.screen = screen; + } + + public void onControlHover(ControlElement hovered, int mouseX, int mouseY) { + if (this.hoveredElement == hovered) { + return; + } + + if (hovered != null) { + this.hoveredElement = hovered; + + if (this.scrollbar != null) { + this.screen.removeWidget(this.scrollbar); + this.scrollbar = null; + } + + this.updateTooltip(); + + if (this.needsScrolling) { + this.scrollbar = this.screen.addRenderableWidget(new ScrollbarWidget(new Dim2i( + this.visibleDim.getLimitX() - Layout.SCROLLBAR_WIDTH, + this.visibleDim.y(), + Layout.SCROLLBAR_WIDTH, + this.visibleDim.height() + ))); + this.scrollbar.setScrollbarContext(this.visibleDim.height(), this.contentSize.y()); + } + } else { + this.updateTooltip(); + + // handle the space between options and their tooltip + if ((mouseX < this.hoveredElement.getLimitX() || mouseX >= this.visibleDim.x() || + mouseY < this.hoveredElement.getY() || mouseY >= this.hoveredElement.getLimitY()) && + !this.visibleDim.containsCursor(mouseX, mouseY)) { + this.hoveredElement = null; + + if (this.scrollbar != null) { + this.screen.removeWidget(this.scrollbar); + this.scrollbar = null; + } + } + } + } + + private int getLineHeight() { + return this.font.lineHeight + Layout.TEXT_LINE_SPACING; + } + + private void updateTooltip() { + var option = this.hoveredElement.getOption(); + + int boxWidth = Mth.clamp(this.screen.width - this.hoveredElement.getLimitX() - INNER_BOX_MARGIN - OUTER_BOX_MARGIN, + MIN_TOOLTIP_WIDTH, MAX_TOOLTIP_WIDTH); + var textWidth = boxWidth - TEXT_HORIZONTAL_PADDING * 2; + int boxY = this.hoveredElement.getY(); + int boxX = this.hoveredElement.getLimitX() + INNER_BOX_MARGIN; + + this.content.clear(); + this.content.addAll(this.font.split(option.getTooltip(), textWidth)); + OptionImpact impact = option.getImpact(); + + if (impact != null) { + var impactText = Component.translatable("sodium.options.performance_impact_string", impact.getName()); + this.content.addAll(this.font.split(impactText.withStyle(ChatFormatting.GRAY), textWidth)); + } + + int contentHeight = this.content.size() * this.getLineHeight() - Layout.TEXT_LINE_SPACING + TEXT_VERTICAL_PADDING * 2; + int boxYLimit = boxY + contentHeight; + int boxYCutoff = this.screen.height - BOTTOM_BOX_MARGIN; + + // If the box is going to be cut off on the Y-axis, move it back up the difference + if (boxYLimit > boxYCutoff) { + boxY -= boxYLimit - boxYCutoff; + } + + // prevent it from moving up further than the tooltip safe area + if (boxY < UPPER_BOX_MARGIN) { + boxY = UPPER_BOX_MARGIN; + } + + this.contentSize = new Vector2i(boxWidth, contentHeight); + + var visibleMaxHeight = this.screen.height - UPPER_BOX_MARGIN - BOTTOM_BOX_MARGIN; + var visibleHeight = Math.min(contentHeight, visibleMaxHeight); + this.visibleDim = new Dim2i(boxX, boxY, boxWidth, visibleHeight); + + this.needsScrolling = contentHeight > visibleMaxHeight; + } + + public void render(@NotNull GuiGraphics graphics) { + if (this.hoveredElement == null) { + return; + } + + // draw small triangular arrow attached to the side of the tooltip box pointing at the hovered element, in the margin between the hovered element and the tooltip box + int arrowX = this.visibleDim.x() - ARROW_WIDTH; + int arrowY = this.hoveredElement.getCenterY() - (ARROW_HEIGHT / 2); + + // parameters are: render type, sprite, x, y, u offset, v offset, render width, render height, u size, v size, color + graphics.blit(RenderType::guiTextured, ARROW_TEXTURE, arrowX, arrowY, ARROW_WIDTH, 0, ARROW_WIDTH, ARROW_HEIGHT, SPRITE_WIDTH, ARROW_HEIGHT, Colors.BACKGROUND_LIGHT); + graphics.blit(RenderType::guiTextured, ARROW_TEXTURE, arrowX, arrowY, 0, 0, ARROW_WIDTH, ARROW_HEIGHT, SPRITE_WIDTH, ARROW_HEIGHT, Colors.BACKGROUND_DEFAULT); + + int lineHeight = this.getLineHeight(); + + int scrollAmount = 0; + if (this.scrollbar != null) { + scrollAmount = this.scrollbar.getScrollAmount(); + } + + graphics.enableScissor(this.visibleDim.x(), this.visibleDim.y(), this.visibleDim.getLimitX(), this.visibleDim.getLimitY()); + graphics.fill(this.visibleDim.x(), this.visibleDim.y(), this.visibleDim.getLimitX(), this.visibleDim.getLimitY(), Colors.BACKGROUND_LIGHT); + for (int i = 0; i < this.content.size(); i++) { + graphics.drawString(this.font, this.content.get(i), + this.visibleDim.x() + TEXT_HORIZONTAL_PADDING, this.visibleDim.y() + TEXT_VERTICAL_PADDING + (i * lineHeight) - scrollAmount, + Colors.FOREGROUND); + } + graphics.disableScissor(); + } + + public boolean mouseScrolled(double d, double e, double amount) { + if (this.visibleDim.containsCursor(d, e) && this.scrollbar != null) { + this.scrollbar.scroll((int) (-amount * 10)); + return true; + } + return false; + } +} diff --git a/common/src/main/resources/assets/sodium/textures/gui/coffee_cup.png b/common/src/main/resources/assets/sodium/textures/gui/coffee_cup.png new file mode 100644 index 0000000000..2ed2832398 Binary files /dev/null and b/common/src/main/resources/assets/sodium/textures/gui/coffee_cup.png differ diff --git a/common/src/main/resources/assets/sodium/textures/gui/tooltip_arrows.png b/common/src/main/resources/assets/sodium/textures/gui/tooltip_arrows.png new file mode 100644 index 0000000000..2accc220f0 Binary files /dev/null and b/common/src/main/resources/assets/sodium/textures/gui/tooltip_arrows.png differ