diff --git a/src/main/java/com/trunksbomb/bagtabs/bag/BagAccess.java b/src/main/java/com/trunksbomb/bagtabs/bag/BagAccess.java index 3e0ef9c..e14a24e 100644 --- a/src/main/java/com/trunksbomb/bagtabs/bag/BagAccess.java +++ b/src/main/java/com/trunksbomb/bagtabs/bag/BagAccess.java @@ -22,7 +22,7 @@ public final class BagAccess { } BagCompat.BagHandler handler = BagCompat.findHandler(stack); if (handler != null) { - bags.add(new BagEntry(slot, stack.copy(), handler)); + bags.add(new BagEntry(slot, stack.copy(), handler, BagCompat.getIdentity(stack))); } } diff --git a/src/main/java/com/trunksbomb/bagtabs/bag/BagEntry.java b/src/main/java/com/trunksbomb/bagtabs/bag/BagEntry.java index 7bda808..217a04a 100644 --- a/src/main/java/com/trunksbomb/bagtabs/bag/BagEntry.java +++ b/src/main/java/com/trunksbomb/bagtabs/bag/BagEntry.java @@ -2,5 +2,5 @@ package com.trunksbomb.bagtabs.bag; import net.minecraft.world.item.ItemStack; -public record BagEntry(int slot, ItemStack stack, BagCompat.BagHandler handler) { +public record BagEntry(int slot, ItemStack stack, BagCompat.BagHandler handler, BagIdentity identity) { } diff --git a/src/main/java/com/trunksbomb/bagtabs/client/BagTabOverlay.java b/src/main/java/com/trunksbomb/bagtabs/client/BagTabOverlay.java index 6a5f936..6a60941 100644 --- a/src/main/java/com/trunksbomb/bagtabs/client/BagTabOverlay.java +++ b/src/main/java/com/trunksbomb/bagtabs/client/BagTabOverlay.java @@ -18,6 +18,8 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvents; import net.minecraft.world.entity.player.Player; @@ -68,19 +70,24 @@ public final class BagTabOverlay { for (RenderedTab tab : tabs) { renderTab(guiGraphics, tab, mouseX, mouseY, carriedStack); - guiGraphics.renderItem(tab.entry().stack(), tab.x() + 3, tab.y() + 3); + guiGraphics.renderItem(tab.entry().stack(), tab.x() + 3, tab.y() + 2); } for (RenderedTab tab : tabs) { if (tab.isHovered(mouseX, mouseY)) { - guiGraphics.renderTooltip(Minecraft.getInstance().font, tab.entry().stack().getHoverName(), mouseX, mouseY); + guiGraphics.renderTooltip( + Minecraft.getInstance().font, + getTooltipLines(tab, tabs).stream().map(Component::getVisualOrderText).toList(), + mouseX, + mouseY + ); break; } } } public static void mouseClicked(ScreenEvent.MouseButtonPressed.Pre event) { - if (!(event.getScreen() instanceof AbstractContainerScreen screen) || !supportsTabs(screen) || event.getButton() != 0) { + if (!(event.getScreen() instanceof AbstractContainerScreen screen) || !supportsTabs(screen)) { return; } @@ -89,11 +96,31 @@ public final class BagTabOverlay { return; } - if (!screen.getMenu().getCarried().isEmpty()) { + List tabs = getRenderedTabs(screen, player); + + if (event.getButton() == 1) { + for (RenderedTab tab : tabs) { + if (!tab.isHovered(event.getMouseX(), event.getMouseY())) { + continue; + } + + TabPinManager.ToggleResult result = TabPinManager.togglePin(tab.entry(), tabs.stream().map(RenderedTab::entry).toList()); + if (result.changed()) { + Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F)); + } else { + Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.VILLAGER_NO, 0.8F)); + } + event.setCanceled(true); + return; + } return; } - for (RenderedTab tab : getRenderedTabs(screen, player)) { + if (event.getButton() != 0 || !screen.getMenu().getCarried().isEmpty()) { + return; + } + + for (RenderedTab tab : tabs) { if (tab.isHovered(event.getMouseX(), event.getMouseY())) { PacketDistributor.sendToServer(new OpenBagPayload(tab.entry().slot())); Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F)); @@ -146,7 +173,7 @@ public final class BagTabOverlay { } private static List getRenderedTabs(AbstractContainerScreen screen, Player player) { - List bags = BagAccess.findBags(player); + List bags = TabPinManager.sortTabs(BagAccess.findBags(player)); List renderedTabs = new ArrayList<>(); int activeBagSlot = getActiveBagSlot(screen); int leftBound = getInventoryLeftBound(screen, player); @@ -160,7 +187,8 @@ public final class BagTabOverlay { break; } - renderedTabs.add(new RenderedTab(bag, x, y, bag.slot() == activeBagSlot)); + boolean pinned = TabPinManager.isPinned(bag, bags); + renderedTabs.add(new RenderedTab(bag, x, y, bag.slot() == activeBagSlot, pinned)); x += TAB_WIDTH + TAB_GAP; } @@ -206,6 +234,12 @@ public final class BagTabOverlay { guiGraphics.fill(tab.x() + 2, tab.y() + 2, tab.x() + TAB_WIDTH - 2, tab.y() + 4, 0x90FFFFFF); } + if (tab.pinned()) { + guiGraphics.fill(tab.x() + 2, tab.y() + 4, tab.x() + 6, tab.y() + 5, 0xFFFFDA6B); + guiGraphics.fill(tab.x() + 3, tab.y() + 5, tab.x() + 5, tab.y() + 8, 0xFFFFDA6B); + guiGraphics.fill(tab.x() + 4, tab.y() + 8, tab.x() + 5, tab.y() + 9, 0xFFD94A4A); + } + if (!carriedStack.isEmpty()) { if (INSERTABLE_SLOTS.contains(tab.entry().slot())) { renderPlusIndicator(guiGraphics, tab.x() + 15, tab.y() + 3); @@ -264,7 +298,24 @@ public final class BagTabOverlay { guiGraphics.fill(x + 4, y + 4, x + 5, y + 5, 0xFFFF6C6C); } - private record RenderedTab(BagEntry entry, int x, int y, boolean selected) { + private static List getTooltipLines(RenderedTab tab, List tabs) { + List lines = new ArrayList<>(); + lines.add(tab.entry().stack().getHoverName()); + lines.add(Component.literal("Left-click: open")); + if (tab.pinned()) { + lines.add(Component.literal("Right-click: unpin")); + } else { + String failureReason = TabPinManager.getPinFailureReason(tab.entry(), tabs.stream().map(RenderedTab::entry).toList()); + if (failureReason == null) { + lines.add(Component.literal("Right-click: pin")); + } else { + lines.add(Component.literal("Rename bag to pin").withStyle(ChatFormatting.ITALIC)); + } + } + return lines; + } + + private record RenderedTab(BagEntry entry, int x, int y, boolean selected, boolean pinned) { private boolean isHovered(double mouseX, double mouseY) { return mouseX >= this.x && mouseX < this.x + TAB_WIDTH && mouseY >= this.y && mouseY < this.y + TAB_HEIGHT; } diff --git a/src/main/java/com/trunksbomb/bagtabs/client/TabPinManager.java b/src/main/java/com/trunksbomb/bagtabs/client/TabPinManager.java new file mode 100644 index 0000000..d5fe811 --- /dev/null +++ b/src/main/java/com/trunksbomb/bagtabs/client/TabPinManager.java @@ -0,0 +1,178 @@ +package com.trunksbomb.bagtabs.client; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.trunksbomb.bagtabs.BagTabs; +import com.trunksbomb.bagtabs.bag.BagEntry; +import com.trunksbomb.bagtabs.bag.BagIdentity; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import net.minecraft.client.Minecraft; + +public final class TabPinManager { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Type PINNED_TAB_LIST_TYPE = new TypeToken>() {}.getType(); + + private static boolean loaded = false; + private static final List PINNED_TABS = new ArrayList<>(); + + private TabPinManager() { + } + + public static List sortTabs(List bags) { + ensureLoaded(); + return bags.stream() + .sorted(Comparator + .comparingInt((BagEntry bag) -> isPinned(bag, bags) ? 0 : 1) + .thenComparingInt(bag -> getPinnedOrder(bag, bags)) + .thenComparingInt(BagEntry::slot)) + .toList(); + } + + public static boolean isPinned(BagEntry bag, List allBags) { + return resolvePinnedBag(bag.identity(), allBags) + .map(resolved -> resolved.slot() == bag.slot()) + .orElse(false); + } + + public static String getPinFailureReason(BagEntry bag, List allBags) { + BagIdentity identity = bag.identity(); + if (identity == null || identity.stable()) { + return null; + } + + for (PinnedTab pinnedTab : PINNED_TABS) { + if (!pinnedTab.identityKey().equals(identity.key())) { + continue; + } + + BagEntry resolved = resolvePinnedBag(identity, allBags).orElse(null); + if (resolved != null && resolved.slot() != bag.slot()) { + return "Only one fallback-identified bag with this name can be pinned."; + } + } + + return null; + } + + public static ToggleResult togglePin(BagEntry bag, List allBags) { + ensureLoaded(); + BagIdentity identity = bag.identity(); + if (identity == null) { + return new ToggleResult(false, "This bag can't be pinned yet."); + } + + if (isPinned(bag, allBags)) { + PINNED_TABS.removeIf(pinnedTab -> pinnedTab.identityKey().equals(identity.key())); + save(); + return new ToggleResult(true, null); + } + + String failureReason = getPinFailureReason(bag, allBags); + if (failureReason != null) { + return new ToggleResult(false, failureReason); + } + + PINNED_TABS.add(new PinnedTab(identity.key(), identity.stable(), bag.slot())); + save(); + return new ToggleResult(true, null); + } + + private static int getPinnedOrder(BagEntry bag, List allBags) { + for (int i = 0; i < PINNED_TABS.size(); i++) { + BagEntry resolved = resolvePinnedBag(PINNED_TABS.get(i).identityKey(), allBags).orElse(null); + if (resolved != null && resolved.slot() == bag.slot()) { + return i; + } + } + + return Integer.MAX_VALUE; + } + + private static java.util.Optional resolvePinnedBag(BagIdentity identity, List allBags) { + return identity == null ? java.util.Optional.empty() : resolvePinnedBag(identity.key(), allBags); + } + + private static java.util.Optional resolvePinnedBag(String identityKey, List allBags) { + PinnedTab pinnedTab = PINNED_TABS.stream() + .filter(tab -> tab.identityKey().equals(identityKey)) + .findFirst() + .orElse(null); + if (pinnedTab == null) { + return java.util.Optional.empty(); + } + + List matches = allBags.stream() + .filter(bag -> bag.identity() != null && Objects.equals(bag.identity().key(), identityKey)) + .sorted(Comparator.comparingInt(BagEntry::slot)) + .toList(); + if (matches.isEmpty()) { + return java.util.Optional.empty(); + } + + return matches.stream() + .filter(match -> match.slot() == pinnedTab.preferredSlot()) + .findFirst() + .or(() -> java.util.Optional.of(matches.getFirst())); + } + + private static void ensureLoaded() { + if (loaded) { + return; + } + + loaded = true; + Path path = getConfigPath(); + if (!Files.exists(path)) { + return; + } + + try (Reader reader = Files.newBufferedReader(path)) { + List loadedTabs = GSON.fromJson(reader, PINNED_TAB_LIST_TYPE); + if (loadedTabs != null) { + PINNED_TABS.clear(); + Set seenKeys = new HashSet<>(); + for (PinnedTab pinnedTab : loadedTabs) { + if (seenKeys.add(pinnedTab.identityKey())) { + PINNED_TABS.add(pinnedTab); + } + } + } + } catch (IOException exception) { + BagTabs.LOGGER.warn("Failed to load pinned bag tab state", exception); + } + } + + private static void save() { + Path path = getConfigPath(); + try { + Files.createDirectories(path.getParent()); + try (Writer writer = Files.newBufferedWriter(path)) { + GSON.toJson(PINNED_TABS, PINNED_TAB_LIST_TYPE, writer); + } + } catch (IOException exception) { + BagTabs.LOGGER.warn("Failed to save pinned bag tab state", exception); + } + } + + private static Path getConfigPath() { + return Minecraft.getInstance().gameDirectory.toPath().resolve("config").resolve("bagtabs-pinned-tabs.json"); + } + + private record PinnedTab(String identityKey, boolean stable, int preferredSlot) { + } + + public record ToggleResult(boolean changed, String failureReason) { + } +}