From b3736ca36b3845a7163f477f851d59318f0bcefb Mon Sep 17 00:00:00 2001 From: SirKaiMartin Date: Wed, 2 Sep 2026 20:07:07 +0200 Subject: [PATCH 1/2] feat: add reusable dropdown widget - support configurable popup bounds, row heights, and visible option counts - add scrolling, automatic placement, and selected-state rendering - preserve native button sounds, focus, and narration --- src/tel/eden/mod/gui/EdenDropdown.java | 215 +++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/tel/eden/mod/gui/EdenDropdown.java diff --git a/src/tel/eden/mod/gui/EdenDropdown.java b/src/tel/eden/mod/gui/EdenDropdown.java new file mode 100644 index 0000000..345d163 --- /dev/null +++ b/src/tel/eden/mod/gui/EdenDropdown.java @@ -0,0 +1,215 @@ +package tel.eden.mod.gui; + +import com.mojang.blaze3d.platform.cursor.CursorTypes; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.input.InputWithModifiers; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +/** A reusable vanilla-styled select button with caller-configured popup bounds and row count. */ +final class EdenDropdown extends AbstractButton { + private static final int POPUP_BORDER = 0xFF8E8E8E; + private static final int POPUP_BACKGROUND = 0xFF202020; + private static final int OPTION_HOVERED = 0xFF4A4A4A; + private static final int OPTION_SELECTED = 0xFF303830; + + private final Font font; + private final List values; + private final Function label; + private final Consumer onChange; + private final Consumer> onOpen; + private final PopupSettings popupSettings; + + private T value; + private boolean open; + private int optionOffset; + + EdenDropdown(int x, int y, int width, int height, Font font, List values, T initialValue, Function label, Consumer onChange, Consumer> onOpen, PopupSettings popupSettings) { + super(x, y, width, height, Component.empty()); + Objects.requireNonNull(values, "values"); + if (values.isEmpty()) { + throw new IllegalArgumentException("Dropdown values cannot be empty"); + } + this.font = Objects.requireNonNull(font, "font"); + this.values = List.copyOf(values); + this.value = this.values.contains(initialValue) ? initialValue : this.values.getFirst(); + this.label = Objects.requireNonNull(label, "label"); + this.onChange = Objects.requireNonNull(onChange, "onChange"); + this.onOpen = Objects.requireNonNull(onOpen, "onOpen"); + this.popupSettings = Objects.requireNonNull(popupSettings, "popupSettings"); + updateMessage(); + } + + boolean isOpen() { + return open; + } + + void close() { + open = false; + } + + boolean isOverPopup(double mouseX, double mouseY) { + return open && mouseX >= getX() && mouseX < getRight() && mouseY >= popupY() && mouseY < popupY() + popupHeight(); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return super.isMouseOver(mouseX, mouseY) || isOverPopup(mouseX, mouseY); + } + + @Override + public void onPress(InputWithModifiers input) { + if (input instanceof MouseButtonEvent mouse && isOverPopup(mouse.x(), mouse.y())) { + selectAt(mouse.y()); + return; + } + open = !open; + if (open) { + revealSelection(); + onOpen.accept(this); + } + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (!open || (!isOverPopup(mouseX, mouseY) && !super.isMouseOver(mouseX, mouseY))) { + return false; + } + int maxOffset = Math.max(0, values.size() - visibleOptionCount()); + optionOffset = Math.max(0, Math.min(maxOffset, optionOffset - (int) Math.signum(scrollY))); + return true; + } + + @Override + protected void renderContents(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + renderDefaultSprite(graphics); + renderDefaultLabel(graphics.textRendererForWidget(this, GuiGraphics.HoveredTextEffects.NONE)); + + int arrowX = getRight() - 9; + int arrowY = getY() + (getHeight() / 2) - 1; + int color = active ? 0xFFFFFFFF : 0xFF888888; + graphics.fill(arrowX - 2, arrowY, arrowX + 3, arrowY + 1, color); + graphics.fill(arrowX - 1, arrowY + 1, arrowX + 2, arrowY + 2, color); + graphics.fill(arrowX, arrowY + 2, arrowX + 1, arrowY + 3, color); + } + + void renderPopup(GuiGraphics graphics, int mouseX, int mouseY) { + if (!open) { + return; + } + + int popupY = popupY(); + int popupHeight = popupHeight(); + if (isOverPopup(mouseX, mouseY)) { + graphics.requestCursor(CursorTypes.POINTING_HAND); + } + graphics.fill(getX() - 1, popupY - 1, getRight() + 1, popupY + popupHeight + 1, POPUP_BORDER); + graphics.fill(getX(), popupY, getRight(), popupY + popupHeight, POPUP_BACKGROUND); + + for (int visible = 0; visible < visibleOptionCount(); visible++) { + int index = optionOffset + visible; + if (index >= values.size()) { + break; + } + int optionY = popupY + visible * popupSettings.optionHeight(); + boolean hovered = mouseX >= getX() && mouseX < getRight() && mouseY >= optionY && mouseY < optionY + popupSettings.optionHeight(); + T option = values.get(index); + if (hovered || option.equals(value)) { + graphics.fill(getX() + 1, optionY + 1, getRight() - 1, optionY + popupSettings.optionHeight() - 1, hovered ? OPTION_HOVERED : OPTION_SELECTED); + } + graphics.drawString(font, trimLabel(label.apply(option)), getX() + 6, optionY + Math.max(1, (popupSettings.optionHeight() - font.lineHeight) / 2), option.equals(value) ? 0xFF55FF55 : 0xFFFFFFFF); + } + + drawScrollbar(graphics, popupY, popupHeight); + } + + private void selectAt(double mouseY) { + int index = optionOffset + (int) ((mouseY - popupY()) / popupSettings.optionHeight()); + if (index >= 0 && index < values.size()) { + T selected = values.get(index); + if (!selected.equals(value)) { + value = selected; + updateMessage(); + onChange.accept(value); + } + } + close(); + } + + private void updateMessage() { + setMessage(Component.literal(label.apply(value))); + } + + private int visibleOptionCount() { + int below = Math.max(0, popupSettings.maxY() - getBottom() - 1); + int above = Math.max(0, getY() - popupSettings.minY() - 1); + int availableRows = Math.max(1, Math.max(below, above) / popupSettings.optionHeight()); + return Math.min(values.size(), Math.min(popupSettings.maxVisibleOptions(), availableRows)); + } + + private boolean opensAbove() { + int below = Math.max(0, popupSettings.maxY() - getBottom() - 1); + int above = Math.max(0, getY() - popupSettings.minY() - 1); + return below < popupHeight() && above > below; + } + + private int popupY() { + return opensAbove() ? getY() - popupHeight() - 1 : getBottom() + 1; + } + + private int popupHeight() { + return visibleOptionCount() * popupSettings.optionHeight(); + } + + private void revealSelection() { + int selected = values.indexOf(value); + if (selected < optionOffset) { + optionOffset = selected; + } else if (selected >= optionOffset + visibleOptionCount()) { + optionOffset = selected - visibleOptionCount() + 1; + } + } + + private String trimLabel(String text) { + int availableWidth = getWidth() - 18; + if (font.width(text) <= availableWidth) { + return text; + } + return font.plainSubstrByWidth(text, Math.max(0, availableWidth - font.width("..."))) + "..."; + } + + private void drawScrollbar(GuiGraphics graphics, int popupY, int popupHeight) { + if (values.size() <= visibleOptionCount()) { + return; + } + int trackX = getRight() - 4; + graphics.fill(trackX, popupY + 1, getRight() - 1, popupY + popupHeight - 1, 0x55000000); + int thumbHeight = Math.max(6, Math.round((popupHeight - 2) * (visibleOptionCount() / (float) values.size()))); + int travel = Math.max(1, popupHeight - 2 - thumbHeight); + int maxOffset = values.size() - visibleOptionCount(); + int thumbY = popupY + 1 + Math.round((optionOffset / (float) maxOffset) * travel); + graphics.fill(trackX, thumbY, getRight() - 1, thumbY + thumbHeight, 0xFF8A8A8A); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + defaultButtonNarrationText(output); + } + + record PopupSettings(int minY, int maxY, int optionHeight, int maxVisibleOptions) { + PopupSettings { + if (maxY <= minY) { + throw new IllegalArgumentException("Dropdown popup bounds must have positive height"); + } + optionHeight = Math.max(1, optionHeight); + maxVisibleOptions = Math.max(1, maxVisibleOptions); + } + } +} From bd9c77cc04ed2a061c34206faf81def6f11cb08d Mon Sep 17 00:00:00 2001 From: SirKaiMartin Date: Wed, 2 Sep 2026 22:10:39 +0200 Subject: [PATCH 2/2] feat: add configurable dropped item scaling - match visible names by case-insensitive substring and prioritize the first matching rule - scale complete dropped-item stacks from 0.1x to 10.0x without affecting gameplay - add responsive rule management with scrolling, aligned padding, and compact action buttons - validate persisted rules and preserve safe empty defaults --- resources/edenmod.mixins.json | 1 + src/tel/eden/mod/config/BridgeConfig.java | 56 ++++ src/tel/eden/mod/gui/BridgeConfigScreen.java | 2 + .../gui/GroundItemVisibilityRuleScreen.java | 133 +++++++++ .../mod/gui/GroundItemVisibilityScreen.java | 269 ++++++++++++++++++ .../item/GroundItemVisibilityRenderer.java | 63 ++++ .../mod/mixin/GroundItemVisibilityMixin.java | 37 +++ 7 files changed, 561 insertions(+) create mode 100644 src/tel/eden/mod/gui/GroundItemVisibilityRuleScreen.java create mode 100644 src/tel/eden/mod/gui/GroundItemVisibilityScreen.java create mode 100644 src/tel/eden/mod/item/GroundItemVisibilityRenderer.java create mode 100644 src/tel/eden/mod/mixin/GroundItemVisibilityMixin.java diff --git a/resources/edenmod.mixins.json b/resources/edenmod.mixins.json index bbee87d..8024714 100644 --- a/resources/edenmod.mixins.json +++ b/resources/edenmod.mixins.json @@ -9,6 +9,7 @@ "ClientPacketListenerCommandAliasMixin", "ClientPacketListenerMixin", "ConnectionScoreboardMixin", + "GroundItemVisibilityMixin", "GuiGraphicsMixin", "ItemDecorationMixin", "ItemRenderMixin", diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index cfdb003..fc5777e 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import net.fabricmc.loader.api.FabricLoader; import tel.eden.mod.EdenLogger; @@ -99,6 +100,40 @@ public String toString() { /** Green in-world beacon marking the soonest upcoming territory attack. */ public boolean warGreenBeacon = true; + // ---- Dropped item scaling --------------------------------------------------- + + public static final float GROUND_ITEM_MIN_SCALE = 0.1f; + public static final float GROUND_ITEM_MAX_SCALE = 10.0f; + + /** Master toggle for client-only dropped-item rescaling. */ + public boolean groundItemVisibility = false; + + /** Ordered filter rules for dropped items. The first matching rule supplies the scale. */ + public List groundItemVisibilityRules = new ArrayList<>(); + + public static final class GroundItemVisibilityRule { + /** Case-insensitive substring filter against the dropped item's visible name. */ + public String nameContains = ""; + /** Render-only dropped-item scale multiplier. Range 0.1-10.0; 1.0 means unchanged. */ + public float size = 1.0f; + + public GroundItemVisibilityRule() { + } + + public GroundItemVisibilityRule(String nameContains, float size) { + this.nameContains = nameContains; + this.size = size; + } + + public void sanitize() { + nameContains = sanitizeGroundItemName(nameContains); + if (!Float.isFinite(size)) { + size = 1.0f; + } + size = Math.max(GROUND_ITEM_MIN_SCALE, Math.min(GROUND_ITEM_MAX_SCALE, size)); + } + } + /** War info overlay: tower EHP, team DPS, and estimated time remaining. */ public boolean warDpsHud = true; @@ -299,6 +334,13 @@ public static BridgeConfig load() { if (config.emotePickerOpenMode == null) { config.emotePickerOpenMode = EmotePickerOpenMode.CURSOR; } + if (config.groundItemVisibilityRules == null) { + config.groundItemVisibilityRules = new ArrayList<>(); + } + config.groundItemVisibilityRules.removeIf(rule -> rule == null); + for (GroundItemVisibilityRule rule : config.groundItemVisibilityRules) { + rule.sanitize(); + } config.emotePickerColumns = Math.max(1, Math.min(10, config.emotePickerColumns)); config.emotePickerRows = Math.max(1, Math.min(10, config.emotePickerRows)); config.warAttackTimerMaxRows = Math.max(1, Math.min(50, config.warAttackTimerMaxRows)); @@ -329,4 +371,18 @@ public synchronized void save() { LOGGER.warn("Failed to write edenmod config", e); } } + + public static String sanitizeGroundItemName(String value) { + if (value == null) { + return ""; + } + return value.trim().replaceAll("\\s+", " "); + } + + public static String normalizeGroundItemName(String value) { + if (value == null) { + return ""; + } + return sanitizeGroundItemName(value).toLowerCase(Locale.ROOT); + } } diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index be11986..89365ca 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -118,6 +118,8 @@ protected void init() { }); addToggleRow("Custom item textures", () -> config.customItemTextures, v -> config.customItemTextures = v, "On", "Off", true); addToggleRow("Consumable labels", () -> config.consumableLabels, v -> config.consumableLabels = v, "On", "Off", true); + addToggleRow("Dropped item scaling", () -> config.groundItemVisibility, v -> config.groundItemVisibility = v, "On", "Off", false); + addButtonRow("Dropped item rules", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new GroundItemVisibilityScreen(this, config)), () -> config.groundItemVisibilityRules = new ArrayList<>()); addToggleRow("Emote wheel", () -> config.emoteWheelEnabled, v -> config.emoteWheelEnabled = v, "On", "Off", true); addButtonRow("Emote wheel favorites", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new tel.eden.mod.emote.EmoteConfigScreen(this, config)), () -> { }); diff --git a/src/tel/eden/mod/gui/GroundItemVisibilityRuleScreen.java b/src/tel/eden/mod/gui/GroundItemVisibilityRuleScreen.java new file mode 100644 index 0000000..933526f --- /dev/null +++ b/src/tel/eden/mod/gui/GroundItemVisibilityRuleScreen.java @@ -0,0 +1,133 @@ +package tel.eden.mod.gui; + +import java.util.Locale; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractSliderButton; +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.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import tel.eden.mod.config.BridgeConfig; + +/** Detail editor for one dropped-item visibility rule. */ +public final class GroundItemVisibilityRuleScreen extends EdenReferenceScreen { + private static final int BASE_PANEL_WIDTH = 360; + private static final int BASE_PANEL_HEIGHT = 170; + private static final int PANEL_PADDING = 15; + private static final int FORM_TOP = 36; + private static final int FORM_BOTTOM = 104; + private static final int LABEL_X = 25; + private static final int CONTROL_X = 140; + private static final int CONTROL_WIDTH = 195; + private static final int FIRST_ROW_Y = 40; + private static final int ROW_PITCH = 30; + private static final int CONTROL_HEIGHT = 20; + private static final int DONE_Y = BASE_PANEL_HEIGHT - PANEL_PADDING - CONTROL_HEIGHT; + + private final Screen parent; + private final BridgeConfig config; + private final BridgeConfig.GroundItemVisibilityRule rule; + + private EdenPanelLayout layout; + + public GroundItemVisibilityRuleScreen(Screen parent, BridgeConfig config, BridgeConfig.GroundItemVisibilityRule rule) { + super(Component.literal("Dropped Item Scale Rule")); + this.parent = parent; + this.config = config; + this.rule = rule; + } + + @Override + protected void init() { + super.init(); + updateReferenceSpace(); + layout = EdenPanelLayout.centered(virtualWidth, virtualHeight, BASE_PANEL_WIDTH, BASE_PANEL_HEIGHT); + rule.sanitize(); + + EditBox nameBox = new EditBox(this.font, layout.x(CONTROL_X), layout.y(rowY(0)), layout.w(CONTROL_WIDTH), layout.h(CONTROL_HEIGHT), Component.literal("Name contains")); + nameBox.setMaxLength(128); + nameBox.setValue(rule.nameContains); + nameBox.setResponder(value -> { + rule.nameContains = BridgeConfig.sanitizeGroundItemName(value); + config.save(); + }); + this.addRenderableWidget(nameBox); + + this.addRenderableWidget(new SizeSlider(layout.x(CONTROL_X), layout.y(rowY(1)), layout.w(CONTROL_WIDTH), layout.h(CONTROL_HEIGHT))); + + this.addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose()).bounds(layout.x(PANEL_PADDING), layout.y(DONE_Y), layout.w(BASE_PANEL_WIDTH - PANEL_PADDING * 2), layout.h(CONTROL_HEIGHT)).build()); + } + + private int rowY(int row) { + return FIRST_ROW_Y + row * ROW_PITCH; + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + int scaledMouseX = scaledMouseX(mouseX); + int scaledMouseY = scaledMouseY(mouseY); + + pushReferencePose(graphics); + layout.drawBackground(graphics); + layout.drawPanel(graphics); + graphics.fill(layout.x(PANEL_PADDING), layout.y(FORM_TOP), layout.x(BASE_PANEL_WIDTH - PANEL_PADDING), layout.y(FORM_BOTTOM), 0x22000000); + super.render(graphics, scaledMouseX, scaledMouseY, delta); + + graphics.drawCenteredString(this.font, this.title, layout.centerX(), layout.y(12), 0xFFFFFFFF); + String[] labels = {"Name contains", "Size"}; + for (int row = 0; row < labels.length; row++) { + graphics.drawString(this.font, labels[row], layout.x(LABEL_X), layout.y(rowY(row) + 6), 0xFFA0A0A0); + } + + popReferencePose(graphics); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + return super.mouseClicked(rescale(event), doubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + return super.mouseReleased(rescale(event)); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) { + return super.mouseDragged(rescale(event), dragX / uiScale, dragY / uiScale); + } + + @Override + public void onClose() { + rule.sanitize(); + config.save(); + this.minecraft.setScreen(parent); + } + + private final class SizeSlider extends AbstractSliderButton { + private SizeSlider(int x, int y, int width, int height) { + super(x, y, width, height, Component.empty(), 0.0d); + this.value = (rule.size - BridgeConfig.GROUND_ITEM_MIN_SCALE) / (BridgeConfig.GROUND_ITEM_MAX_SCALE - BridgeConfig.GROUND_ITEM_MIN_SCALE); + this.value = Math.max(0.0d, Math.min(1.0d, this.value)); + updateMessage(); + } + + @Override + protected void updateMessage() { + setMessage(Component.literal(String.format(Locale.ROOT, "%.1fx", rule.size))); + } + + @Override + protected void applyValue() { + float range = BridgeConfig.GROUND_ITEM_MAX_SCALE - BridgeConfig.GROUND_ITEM_MIN_SCALE; + float snapped = BridgeConfig.GROUND_ITEM_MIN_SCALE + (float) Math.round(this.value * range * 10.0d) / 10.0f; + float newSize = Math.max(BridgeConfig.GROUND_ITEM_MIN_SCALE, Math.min(BridgeConfig.GROUND_ITEM_MAX_SCALE, snapped)); + if (newSize != rule.size) { + rule.size = newSize; + config.save(); + } + updateMessage(); + } + } +} diff --git a/src/tel/eden/mod/gui/GroundItemVisibilityScreen.java b/src/tel/eden/mod/gui/GroundItemVisibilityScreen.java new file mode 100644 index 0000000..4f45621 --- /dev/null +++ b/src/tel/eden/mod/gui/GroundItemVisibilityScreen.java @@ -0,0 +1,269 @@ +package tel.eden.mod.gui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import tel.eden.mod.config.BridgeConfig; + +/** Scrollable list of dropped-item visibility rules, each editable in a detail screen. */ +public final class GroundItemVisibilityScreen extends EdenReferenceScreen { + private static final int BASE_PANEL_WIDTH = 420; + private static final int BASE_PANEL_HEIGHT = 300; + private static final int PANEL_PADDING = 15; + private static final int LIST_X = 15; + private static final int LIST_Y = 36; + private static final int LIST_WIDTH = 390; + private static final int ROW_HEIGHT = 30; + private static final int ROW_X = 17; + private static final int ROW_WIDTH = 374; + private static final int ROW_INNER_HEIGHT = 26; + private static final int ROW_BUTTON_HEIGHT = 20; + private static final int ROW_BUTTON_TOP_OFFSET = (ROW_INNER_HEIGHT - ROW_BUTTON_HEIGHT) / 2; + private static final int VISIBLE_ROWS = 7; + private static final int SUMMARY_X = 23; + private static final int EDIT_X = 263; + private static final int EDIT_WIDTH = 56; + private static final int REMOVE_X = 325; + private static final int REMOVE_WIDTH = 64; + private static final int SCROLLBAR_X = 393; + private static final int FOOTER_Y = 266; + private static final int FOOTER_GAP = 10; + private static final int FOOTER_BUTTON_WIDTH = (BASE_PANEL_WIDTH - PANEL_PADDING * 2 - FOOTER_GAP) / 2; + + private final Screen parent; + private final BridgeConfig config; + private final List rows = new ArrayList<>(); + + private EdenPanelLayout layout; + private int scrollOffset; + private boolean draggingScrollbar; + + public GroundItemVisibilityScreen(Screen parent, BridgeConfig config) { + super(Component.literal("Dropped Item Rules")); + this.parent = parent; + this.config = config; + } + + @Override + protected void init() { + super.init(); + updateReferenceSpace(); + layout = EdenPanelLayout.centered(virtualWidth, virtualHeight, BASE_PANEL_WIDTH, BASE_PANEL_HEIGHT); + rows.clear(); + + this.addRenderableWidget(Button.builder(Component.literal("Add"), button -> onAdd()).bounds(layout.x(PANEL_PADDING), layout.y(FOOTER_Y), layout.w(FOOTER_BUTTON_WIDTH), layout.h(20)).build()); + this.addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose()).bounds(layout.x(PANEL_PADDING + FOOTER_BUTTON_WIDTH + FOOTER_GAP), layout.y(FOOTER_Y), layout.w(FOOTER_BUTTON_WIDTH), layout.h(20)).build()); + + if (config.groundItemVisibilityRules == null) { + config.groundItemVisibilityRules = new ArrayList<>(); + } + for (BridgeConfig.GroundItemVisibilityRule rule : config.groundItemVisibilityRules) { + if (rule == null) { + continue; + } + rule.sanitize(); + rows.add(createRow(rule)); + } + scrollOffset = Math.max(0, Math.min(scrollOffset, maxScrollOffset())); + layoutRows(); + } + + private RuleRow createRow(BridgeConfig.GroundItemVisibilityRule rule) { + Button editButton = Button.builder(Component.literal("Edit"), button -> this.minecraft.setScreen(new GroundItemVisibilityRuleScreen(this, config, rule))).bounds(0, 0, layout.w(EDIT_WIDTH), layout.h(ROW_BUTTON_HEIGHT)).build(); + Button removeButton = Button.builder(Component.literal("Remove"), button -> onDelete(rule)).bounds(0, 0, layout.w(REMOVE_WIDTH), layout.h(ROW_BUTTON_HEIGHT)).build(); + this.addWidget(editButton); + this.addWidget(removeButton); + return new RuleRow(rule, editButton, removeButton); + } + + private void onAdd() { + BridgeConfig.GroundItemVisibilityRule rule = new BridgeConfig.GroundItemVisibilityRule("", 1.0f); + config.groundItemVisibilityRules.add(0, rule); + config.save(); + this.minecraft.setScreen(new GroundItemVisibilityRuleScreen(this, config, rule)); + } + + private void onDelete(BridgeConfig.GroundItemVisibilityRule rule) { + config.groundItemVisibilityRules.remove(rule); + config.save(); + this.minecraft.setScreen(new GroundItemVisibilityScreen(parent, config)); + } + + private int maxScrollOffset() { + return Math.max(0, rows.size() - VISIBLE_ROWS); + } + + private void layoutRows() { + for (int index = 0; index < rows.size(); index++) { + RuleRow row = rows.get(index); + int visibleIndex = index - scrollOffset; + boolean inView = visibleIndex >= 0 && visibleIndex < VISIBLE_ROWS; + row.editButton.visible = inView; + row.editButton.active = inView; + row.removeButton.visible = inView; + row.removeButton.active = inView; + if (!inView) { + continue; + } + + int rowTop = layout.y(38 + visibleIndex * ROW_HEIGHT); + position(row.editButton, EDIT_X, rowTop + layout.h(ROW_BUTTON_TOP_OFFSET), EDIT_WIDTH); + position(row.removeButton, REMOVE_X, rowTop + layout.h(ROW_BUTTON_TOP_OFFSET), REMOVE_WIDTH); + } + } + + private void position(Button button, int baseX, int y, int baseWidth) { + button.setX(layout.x(baseX)); + button.setY(y); + button.setWidth(layout.w(baseWidth)); + button.setHeight(layout.h(ROW_BUTTON_HEIGHT)); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + int scaledMouseX = scaledMouseX(mouseX); + int scaledMouseY = scaledMouseY(mouseY); + + pushReferencePose(graphics); + layout.drawBackground(graphics); + layout.drawPanel(graphics); + + int listLeft = layout.x(LIST_X); + int listTop = layout.y(LIST_Y); + int listWidth = layout.w(LIST_WIDTH); + int listHeight = layout.h(ROW_HEIGHT * VISIBLE_ROWS); + graphics.fill(listLeft, listTop, listLeft + listWidth, listTop + listHeight, 0x22000000); + + layoutRows(); + graphics.enableScissor(listLeft, listTop, listLeft + listWidth, listTop + listHeight); + for (int visible = 0; visible < VISIBLE_ROWS; visible++) { + int index = scrollOffset + visible; + if (index >= rows.size()) { + break; + } + + RuleRow row = rows.get(index); + int rowTop = layout.y(38 + visible * ROW_HEIGHT); + graphics.fill(layout.x(ROW_X), rowTop, layout.x(ROW_X + ROW_WIDTH), rowTop + layout.h(ROW_INNER_HEIGHT), 0x44282828); + + int textWidth = layout.x(EDIT_X) - layout.x(SUMMARY_X) - layout.w(8); + graphics.drawString(this.font, trimToWidth(summaryLine(row.rule), textWidth), layout.x(SUMMARY_X), rowTop + layout.h(4), 0xFFFFFFFF); + graphics.drawString(this.font, trimToWidth(actionLine(row.rule), textWidth), layout.x(SUMMARY_X), rowTop + layout.h(14), 0xFFA0A0A0); + row.editButton.render(graphics, scaledMouseX, scaledMouseY, delta); + row.removeButton.render(graphics, scaledMouseX, scaledMouseY, delta); + } + graphics.disableScissor(); + + layout.drawScrollbar(graphics, layout.x(SCROLLBAR_X), listTop, layout.w(8), listHeight, VISIBLE_ROWS, rows.size(), scrollOffset); + super.render(graphics, scaledMouseX, scaledMouseY, delta); + graphics.drawCenteredString(this.font, this.title, layout.centerX(), layout.y(12), 0xFFFFFFFF); + if (rows.isEmpty()) { + graphics.drawCenteredString(this.font, "No item rules yet", layout.centerX(), layout.y(136), 0xFFAAAAAA); + } + popReferencePose(graphics); + } + + private String summaryLine(BridgeConfig.GroundItemVisibilityRule rule) { + return rule.nameContains.isBlank() ? "Any name" : rule.nameContains; + } + + private String actionLine(BridgeConfig.GroundItemVisibilityRule rule) { + return "Scale " + String.format(Locale.ROOT, "%.1fx", rule.size); + } + + private String trimToWidth(String text, int width) { + if (this.font.width(text) <= width) { + return text; + } + return this.font.plainSubstrByWidth(text, Math.max(0, width - this.font.width("..."))) + "..."; + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + MouseButtonEvent scaled = rescale(event); + if (scaled.button() == 0 && isOverScrollbar(scaled.x(), scaled.y())) { + draggingScrollbar = true; + updateScrollFromMouse(scaled.y()); + return true; + } + return super.mouseClicked(scaled, doubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + draggingScrollbar = false; + return super.mouseReleased(rescale(event)); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) { + MouseButtonEvent scaled = rescale(event); + if (draggingScrollbar) { + updateScrollFromMouse(scaled.y()); + return true; + } + return super.mouseDragged(scaled, dragX / uiScale, dragY / uiScale); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + double scaledMouseX = mouseX / uiScale; + double scaledMouseY = mouseY / uiScale; + if (!isOverList(scaledMouseX, scaledMouseY) && !isOverScrollbar(scaledMouseX, scaledMouseY)) { + return super.mouseScrolled(scaledMouseX, scaledMouseY, scrollX, scrollY); + } + if (maxScrollOffset() == 0) { + return true; + } + scrollOffset = Math.max(0, Math.min(maxScrollOffset(), scrollOffset - (int) Math.signum(scrollY))); + layoutRows(); + return true; + } + + private boolean isOverList(double mouseX, double mouseY) { + return mouseX >= layout.x(LIST_X) && mouseX <= layout.x(LIST_X + LIST_WIDTH) && mouseY >= layout.y(LIST_Y) && mouseY <= layout.y(LIST_Y + ROW_HEIGHT * VISIBLE_ROWS); + } + + private boolean isOverScrollbar(double mouseX, double mouseY) { + return mouseX >= layout.x(SCROLLBAR_X) && mouseX <= layout.x(LIST_X + LIST_WIDTH) && mouseY >= layout.y(LIST_Y) && mouseY <= layout.y(LIST_Y + ROW_HEIGHT * VISIBLE_ROWS); + } + + private void updateScrollFromMouse(double mouseY) { + if (maxScrollOffset() == 0) { + scrollOffset = 0; + return; + } + + int trackTop = layout.y(LIST_Y); + int trackHeight = layout.h(ROW_HEIGHT * VISIBLE_ROWS); + int thumbHeight = Math.max(layout.h(18), Math.round(trackHeight * (VISIBLE_ROWS / (float) rows.size()))); + double relative = mouseY - trackTop - thumbHeight / 2.0; + double range = Math.max(1, trackHeight - thumbHeight); + double percent = Math.max(0.0, Math.min(1.0, relative / range)); + scrollOffset = (int) Math.round(percent * maxScrollOffset()); + layoutRows(); + } + + @Override + public void onClose() { + config.save(); + this.minecraft.setScreen(parent); + } + + private static final class RuleRow { + private final BridgeConfig.GroundItemVisibilityRule rule; + private final Button editButton; + private final Button removeButton; + + private RuleRow(BridgeConfig.GroundItemVisibilityRule rule, Button editButton, Button removeButton) { + this.rule = rule; + this.editButton = editButton; + this.removeButton = removeButton; + } + } +} diff --git a/src/tel/eden/mod/item/GroundItemVisibilityRenderer.java b/src/tel/eden/mod/item/GroundItemVisibilityRenderer.java new file mode 100644 index 0000000..8b76cc9 --- /dev/null +++ b/src/tel/eden/mod/item/GroundItemVisibilityRenderer.java @@ -0,0 +1,63 @@ +package tel.eden.mod.item; + +import java.util.Map; +import java.util.WeakHashMap; +import net.minecraft.client.renderer.entity.state.ItemEntityRenderState; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import tel.eden.mod.config.BridgeConfig; + +/** + * Resolves dropped-item scale rules and applies their render-only state to item entities. + */ +public final class GroundItemVisibilityRenderer { + private static final Map SCALE_BY_STATE = java.util.Collections.synchronizedMap(new WeakHashMap<>()); + + private GroundItemVisibilityRenderer() { + } + + public static void extract(BridgeConfig config, ItemEntity itemEntity, ItemEntityRenderState state) { + BridgeConfig.GroundItemVisibilityRule rule = matchingRule(config, itemEntity.getItem()); + if (rule == null) { + SCALE_BY_STATE.remove(state); + return; + } + float scale = rule.size; + if (!Float.isFinite(scale)) { + scale = 1.0f; + } + scale = Math.max(BridgeConfig.GROUND_ITEM_MIN_SCALE, Math.min(BridgeConfig.GROUND_ITEM_MAX_SCALE, scale)); + if (scale != 1.0f) { + SCALE_BY_STATE.put(state, scale); + } else { + SCALE_BY_STATE.remove(state); + } + } + + public static float consumeScale(ItemEntityRenderState state) { + Float scale = SCALE_BY_STATE.remove(state); + return scale != null ? scale : 1.0f; + } + + private static BridgeConfig.GroundItemVisibilityRule matchingRule(BridgeConfig config, ItemStack stack) { + if (!config.groundItemVisibility || config.groundItemVisibilityRules == null || config.groundItemVisibilityRules.isEmpty()) { + return null; + } + String normalizedName = BridgeConfig.normalizeGroundItemName(stack.getHoverName().getString()); + if (normalizedName.isEmpty()) { + return null; + } + for (BridgeConfig.GroundItemVisibilityRule rule : config.groundItemVisibilityRules) { + if (rule == null) { + continue; + } + String nameFilter = BridgeConfig.normalizeGroundItemName(rule.nameContains); + if (nameFilter.isEmpty() || normalizedName.contains(nameFilter)) { + // A rule supplies the complete scale. Returning the first match makes list + // order the priority and prevents overlapping rules from stacking transforms. + return rule; + } + } + return null; + } +} diff --git a/src/tel/eden/mod/mixin/GroundItemVisibilityMixin.java b/src/tel/eden/mod/mixin/GroundItemVisibilityMixin.java new file mode 100644 index 0000000..73967a8 --- /dev/null +++ b/src/tel/eden/mod/mixin/GroundItemVisibilityMixin.java @@ -0,0 +1,37 @@ +package tel.eden.mod.mixin; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.ItemEntityRenderer; +import net.minecraft.client.renderer.entity.state.ItemEntityRenderState; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.world.entity.item.ItemEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.item.GroundItemVisibilityRenderer; + +/** + * Scales dropped item renders without touching entity size or pickup behavior. The pose + * scale is inserted immediately before the clustered item model submission, after vanilla + * has already applied its bob/spin transforms, so the scaled item stays anchored to the + * same floating/rotating origin. + */ +@Mixin(ItemEntityRenderer.class) +public abstract class GroundItemVisibilityMixin { + @Inject(method = "extractRenderState(Lnet/minecraft/world/entity/item/ItemEntity;Lnet/minecraft/client/renderer/entity/state/ItemEntityRenderState;F)V", at = @At("TAIL")) + private void edenmod$extractDroppedItemScale(ItemEntity itemEntity, ItemEntityRenderState state, float partialTick, CallbackInfo ci) { + EdenModClient client = EdenModClient.instance(); + GroundItemVisibilityRenderer.extract(client.config(), itemEntity, state); + } + + @Inject(method = "submit(Lnet/minecraft/client/renderer/entity/state/ItemEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/CameraRenderState;)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/entity/ItemEntityRenderer;submitMultipleFromCount(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/client/renderer/entity/state/ItemClusterRenderState;Lnet/minecraft/util/RandomSource;Lnet/minecraft/world/phys/AABB;)V")) + private void edenmod$scaleDroppedItem(ItemEntityRenderState state, PoseStack poseStack, SubmitNodeCollector collector, CameraRenderState camera, CallbackInfo ci) { + float scale = GroundItemVisibilityRenderer.consumeScale(state); + if (scale != 1.0f) { + poseStack.scale(scale, scale, scale); + } + } +}