diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2109ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Gradle +.gradle/ +build/ +gradlew +gradlew.bat +gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ + +# Fabric / Loom +run/ +logs/ +*.class diff --git a/src/client/java/com/servertabs/ServerTabsClient.java b/src/client/java/com/servertabs/ServerTabsClient.java index fdcd27b..b7ae910 100644 --- a/src/client/java/com/servertabs/ServerTabsClient.java +++ b/src/client/java/com/servertabs/ServerTabsClient.java @@ -1,7 +1,9 @@ package com.servertabs; import com.servertabs.gui.AssignTabScreen; +import com.servertabs.gui.AssignWorldScreen; import com.servertabs.gui.TabDropdownController; +import com.servertabs.gui.WorldTabsDropdownController; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; import net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents; @@ -11,13 +13,18 @@ import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; -import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; +import net.minecraft.client.gui.screens.worldselection.WorldSelectionList; import net.minecraft.client.multiplayer.ServerData; import net.minecraft.client.multiplayer.ServerList; import net.minecraft.network.chat.Component; +import net.minecraft.world.level.storage.LevelSummary; import org.lwjgl.glfw.GLFW; import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.WeakHashMap; public class ServerTabsClient implements ClientModInitializer { @@ -25,21 +32,17 @@ public class ServerTabsClient implements ClientModInitializer { /** * Tracks controllers by JoinMultiplayerScreen INSTANCE. * WeakHashMap: when the screen is GC'd the entry is removed automatically. - * - * Events (afterRender, allowMouseClick) are registered only once per - * screen instance — this prevents duplicate listeners on reinit. */ private static final WeakHashMap controllers = new WeakHashMap<>(); + /** Tracks world-tab controllers by SelectWorldScreen INSTANCE. */ + private static final WeakHashMap worldControllers + = new WeakHashMap<>(); + /** * Tracks which non-JMS screens have already received our injected button, - * to prevent adding duplicates when the screen reinits (e.g. returning - * from AssignTabScreen back to EditServerScreen). - * - * Bug 1 root fix: without this, AFTER_INIT fires again on EditServerScreen - * after AssignTabScreen closes, adding a second "Assign Tab" button and - * leaving the screen in an inconsistent state that breaks the JMS dropdown. + * to prevent adding duplicates when the screen reinits. */ private static final WeakHashMap injectedScreens = new WeakHashMap<>(); @@ -50,6 +53,9 @@ public class ServerTabsClient implements ClientModInitializer { */ private static int lastKnownServerCount = -1; + /** Tracks world IDs seen on the last SelectWorldScreen open, for assign-on-add. */ + private static Set lastKnownWorldIds = null; + @Override public void onInitializeClient() { ServerTabsMod.LOGGER.info("ServerTabs client initialized!"); @@ -62,7 +68,6 @@ public void onInitializeClient() { // ---------------------------------------------------------------- if (screen instanceof JoinMultiplayerScreen jms) { - // Reuse or create the controller for this screen instance TabDropdownController controller = controllers.get(screen); if (controller == null) { controller = new TabDropdownController(screen); @@ -70,7 +75,6 @@ public void onInitializeClient() { ScreenEvents.afterRender(screen).register(controller::onRender); ScreenMouseEvents.allowMouseClick(screen).register(controller::onMouseClick); - // Feature 4: Alt+W (prev tab) / Alt+S (next tab) final TabDropdownController ctrl = controller; ScreenKeyboardEvents.allowKeyPress(screen).register((s, keyEvent) -> { if ((keyEvent.modifiers() & GLFW.GLFW_MOD_ALT) != 0) { @@ -81,64 +85,115 @@ public void onInitializeClient() { }); } - // createToggleButton() resets panelOpen/slideProgress (bug 1 fix) Screens.getButtons(screen).add(controller.createToggleButton()); - // ── Feature 3: Assign on Add ───────────────────────────── - // Check if a new server was just added by comparing the full - // (unfiltered) server count to what we saw last time. + // Feature: Assign on Add if (TabConfig.getInstance().isAssignOnAdd()) { ServerList servers = jms.servers; if (servers != null) { - servers.load(); // get full unfiltered count + servers.load(); int currentCount = servers.size(); if (lastKnownServerCount >= 0 && currentCount > lastKnownServerCount) { - // A new server was added — get the last entry ServerData newest = servers.get(currentCount - 1); if (newest != null) { String ip = newest.ip != null ? newest.ip.trim() : ""; String name = newest.name != null ? newest.name.trim() : ""; - // Navigate to AssignTabScreen for the new server. - // We post this via setScreen so the JMS fully - // finishes initializing before we navigate away. final Screen jmsScreen = screen; client.execute(() -> client.setScreen(new AssignTabScreen(jmsScreen, ip, name)) ); lastKnownServerCount = currentCount; - return; // skip applyTabFilter — we're navigating away + return; } } lastKnownServerCount = currentCount; } } - // Apply the active tab filter TabDropdownController.applyTabFilter(jms, TabSessionState.getActiveTabId()); return; } // ---------------------------------------------------------------- - // Main menu — reset tab + server count tracking if rememberTab OFF + // Singleplayer world list + // ---------------------------------------------------------------- + if (screen instanceof SelectWorldScreen sws) { + + if (!TabConfig.getInstance().isWorldTabsEnabled()) return; + + WorldTabsDropdownController worldCtrl = worldControllers.get(screen); + if (worldCtrl == null) { + worldCtrl = new WorldTabsDropdownController(screen); + worldControllers.put(screen, worldCtrl); + ScreenEvents.afterRender(screen).register(worldCtrl::onRender); + ScreenMouseEvents.allowMouseClick(screen).register(worldCtrl::onMouseClick); + + final WorldTabsDropdownController wc = worldCtrl; + ScreenKeyboardEvents.allowKeyPress(screen).register((s, keyEvent) -> { + if ((keyEvent.modifiers() & GLFW.GLFW_MOD_ALT) != 0) { + if (keyEvent.key() == GLFW.GLFW_KEY_W) { wc.switchTab(-1); return false; } + if (keyEvent.key() == GLFW.GLFW_KEY_S) { wc.switchTab(+1); return false; } + } + return true; + }); + } + + Screens.getButtons(screen).add(worldCtrl.createToggleButton()); + + // Feature: World Assign on Add — detect newly created worlds + if (TabConfig.getInstance().isWorldAssignOnAdd()) { + WorldSelectionList worldList = findWorldList(sws); + if (worldList != null) { + Set currentIds = collectWorldIds(worldList); + if (lastKnownWorldIds != null && !currentIds.isEmpty()) { + // Find IDs present now but not before + for (String id : currentIds) { + if (!lastKnownWorldIds.contains(id)) { + LevelSummary summary = findWorldSummary(worldList, id); + if (summary != null) { + // Update tracking BEFORE navigating so that when the user + // returns from AssignWorldScreen and AFTER_INIT fires again, + // the new world ID is already known and won't re-trigger. + lastKnownWorldIds = currentIds; + final Screen swsScreen = screen; + final LevelSummary s = summary; + client.execute(() -> + client.setScreen(new AssignWorldScreen( + swsScreen, s.getLevelId(), s.getLevelName())) + ); + WorldTabsDropdownController.applyTabFilter(sws, WorldTabSessionState.getActiveTabId()); + return; + } + } + } + } + lastKnownWorldIds = currentIds; + } + } + + WorldTabsDropdownController.applyTabFilter(sws, WorldTabSessionState.getActiveTabId()); + return; + } + + // ---------------------------------------------------------------- + // Main menu — reset tab tracking if remember is OFF // ---------------------------------------------------------------- if (screen instanceof TitleScreen) { if (!TabConfig.getInstance().isRememberTab()) { TabSessionState.resetToDefault(); } - // Reset server count so we don't false-positive on next JMS open + if (!TabConfig.getInstance().isWorldRememberTab()) { + WorldTabSessionState.resetToDefault(); + } lastKnownServerCount = -1; + lastKnownWorldIds = null; return; } // ---------------------------------------------------------------- // Add/Edit server screen — inject "Assign Tab" button (once only) - // - // Bug 1 fix: we track which screen instances have already been - // injected. Without this, returning from AssignTabScreen causes - // AFTER_INIT to fire again on EditServerScreen, adding a second - // button and corrupting state that breaks the JMS tab dropdown. // ---------------------------------------------------------------- if (injectedScreens.containsKey(screen)) return; @@ -181,4 +236,86 @@ private static ServerData findServerData(Screen screen) { } return null; } + + // ----------------------------------------------------------------------- + // Reflection helpers — world list + // ----------------------------------------------------------------------- + + private static WorldSelectionList findWorldList(SelectWorldScreen sws) { + Class cls = sws.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (WorldSelectionList.class.isAssignableFrom(f.getType())) { + try { + f.setAccessible(true); + return (WorldSelectionList) f.get(sws); + } catch (Exception e) { + return null; + } + } + } + cls = cls.getSuperclass(); + } + return null; + } + + /** Collects all level IDs currently visible in the world list. */ + private static Set collectWorldIds(WorldSelectionList list) { + Set ids = new HashSet<>(); + List children = getChildrenViaReflection(list); + if (children == null) return ids; + for (Object entry : children) { + LevelSummary summary = getLevelSummaryFromEntry(entry); + if (summary != null && summary.getLevelId() != null) { + ids.add(summary.getLevelId()); + } + } + return ids; + } + + /** Finds the LevelSummary with the given level ID in the world list. */ + private static LevelSummary findWorldSummary(WorldSelectionList list, String levelId) { + List children = getChildrenViaReflection(list); + if (children == null) return null; + for (Object entry : children) { + LevelSummary summary = getLevelSummaryFromEntry(entry); + if (summary != null && levelId.equals(summary.getLevelId())) return summary; + } + return null; + } + + @SuppressWarnings("unchecked") + private static List getChildrenViaReflection(Object obj) { + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (List.class.isAssignableFrom(f.getType())) { + try { + f.setAccessible(true); + Object value = f.get(obj); + if (value instanceof List) return (List) value; + } catch (Exception ignored) {} + } + } + cls = cls.getSuperclass(); + } + return null; + } + + private static LevelSummary getLevelSummaryFromEntry(Object entry) { + if (entry == null) return null; + Class cls = entry.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (LevelSummary.class.isAssignableFrom(f.getType())) { + try { + f.setAccessible(true); + return (LevelSummary) f.get(entry); + } catch (Exception ignored) {} + } + } + cls = cls.getSuperclass(); + } + return null; + } } diff --git a/src/client/java/com/servertabs/gui/AssignTabScreen.java b/src/client/java/com/servertabs/gui/AssignTabScreen.java index 42f4427..5aebd84 100644 --- a/src/client/java/com/servertabs/gui/AssignTabScreen.java +++ b/src/client/java/com/servertabs/gui/AssignTabScreen.java @@ -45,6 +45,16 @@ protected void init() { btn -> this.minecraft.setScreen(parent)) .bounds(this.width / 2 - 50, this.height - 30, 100, 20) .build()); + + this.addRenderableWidget(Button.builder( + Component.literal("Deselect All"), + btn -> { + if (!serverIp.isEmpty()) { + TabConfig.getInstance().deselectAllServerTabs(serverIp); + } + }) + .bounds(this.width / 2 - 50, this.height - 55, 100, 20) + .build()); } // ----------------------------------------------------------------------- diff --git a/src/client/java/com/servertabs/gui/AssignWorldScreen.java b/src/client/java/com/servertabs/gui/AssignWorldScreen.java new file mode 100644 index 0000000..90aef4c --- /dev/null +++ b/src/client/java/com/servertabs/gui/AssignWorldScreen.java @@ -0,0 +1,184 @@ +package com.servertabs.gui; + +import com.servertabs.TabConfig; +import com.servertabs.TabEntry; +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 java.util.List; + +/** + * Shows a list of world tabs with checkboxes. + * Tick/untick any non-locked tab to assign or unassign the world. + * + * "All" is always ticked and cannot be unticked — every world is always in All. + */ +public class AssignWorldScreen extends Screen { + + private static final int ROW_H = 22; + private static final int BOX_SIZE = 11; + private static final int PANEL_W = 220; + + private final Screen parent; + private final String worldId; + private final String worldName; + + public AssignWorldScreen(Screen parent, String worldId, String worldName) { + super(Component.literal("Assign World to Tabs")); + this.parent = parent; + this.worldId = worldId; + this.worldName = worldName; + } + + // ----------------------------------------------------------------------- + // Init + // ----------------------------------------------------------------------- + + @Override + protected void init() { + this.addRenderableWidget(Button.builder( + Component.literal("Done"), + btn -> this.minecraft.setScreen(parent)) + .bounds(this.width / 2 - 50, this.height - 30, 100, 20) + .build()); + + this.addRenderableWidget(Button.builder( + Component.literal("Deselect All"), + btn -> { + if (!worldId.isEmpty()) { + TabConfig.getInstance().deselectAllWorldTabs(worldId); + } + }) + .bounds(this.width / 2 - 50, this.height - 55, 100, 20) + .build()); + } + + // ----------------------------------------------------------------------- + // Rendering + // ----------------------------------------------------------------------- + + @Override + public void renderBackground(GuiGraphics g, int mx, int my, float pt) { + super.renderBackground(g, mx, my, pt); + + List tabs = TabConfig.getInstance().getWorldTabs(); + int panelH = 32 + tabs.size() * ROW_H + 8; + int px = (this.width - PANEL_W) / 2; + int py = (this.height - panelH) / 2; + + g.fill(px, py, px + PANEL_W, py + panelH, 0xEE101010); + g.fill(px, py, px + PANEL_W, py + 1, 0xFF666666); + g.fill(px, py + panelH - 1, px + PANEL_W, py + panelH, 0xFF666666); + g.fill(px, py, px + 1, py + panelH, 0xFF666666); + g.fill(px + PANEL_W - 1, py, px + PANEL_W, py + panelH, 0xFF666666); + g.fill(px + 1, py + 18, px + PANEL_W - 1, py + 19, 0xFF444444); + } + + @Override + public void render(GuiGraphics g, int mx, int my, float pt) { + super.render(g, mx, my, pt); + + List tabs = TabConfig.getInstance().getWorldTabs(); + int panelH = 32 + tabs.size() * ROW_H + 8; + int px = (this.width - PANEL_W) / 2; + int py = (this.height - panelH) / 2; + + // Title + g.drawCenteredString(this.font, this.title, this.width / 2, py + 5, 0xFFFFFFFF); + + // World subtitle + String subtitle = worldName.isEmpty() ? worldId : worldName + " (" + worldId + ")"; + if (this.font.width(subtitle) > PANEL_W - 16) { + subtitle = this.font.plainSubstrByWidth(subtitle, PANEL_W - 16) + "\u2026"; + } + g.drawCenteredString(this.font, Component.literal(subtitle), + this.width / 2, py + 22, 0xFF888888); + + // Warning if world ID is empty + if (worldId.isEmpty()) { + g.drawCenteredString(this.font, + Component.literal("No world ID provided!"), + this.width / 2, py + 40, 0xFFFF5555); + return; + } + + // Checkbox rows + for (int i = 0; i < tabs.size(); i++) { + TabEntry tab = tabs.get(i); + int rowX = px + 10; + int rowY = py + 32 + i * ROW_H; + boolean checked = tab.isLocked() || TabConfig.getInstance().worldInTab(worldId, tab.getId()); + boolean locked = tab.isLocked(); + boolean hovered = !locked + && mx >= rowX && mx < px + PANEL_W - 10 + && my >= rowY && my < rowY + ROW_H; + + if (hovered) g.fill(px + 1, rowY, px + PANEL_W - 1, rowY + ROW_H, 0xFF1E2E3E); + + // Checkbox border + fill + int boxX = rowX; + int boxY = rowY + (ROW_H - BOX_SIZE) / 2; + int borderCol = locked ? 0xFF666666 : (hovered ? 0xFF99BBFF : 0xFF888888); + g.fill(boxX, boxY, boxX + BOX_SIZE, boxY + BOX_SIZE, borderCol); + g.fill(boxX + 1, boxY + 1, boxX + BOX_SIZE - 1, boxY + BOX_SIZE - 1, + checked ? (locked ? 0xFF557755 : 0xFF44AA44) : 0xFF1A1A1A); + + // Checkmark — two overlapping filled rectangles forming a ✓ + if (checked) { + g.fill(boxX + 2, boxY + 5, boxX + 5, boxY + BOX_SIZE - 1, 0xFFFFFFFF); + g.fill(boxX + 4, boxY + 2, boxX + BOX_SIZE - 1, boxY + 6, 0xFFFFFFFF); + } + + // Tab name + int labelX = boxX + BOX_SIZE + 6; + int textColor = locked ? 0xFFFFDD88 + : checked ? 0xFFFFFFFF + : 0xFFAAAAAA; + g.drawString(this.font, tab.getName(), + labelX, rowY + (ROW_H - 8) / 2, textColor, false); + + // "(always)" badge on the locked All tab + if (locked) { + int badge = labelX + this.font.width(tab.getName()) + 5; + g.drawString(this.font, "(always)", badge, + rowY + (ROW_H - 8) / 2, 0xFF555555, false); + } + } + } + + // ----------------------------------------------------------------------- + // Input + // ----------------------------------------------------------------------- + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean bl) { + if (worldId.isEmpty()) return super.mouseClicked(event, bl); + + double mx = event.x(); + double my = event.y(); + + List tabs = TabConfig.getInstance().getWorldTabs(); + int panelH = 32 + tabs.size() * ROW_H + 8; + int px = (this.width - PANEL_W) / 2; + int py = (this.height - panelH) / 2; + + for (int i = 0; i < tabs.size(); i++) { + TabEntry tab = tabs.get(i); + if (tab.isLocked()) continue; + + int rowY = py + 32 + i * ROW_H; + if (mx >= px + 10 && mx < px + PANEL_W - 10 + && my >= rowY && my < rowY + ROW_H) { + TabConfig.getInstance().toggleWorldTab(worldId, tab.getId()); + return true; + } + } + return super.mouseClicked(event, bl); + } + + @Override public boolean shouldCloseOnEsc() { return true; } + @Override public void onClose() { this.minecraft.setScreen(parent); } +} diff --git a/src/client/java/com/servertabs/gui/TabDropdownController.java b/src/client/java/com/servertabs/gui/TabDropdownController.java index aacd460..9b3ea2f 100644 --- a/src/client/java/com/servertabs/gui/TabDropdownController.java +++ b/src/client/java/com/servertabs/gui/TabDropdownController.java @@ -400,6 +400,7 @@ private void enterQuickAssign(String tabId) { /** * Saves all checkbox states to TabConfig and exits Quick Assign mode. * For every server: if checked → assign; if unchecked → unassign. + * All mutations are batched and a single save() is issued at the end. */ private void commitQuickAssign() { JoinMultiplayerScreen jms = (JoinMultiplayerScreen) screen; @@ -410,11 +411,13 @@ private void commitQuickAssign() { if (sd == null) continue; String key = normalise(sd.ip); if (quickAssignChecked.contains(key)) { - TabConfig.getInstance().assignServer(sd.ip, quickAssignTabId); + TabConfig.getInstance().assignServer(sd.ip, quickAssignTabId, false); } else { - TabConfig.getInstance().unassignServer(sd.ip, quickAssignTabId); + TabConfig.getInstance().unassignServer(sd.ip, quickAssignTabId, false); } } + // Single save after all mutations — performance fix + TabConfig.getInstance().save(); } // Exit quick assign, restore the active tab filter diff --git a/src/client/java/com/servertabs/gui/WorldTabsDropdownController.java b/src/client/java/com/servertabs/gui/WorldTabsDropdownController.java new file mode 100644 index 0000000..4fee4eb --- /dev/null +++ b/src/client/java/com/servertabs/gui/WorldTabsDropdownController.java @@ -0,0 +1,365 @@ +package com.servertabs.gui; + +import com.servertabs.TabConfig; +import com.servertabs.TabEntry; +import com.servertabs.WorldTabSessionState; +import com.servertabs.ServerTabsMod; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; +import net.minecraft.client.gui.screens.worldselection.WorldSelectionList; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.storage.LevelSummary; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * Manages the sliding world-tab dropdown panel on SelectWorldScreen. + * + * Features: + * - Slide-in tab panel with click-to-filter + * - Alt+W / Alt+S keyboard tab switching (via switchTab()) + */ +public class WorldTabsDropdownController { + + // ----------------------------------------------------------------------- + // Constants + // ----------------------------------------------------------------------- + + private static final int PANEL_WIDTH = 120; + private static final int PANEL_PAD = 5; + private static final int TAB_HEIGHT = 20; + private static final int TAB_GAP = 3; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + private final Screen screen; + private boolean panelOpen = false; + private float slideProgress = 0f; + private String activeTabId; + + private Button toggleButton; + + // ----------------------------------------------------------------------- + // Constructor + // ----------------------------------------------------------------------- + + public WorldTabsDropdownController(Screen screen) { + this.screen = screen; + this.activeTabId = WorldTabSessionState.getActiveTabId(); + } + + // ----------------------------------------------------------------------- + // Public API — toggle button + // ----------------------------------------------------------------------- + + /** + * Called on every AFTER_INIT. Resets panel state so stale open/progress + * values can never block clicks after returning from a sub-screen. + */ + public Button createToggleButton() { + panelOpen = false; + slideProgress = 0f; + + toggleButton = Button.builder( + Component.literal("Tabs \u2193"), + btn -> { + if (!TabConfig.getInstance().isWorldTabsEnabled()) return; + if (!TabConfig.getInstance().isWorldDropdownEnabled()) return; + panelOpen = !panelOpen; + refreshToggleLabel(); + }) + .bounds(4, 4, 60, 20) + .build(); + refreshToggleLabel(); + return toggleButton; + } + + // ----------------------------------------------------------------------- + // Public API — Alt+W/S keyboard tab switching + // ----------------------------------------------------------------------- + + public void switchTab(int delta) { + if (!TabConfig.getInstance().isWorldTabsEnabled()) return; + + List tabs = TabConfig.getInstance().getWorldTabs(); + if (tabs.isEmpty()) return; + + int currentIdx = 0; + for (int i = 0; i < tabs.size(); i++) { + if (tabs.get(i).getId().equals(activeTabId)) { currentIdx = i; break; } + } + + int newIdx = Math.floorMod(currentIdx + delta, tabs.size()); + activeTabId = tabs.get(newIdx).getId(); + WorldTabSessionState.setActiveTabId(activeTabId); + panelOpen = false; + refreshToggleLabel(); + applyTabFilter((SelectWorldScreen) screen, activeTabId); + } + + public String getActiveTabId() { return activeTabId; } + + // ----------------------------------------------------------------------- + // Render + // ----------------------------------------------------------------------- + + public void onRender(Screen s, GuiGraphics gfx, int mouseX, int mouseY, float delta) { + if (!TabConfig.getInstance().isWorldTabsEnabled()) return; + + float speed = TabConfig.getInstance().getWorldTransitionSpeed().value; + slideProgress = panelOpen + ? Math.min(1f, slideProgress + speed) + : Math.max(0f, slideProgress - speed); + + if (slideProgress <= 0f) return; + + if (!TabConfig.getInstance().isWorldDropdownEnabled()) { + panelOpen = false; slideProgress = 0f; + return; + } + + float ease = easeInOut(slideProgress); + int panelX = Math.round((ease - 1f) * PANEL_WIDTH); + int panelTop = 28; + + List tabs = TabConfig.getInstance().getWorldTabs(); + int tabAreaH = tabs.size() * (TAB_HEIGHT + TAB_GAP) - TAB_GAP; + int panelH = PANEL_PAD * 2 + tabAreaH; + + // Panel background + borders + gfx.fill(panelX, panelTop, panelX + PANEL_WIDTH, panelTop + panelH, 0xC8101010); + int border = 0xFF555555; + gfx.fill(panelX, panelTop, panelX + PANEL_WIDTH, panelTop + 1, border); + gfx.fill(panelX, panelTop + panelH - 1, panelX + PANEL_WIDTH, panelTop + panelH, border); + gfx.fill(panelX + PANEL_WIDTH - 1, panelTop, panelX + PANEL_WIDTH, panelTop + panelH, border); + + for (int i = 0; i < tabs.size(); i++) { + TabEntry tab = tabs.get(i); + int tabX = panelX + PANEL_PAD; + int tabY = panelTop + PANEL_PAD + i * (TAB_HEIGHT + TAB_GAP); + int tabW = PANEL_WIDTH - PANEL_PAD * 2; + + boolean isActive = tab.getId().equals(activeTabId); + boolean isHovered = mouseX >= tabX && mouseX < tabX + tabW + && mouseY >= tabY && mouseY < tabY + TAB_HEIGHT; + + int bgColor = isActive ? 0xFF3A5C3A + : isHovered ? 0xFF2E3A58 + : 0xFF222222; + gfx.fill(tabX, tabY, tabX + tabW, tabY + TAB_HEIGHT, bgColor); + + int borderC = isActive ? 0xFF66BB66 : 0xFF404040; + gfx.fill(tabX, tabY, tabX + tabW, tabY + 1, borderC); + gfx.fill(tabX, tabY + TAB_HEIGHT - 1, tabX + tabW, tabY + TAB_HEIGHT, borderC); + gfx.fill(tabX, tabY, tabX + 1, tabY + TAB_HEIGHT, borderC); + gfx.fill(tabX + tabW - 1, tabY, tabX + tabW, tabY + TAB_HEIGHT, borderC); + + gfx.drawString( + Minecraft.getInstance().font, + tab.getName(), + tabX + 6, + tabY + (TAB_HEIGHT - 8) / 2, + isActive ? 0xFFFFFFFF : 0xFFAAAAAA, + false); + } + } + + // ----------------------------------------------------------------------- + // Mouse click + // ----------------------------------------------------------------------- + + public boolean onMouseClick(Screen s, MouseButtonEvent event) { + if (!TabConfig.getInstance().isWorldTabsEnabled()) return true; + if (slideProgress <= 0f) return true; + + float ease = easeInOut(slideProgress); + int panelX = Math.round((ease - 1f) * PANEL_WIDTH); + int panelTop = 28; + + List tabs = TabConfig.getInstance().getWorldTabs(); + double mouseX = event.x(); + double mouseY = event.y(); + + for (int i = 0; i < tabs.size(); i++) { + int tabX = panelX + PANEL_PAD; + int tabY = panelTop + PANEL_PAD + i * (TAB_HEIGHT + TAB_GAP); + int tabW = PANEL_WIDTH - PANEL_PAD * 2; + + if (mouseX >= tabX && mouseX < tabX + tabW + && mouseY >= tabY && mouseY < tabY + TAB_HEIGHT) { + + activeTabId = tabs.get(i).getId(); + WorldTabSessionState.setActiveTabId(activeTabId); + panelOpen = false; + refreshToggleLabel(); + applyTabFilter((SelectWorldScreen) screen, activeTabId); + return false; + } + } + return true; + } + + // ----------------------------------------------------------------------- + // Core filtering logic + // ----------------------------------------------------------------------- + + /** + * Filters the SelectWorldScreen's world list to only show worlds in the given tab. + * Uses reflection to access the WorldSelectionList and its internal entry list. + */ + public static void applyTabFilter(SelectWorldScreen sws, String tabId) { + try { + WorldSelectionList worldList = getWorldList(sws); + if (worldList == null) { + ServerTabsMod.LOGGER.warn("[ServerTabs] applyTabFilter (world): could not get WorldSelectionList"); + return; + } + + // Reload the full list first via reflection (method name may vary by MC version) + tryRefreshList(worldList); + + if (!"all".equals(tabId)) { + // Get the internal children list from AbstractSelectionList via reflection + List children = getChildrenList(worldList); + if (children != null) { + children.removeIf(entry -> { + String levelId = getLevelId(entry); + return levelId != null && !TabConfig.getInstance().worldInTab(levelId, tabId); + }); + } + } + + } catch (Exception e) { + ServerTabsMod.LOGGER.warn("[ServerTabs] applyTabFilter (world) failed", e); + } + } + + /** Empty-string supplier used as the search filter when reloading the world list. */ + private static final Supplier EMPTY_FILTER = () -> ""; + + /** Attempts to refresh/reload the WorldSelectionList using reflection. */ + private static void tryRefreshList(WorldSelectionList list) { + Class cls = list.getClass(); + while (cls != null && cls != Object.class) { + for (java.lang.reflect.Method m : cls.getDeclaredMethods()) { + if (m.getName().contains("refresh") || m.getName().contains("reload")) { + try { + m.setAccessible(true); + if (m.getParameterCount() == 0) { + m.invoke(list); + return; + } else if (m.getParameterCount() == 2 + && m.getParameterTypes()[0] == Supplier.class + && m.getParameterTypes()[1] == boolean.class) { + m.invoke(list, EMPTY_FILTER, false); + return; + } + } catch (Exception ignored) { + } + } + } + cls = cls.getSuperclass(); + } + } + + // ----------------------------------------------------------------------- + // Reflection helpers + // ----------------------------------------------------------------------- + + /** Finds the WorldSelectionList field on SelectWorldScreen via reflection. */ + private static WorldSelectionList getWorldList(SelectWorldScreen sws) { + Class cls = sws.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (WorldSelectionList.class.isAssignableFrom(f.getType())) { + try { + f.setAccessible(true); + return (WorldSelectionList) f.get(sws); + } catch (Exception e) { + return null; + } + } + } + cls = cls.getSuperclass(); + } + return null; + } + + /** Gets the mutable internal children list from an AbstractSelectionList via reflection. */ + @SuppressWarnings("unchecked") + private static List getChildrenList(WorldSelectionList list) { + Class cls = list.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (List.class.isAssignableFrom(f.getType())) { + try { + f.setAccessible(true); + Object value = f.get(list); + if (value instanceof List) { + // Make sure it's mutable (not an unmodifiable wrapper) + List mutable = new ArrayList<>((List) value); + f.set(list, mutable); + return mutable; + } + } catch (Exception ignored) { + } + } + } + cls = cls.getSuperclass(); + } + return null; + } + + /** Extracts the level ID from a WorldSelectionList.Entry via reflection. */ + private static String getLevelId(Object entry) { + if (entry == null) return null; + Class cls = entry.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (LevelSummary.class.isAssignableFrom(f.getType())) { + try { + f.setAccessible(true); + LevelSummary summary = (LevelSummary) f.get(entry); + return summary != null ? summary.getLevelId() : null; + } catch (Exception ignored) { + } + } + } + cls = cls.getSuperclass(); + } + return null; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private void refreshToggleLabel() { + if (toggleButton == null) return; + if (!TabConfig.getInstance().isWorldTabsEnabled()) { + toggleButton.setMessage(Component.literal("Tabs \u2193")); + return; + } + List tabs = TabConfig.getInstance().getWorldTabs(); + String label = tabs.stream() + .filter(t -> t.getId().equals(activeTabId)) + .map(TabEntry::getName) + .findFirst() + .orElse("All"); + String arrow = panelOpen ? " \u2191" : " \u2193"; + toggleButton.setMessage(Component.literal(label + arrow)); + } + + private static float easeInOut(float t) { + return t * t * (3f - 2f * t); + } +} diff --git a/src/client/java/com/servertabs/gui/WorldTabsSettingsScreen.java b/src/client/java/com/servertabs/gui/WorldTabsSettingsScreen.java new file mode 100644 index 0000000..c05086a --- /dev/null +++ b/src/client/java/com/servertabs/gui/WorldTabsSettingsScreen.java @@ -0,0 +1,444 @@ +package com.servertabs.gui; + +import com.servertabs.TabConfig; +import com.servertabs.TabEntry; +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.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +import java.util.List; + +/** + * WorldTabs Settings Screen + * + * Layout: + * Left panel — scrollable world-tab list + Add / Edit / Delete buttons + * Right panel — global world-tab settings (dropdown toggle, speed, sort, default tab, etc.) + * Bottom — Done button + * + * Keyboard: + * Alt + Up / Down — reorder the currently selected world tab + */ +public class WorldTabsSettingsScreen extends Screen { + + private static final int ROW_H = 18; + private static final int PANEL_BORDER = 0xFF555555; + private static final int PANEL_BG = 0xC0101010; + private static final int DIVIDER = 0xFF444444; + + private final Screen parent; + + private int leftX, leftY, leftW, leftH; + private int rightX, rightY, rightW, rightH; + private int listY, listH; + + private int scrollOffset = 0; + private int selectedIndex = -1; + + private Button editBtn; + private Button deleteBtn; + private Button enabledBtn; + private Button dropdownBtn; + private Button speedBtn; + private Button sortBtn; + private Button defaultTabBtn; + private Button rememberTabBtn; + private Button assignOnAddBtn; + + public WorldTabsSettingsScreen(Screen parent) { + super(Component.literal("WorldTabs Settings")); + this.parent = parent; + } + + // ----------------------------------------------------------------------- + // init() + // ----------------------------------------------------------------------- + + @Override + protected void init() { + leftX = 5; + leftY = 22; + leftW = this.width / 2 - 8; + leftH = this.height - 57; + + rightX = this.width / 2 + 3; + rightY = 22; + rightW = this.width - rightX - 5; + rightH = this.height - 57; + + listY = leftY + 15; + listH = leftH - 38; + + // ---- Left panel buttons ---- + int btnY = leftY + leftH - 20; + int btnW = (leftW - 8) / 3; + int btnGap = 2; + + this.addRenderableWidget(Button.builder( + Component.literal("Add"), + btn -> openAddPopup()) + .bounds(leftX + 2, btnY, btnW, 16) + .build()); + + editBtn = Button.builder( + Component.literal("Edit"), + btn -> openEditPopup()) + .bounds(leftX + 2 + btnW + btnGap, btnY, btnW, 16) + .build(); + this.addRenderableWidget(editBtn); + + deleteBtn = Button.builder( + Component.literal("Delete"), + btn -> deleteSelected()) + .bounds(leftX + 2 + (btnW + btnGap) * 2, btnY, btnW, 16) + .build(); + this.addRenderableWidget(deleteBtn); + + // ---- Right panel settings ---- + int rBtnX = rightX + rightW / 2; + int rBtnW = rightW / 2 - 4; + + enabledBtn = Button.builder( + Component.literal(enabledLabel()), + btn -> { + TabConfig.getInstance().setWorldTabsEnabled( + !TabConfig.getInstance().isWorldTabsEnabled()); + btn.setMessage(Component.literal(enabledLabel())); + }) + .bounds(rBtnX, rightY + 22, rBtnW, 14) + .build(); + this.addRenderableWidget(enabledBtn); + + dropdownBtn = Button.builder( + Component.literal(dropdownLabel()), + btn -> { + TabConfig.getInstance().setWorldDropdownEnabled( + !TabConfig.getInstance().isWorldDropdownEnabled()); + btn.setMessage(Component.literal(dropdownLabel())); + }) + .bounds(rBtnX, rightY + 42, rBtnW, 14) + .build(); + this.addRenderableWidget(dropdownBtn); + + speedBtn = Button.builder( + Component.literal(TabConfig.getInstance().getWorldTransitionSpeed().label), + btn -> { + TabConfig.TransitionSpeed next = + TabConfig.getInstance().getWorldTransitionSpeed().next(); + TabConfig.getInstance().setWorldTransitionSpeed(next); + btn.setMessage(Component.literal(next.label)); + }) + .bounds(rBtnX, rightY + 62, rBtnW, 14) + .build(); + this.addRenderableWidget(speedBtn); + + sortBtn = Button.builder( + Component.literal(TabConfig.getInstance().getWorldSortingType().label), + btn -> { + TabConfig.WorldSortingType next = + TabConfig.getInstance().getWorldSortingType().next(); + TabConfig.getInstance().setWorldSortingType(next); + btn.setMessage(Component.literal(next.label)); + }) + .bounds(rBtnX, rightY + 82, rBtnW, 14) + .build(); + this.addRenderableWidget(sortBtn); + + defaultTabBtn = Button.builder( + Component.literal(defaultTabLabel()), + btn -> { + cycleDefaultTab(); + btn.setMessage(Component.literal(defaultTabLabel())); + }) + .bounds(rBtnX, rightY + 102, rBtnW, 14) + .build(); + this.addRenderableWidget(defaultTabBtn); + + rememberTabBtn = Button.builder( + Component.literal(rememberTabLabel()), + btn -> { + TabConfig.getInstance().setWorldRememberTab( + !TabConfig.getInstance().isWorldRememberTab()); + btn.setMessage(Component.literal(rememberTabLabel())); + }) + .bounds(rBtnX, rightY + 122, rBtnW, 14) + .build(); + this.addRenderableWidget(rememberTabBtn); + + assignOnAddBtn = Button.builder( + Component.literal(assignOnAddLabel()), + btn -> { + TabConfig.getInstance().setWorldAssignOnAdd( + !TabConfig.getInstance().isWorldAssignOnAdd()); + btn.setMessage(Component.literal(assignOnAddLabel())); + }) + .bounds(rBtnX, rightY + 142, rBtnW, 14) + .build(); + this.addRenderableWidget(assignOnAddBtn); + + // ---- Done button ---- + this.addRenderableWidget(Button.builder( + Component.literal("Done"), + btn -> this.minecraft.setScreen(parent)) + .bounds(this.width / 2 - 50, this.height - 28, 100, 20) + .build()); + + refreshButtonStates(); + } + + // ----------------------------------------------------------------------- + // Rendering + // ----------------------------------------------------------------------- + + @Override + public void renderBackground(GuiGraphics g, int mx, int my, float pt) { + super.renderBackground(g, mx, my, pt); + + drawPanel(g, leftX, leftY, leftW, leftH); + drawPanel(g, rightX, rightY, rightW, rightH); + + g.fill(leftX + 1, leftY + 14, leftX + leftW - 1, leftY + 15, DIVIDER); + g.fill(rightX + 1, rightY + 14, rightX + rightW - 1, rightY + 15, DIVIDER); + g.fill(leftX + 1, leftY + leftH - 22, + leftX + leftW - 1, leftY + leftH - 21, DIVIDER); + } + + private void drawPanel(GuiGraphics g, int x, int y, int w, int h) { + g.fill(x, y, x + w, y + h, PANEL_BG); + g.fill(x, y, x + w, y + 1, PANEL_BORDER); + g.fill(x, y + h - 1, x + w, y + h, PANEL_BORDER); + g.fill(x, y, x + 1, y + h, PANEL_BORDER); + g.fill(x + w - 1, y, x + w, y + h, PANEL_BORDER); + } + + @Override + public void render(GuiGraphics g, int mx, int my, float pt) { + super.render(g, mx, my, pt); + + g.drawCenteredString(this.font, this.title, this.width / 2, 8, 0xFFFFFFFF); + + g.drawString(this.font, "World Tabs", leftX + 4, leftY + 4, 0xFFFFFFFF, false); + g.drawString(this.font, "Settings", rightX + 4, rightY + 4, 0xFFFFFFFF, false); + + int lx = rightX + 5; + g.drawString(this.font, "Enabled:", lx, rightY + 25, 0xFFCCCCCC, false); + g.drawString(this.font, "Dropdown:", lx, rightY + 45, 0xFFCCCCCC, false); + g.drawString(this.font, "Speed:", lx, rightY + 65, 0xFFCCCCCC, false); + g.drawString(this.font, "Sort By:", lx, rightY + 85, 0xFFCCCCCC, false); + g.drawString(this.font, "Default:", lx, rightY + 105, 0xFFCCCCCC, false); + g.drawString(this.font, "Rem. Tab:", lx, rightY + 125, 0xFFCCCCCC, false); + g.drawString(this.font, "Assign+Add:", lx, rightY + 145, 0xFFCCCCCC, false); + + drawTabList(g, mx, my); + + List tabs = TabConfig.getInstance().getWorldTabs(); + if (selectedIndex > 0 && selectedIndex < tabs.size() + && !tabs.get(selectedIndex).isLocked()) { + g.drawCenteredString(this.font, + Component.literal("Alt + \u2191\u2193 to reorder"), + leftX + leftW / 2, + leftY + leftH - 34, + 0xFF666666); + } + } + + private void drawTabList(GuiGraphics g, int mx, int my) { + List tabs = TabConfig.getInstance().getWorldTabs(); + int visibleRows = listH / ROW_H; + int maxScroll = Math.max(0, tabs.size() - visibleRows); + scrollOffset = Math.min(scrollOffset, maxScroll); + + for (int i = 0; i < visibleRows; i++) { + int tabIdx = i + scrollOffset; + if (tabIdx >= tabs.size()) break; + + TabEntry tab = tabs.get(tabIdx); + int rowX = leftX + 2; + int rowY = listY + i * ROW_H; + int rowW = leftW - 4; + + boolean isSelected = tabIdx == selectedIndex; + boolean isHovered = mx >= rowX && mx < rowX + rowW + && my >= rowY && my < rowY + ROW_H; + + int bg = isSelected ? 0xFF2A4A2A + : isHovered ? 0xFF1E2E3E + : 0x00000000; + if (bg != 0) g.fill(rowX, rowY, rowX + rowW, rowY + ROW_H, bg); + + if (isSelected) g.fill(rowX, rowY, rowX + 2, rowY + ROW_H, 0xFF55BB55); + + String label = tab.isLocked() ? "\u2605 " + tab.getName() : tab.getName(); + int textColor = tab.isLocked() + ? (isSelected ? 0xFFFFFFAA : 0xFFFFDD88) + : (isSelected ? 0xFFFFFFFF : 0xFFCCCCCC); + g.drawString(this.font, label, + rowX + 6, rowY + (ROW_H - 8) / 2, textColor, false); + } + + if (scrollOffset > 0) { + g.drawString(this.font, "\u25B2", + leftX + leftW - 10, listY + 2, 0xFF888888, false); + } + if (scrollOffset < maxScroll) { + g.drawString(this.font, "\u25BC", + leftX + leftW - 10, listY + listH - 10, 0xFF888888, false); + } + } + + // ----------------------------------------------------------------------- + // Input + // ----------------------------------------------------------------------- + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean bl) { + double mx = event.x(); + double my = event.y(); + + if (mx >= leftX + 2 && mx < leftX + leftW - 2 + && my >= listY && my < listY + listH) { + + int row = (int)((my - listY) / ROW_H); + int tabIdx = row + scrollOffset; + List tabs = TabConfig.getInstance().getWorldTabs(); + + if (tabIdx >= 0 && tabIdx < tabs.size()) { + selectedIndex = tabIdx; + refreshButtonStates(); + return true; + } + } + return super.mouseClicked(event, bl); + } + + @Override + public boolean mouseScrolled(double mx, double my, double scrollX, double scrollY) { + if (mx >= leftX && mx < leftX + leftW && my >= leftY && my < leftY + leftH) { + List tabs = TabConfig.getInstance().getWorldTabs(); + int visibleRows = listH / ROW_H; + int maxScroll = Math.max(0, tabs.size() - visibleRows); + scrollOffset = Math.max(0, Math.min(maxScroll, + scrollOffset - (int)Math.signum(scrollY))); + return true; + } + return super.mouseScrolled(mx, my, scrollX, scrollY); + } + + @Override + public boolean keyPressed(KeyEvent event) { + int keyCode = event.key(); + boolean altDown = (event.modifiers() & GLFW.GLFW_MOD_ALT) != 0; + + List tabs = TabConfig.getInstance().getWorldTabs(); + + if (altDown && selectedIndex >= 0 && selectedIndex < tabs.size()) { + if (keyCode == GLFW.GLFW_KEY_UP && selectedIndex > 0 + && !tabs.get(selectedIndex - 1).isLocked()) { + TabConfig.getInstance().moveWorldTabUp(selectedIndex); + selectedIndex--; + clampScrollToSelection(); + return true; + } else if (keyCode == GLFW.GLFW_KEY_DOWN + && selectedIndex < tabs.size() - 1 + && !tabs.get(selectedIndex).isLocked()) { + TabConfig.getInstance().moveWorldTabDown(selectedIndex); + selectedIndex++; + clampScrollToSelection(); + return true; + } + } + return super.keyPressed(event); + } + + // ----------------------------------------------------------------------- + // Tab operations + // ----------------------------------------------------------------------- + + private void openAddPopup() { + this.minecraft.setScreen(new TabNamePopupScreen(this, "New World Tab", "", name -> { + TabConfig.getInstance().addWorldTab(name); + selectedIndex = TabConfig.getInstance().getWorldTabs().size() - 1; + clampScrollToSelection(); + refreshButtonStates(); + refreshDefaultTabBtn(); + })); + } + + private void openEditPopup() { + List tabs = TabConfig.getInstance().getWorldTabs(); + if (selectedIndex < 0 || selectedIndex >= tabs.size()) return; + TabEntry tab = tabs.get(selectedIndex); + if (tab.isLocked()) return; + + this.minecraft.setScreen(new TabNamePopupScreen(this, "Rename World Tab", tab.getName(), name -> { + TabConfig.getInstance().renameWorldTab(tab, name); + refreshDefaultTabBtn(); + })); + } + + private void deleteSelected() { + List tabs = TabConfig.getInstance().getWorldTabs(); + if (selectedIndex < 0 || selectedIndex >= tabs.size()) return; + TabEntry tab = tabs.get(selectedIndex); + if (tab.isLocked()) return; + + TabConfig.getInstance().deleteWorldTab(tab); + if (selectedIndex >= tabs.size()) selectedIndex = tabs.size() - 1; + refreshButtonStates(); + refreshDefaultTabBtn(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private void refreshButtonStates() { + List tabs = TabConfig.getInstance().getWorldTabs(); + boolean hasMovable = selectedIndex >= 0 + && selectedIndex < tabs.size() + && !tabs.get(selectedIndex).isLocked(); + editBtn.active = hasMovable; + deleteBtn.active = hasMovable; + } + + private void refreshDefaultTabBtn() { + if (defaultTabBtn != null) + defaultTabBtn.setMessage(Component.literal(defaultTabLabel())); + } + + private String enabledLabel() { return TabConfig.getInstance().isWorldTabsEnabled() ? "ON" : "OFF"; } + private String dropdownLabel() { return TabConfig.getInstance().isWorldDropdownEnabled() ? "ON" : "OFF"; } + private String rememberTabLabel() { return TabConfig.getInstance().isWorldRememberTab() ? "ON" : "OFF"; } + private String assignOnAddLabel() { return TabConfig.getInstance().isWorldAssignOnAdd() ? "ON" : "OFF"; } + + private String defaultTabLabel() { + TabEntry def = TabConfig.getInstance().getWorldDefaultTab(); + return def != null ? def.getName() : "All"; + } + + private void cycleDefaultTab() { + List tabs = TabConfig.getInstance().getWorldTabs(); + if (tabs.isEmpty()) return; + String currentId = TabConfig.getInstance().getWorldDefaultTabId(); + int idx = 0; + for (int i = 0; i < tabs.size(); i++) { + if (tabs.get(i).getId().equals(currentId)) { idx = i; break; } + } + TabConfig.getInstance().setWorldDefaultTabId(tabs.get((idx + 1) % tabs.size()).getId()); + refreshDefaultTabBtn(); + } + + private void clampScrollToSelection() { + int visibleRows = listH / ROW_H; + if (selectedIndex < scrollOffset) scrollOffset = selectedIndex; + if (selectedIndex >= scrollOffset + visibleRows) + scrollOffset = selectedIndex - visibleRows + 1; + } + + @Override public boolean shouldCloseOnEsc() { return true; } + @Override public void onClose() { this.minecraft.setScreen(parent); } +} diff --git a/src/client/java/com/servertabs/mixin/TitleScreenMixin.java b/src/client/java/com/servertabs/mixin/TitleScreenMixin.java index ae42ab4..59cd5bc 100644 --- a/src/client/java/com/servertabs/mixin/TitleScreenMixin.java +++ b/src/client/java/com/servertabs/mixin/TitleScreenMixin.java @@ -1,6 +1,7 @@ package com.servertabs.mixin; import com.servertabs.gui.ServerTabsSettingsScreen; +import com.servertabs.gui.WorldTabsSettingsScreen; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; @@ -13,35 +14,31 @@ @Mixin(TitleScreen.class) public abstract class TitleScreenMixin extends Screen { - // The super constructor is never actually called at runtime — - // Mixin patches the bytecode of TitleScreen directly. protected TitleScreenMixin(Component title) { super(title); } /** - * Injected at the very end of TitleScreen.init() so all vanilla - * buttons already exist. We place our gear button directly to the - * right of the Multiplayer button row. + * Injected at the very end of TitleScreen.init(). * - * Vanilla Multiplayer button position: - * x = this.width / 2 - 100 (left edge of the 200px button) - * y = this.height / 4 + 48 + 24 + * Vanilla button positions (200 px wide, centred): + * Singleplayer y = this.height/4 + 48 + * Multiplayer y = this.height/4 + 48 + 24 * - * Our button starts 2px after the right edge (width/2 + 100 + 2 = width/2 + 102). - * It is 20 x 20 px so it stays compact and never overlaps the vanilla layout. + * We place a ⚙ gear next to the Singleplayer button (WorldTabs settings) + * and another next to the Multiplayer button (ServerTabs settings). + * Both buttons are 20×20 and start 2px after the right edge of their row. */ @Inject(method = "init", at = @At("TAIL")) - private void servertabs$addSettingsButton(CallbackInfo ci) { - int multiplayerY = this.height / 4 + 48 + 24; - int buttonX = this.width / 2 + 102; + private void servertabs$addSettingsButtons(CallbackInfo ci) { + int buttonX = this.width / 2 + 102; + // ── ServerTabs gear (next to Multiplayer button) ────────────────── + int multiplayerY = this.height / 4 + 48 + 24; this.addRenderableWidget( Button.builder( - Component.literal("\u2699"), // ⚙ gear symbol (Unicode U+2699) - btn -> this.minecraft.setScreen( - new ServerTabsSettingsScreen(this) // open our settings screen - ) + Component.literal("\u2699"), + btn -> this.minecraft.setScreen(new ServerTabsSettingsScreen(this)) ) .bounds(buttonX, multiplayerY, 20, 20) .tooltip(net.minecraft.client.gui.components.Tooltip.create( @@ -49,5 +46,19 @@ protected TitleScreenMixin(Component title) { )) .build() ); + + // ── WorldTabs gear (next to Singleplayer button) ────────────────── + int singleplayerY = this.height / 4 + 48; + this.addRenderableWidget( + Button.builder( + Component.literal("\u2699"), + btn -> this.minecraft.setScreen(new WorldTabsSettingsScreen(this)) + ) + .bounds(buttonX, singleplayerY, 20, 20) + .tooltip(net.minecraft.client.gui.components.Tooltip.create( + Component.literal("WorldTabs Settings") + )) + .build() + ); } } diff --git a/src/main/java/com/servertabs/TabConfig.java b/src/main/java/com/servertabs/TabConfig.java index a01fadb..5f6dd14 100644 --- a/src/main/java/com/servertabs/TabConfig.java +++ b/src/main/java/com/servertabs/TabConfig.java @@ -41,6 +41,18 @@ public SortingType next() { } } + public enum WorldSortingType { + NONE("None"), ALPHABETICAL("Alphabetical"), LAST_PLAYED("Last Played"); + + public final String label; + WorldSortingType(String label) { this.label = label; } + + public WorldSortingType next() { + WorldSortingType[] v = values(); + return v[(ordinal() + 1) % v.length]; + } + } + // ----------------------------------------------------------------------- // Singleton // ----------------------------------------------------------------------- @@ -53,7 +65,7 @@ public static TabConfig getInstance() { } // ----------------------------------------------------------------------- - // Fields + // Fields — Server Tabs // ----------------------------------------------------------------------- private List tabs = new ArrayList<>(); @@ -65,6 +77,20 @@ public static TabConfig getInstance() { private boolean assignOnAdd = true; private Map> serverTabAssignments = new HashMap<>(); + // ----------------------------------------------------------------------- + // Fields — World Tabs + // ----------------------------------------------------------------------- + + private List worldTabs = new ArrayList<>(); + private boolean worldTabsEnabled = true; + private boolean worldDropdownEnabled = true; + private TransitionSpeed worldTransitionSpeed = TransitionSpeed.MEDIUM; + private WorldSortingType worldSortingType = WorldSortingType.NONE; + private String worldDefaultTabId = "all"; + private boolean worldRememberTab = false; + private boolean worldAssignOnAdd = false; + private Map> worldTabAssignments = new HashMap<>(); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); // ----------------------------------------------------------------------- @@ -80,6 +106,8 @@ public void load() { if (!file.exists()) { applyDefaults(); save(); return; } try (Reader r = new FileReader(file)) { JsonObject obj = GSON.fromJson(r, JsonObject.class); + + // — Server tabs — tabs.clear(); if (obj.has("tabs")) for (JsonElement el : obj.getAsJsonArray("tabs")) @@ -100,6 +128,30 @@ public void load() { } } ensureAllTab(); + + // — World tabs — + worldTabs.clear(); + if (obj.has("worldTabs")) + for (JsonElement el : obj.getAsJsonArray("worldTabs")) + worldTabs.add(GSON.fromJson(el, TabEntry.class)); + if (obj.has("worldTabsEnabled")) worldTabsEnabled = obj.get("worldTabsEnabled").getAsBoolean(); + if (obj.has("worldDropdownEnabled")) worldDropdownEnabled = obj.get("worldDropdownEnabled").getAsBoolean(); + if (obj.has("worldTransitionSpeed")) worldTransitionSpeed = TransitionSpeed.valueOf(obj.get("worldTransitionSpeed").getAsString()); + if (obj.has("worldSortingType")) worldSortingType = WorldSortingType.valueOf(obj.get("worldSortingType").getAsString()); + if (obj.has("worldDefaultTabId")) worldDefaultTabId = obj.get("worldDefaultTabId").getAsString(); + if (obj.has("worldRememberTab")) worldRememberTab = obj.get("worldRememberTab").getAsBoolean(); + if (obj.has("worldAssignOnAdd")) worldAssignOnAdd = obj.get("worldAssignOnAdd").getAsBoolean(); + worldTabAssignments.clear(); + if (obj.has("worldTabAssignments")) { + JsonObject a = obj.getAsJsonObject("worldTabAssignments"); + for (Map.Entry e : a.entrySet()) { + Set ids = new HashSet<>(); + for (JsonElement el : e.getValue().getAsJsonArray()) ids.add(el.getAsString()); + worldTabAssignments.put(e.getKey(), ids); + } + } + ensureAllWorldTab(); + } catch (Exception e) { ServerTabsMod.LOGGER.error("[ServerTabs] Failed to load config", e); applyDefaults(); @@ -109,6 +161,8 @@ public void load() { public void save() { try { JsonObject obj = new JsonObject(); + + // — Server tabs — obj.add("tabs", GSON.toJsonTree(tabs)); obj.addProperty("dropdownEnabled", dropdownEnabled); obj.addProperty("transitionSpeed", transitionSpeed.name()); @@ -116,10 +170,25 @@ public void save() { obj.addProperty("defaultTabId", defaultTabId); obj.addProperty("rememberTab", rememberTab); obj.addProperty("assignOnAdd", assignOnAdd); - JsonObject a = new JsonObject(); + JsonObject sa = new JsonObject(); for (Map.Entry> e : serverTabAssignments.entrySet()) - a.add(e.getKey(), GSON.toJsonTree(e.getValue())); - obj.add("serverTabAssignments", a); + sa.add(e.getKey(), GSON.toJsonTree(e.getValue())); + obj.add("serverTabAssignments", sa); + + // — World tabs — + obj.add("worldTabs", GSON.toJsonTree(worldTabs)); + obj.addProperty("worldTabsEnabled", worldTabsEnabled); + obj.addProperty("worldDropdownEnabled", worldDropdownEnabled); + obj.addProperty("worldTransitionSpeed", worldTransitionSpeed.name()); + obj.addProperty("worldSortingType", worldSortingType.name()); + obj.addProperty("worldDefaultTabId", worldDefaultTabId); + obj.addProperty("worldRememberTab", worldRememberTab); + obj.addProperty("worldAssignOnAdd", worldAssignOnAdd); + JsonObject wa = new JsonObject(); + for (Map.Entry> e : worldTabAssignments.entrySet()) + wa.add(e.getKey(), GSON.toJsonTree(e.getValue())); + obj.add("worldTabAssignments", wa); + try (Writer w = new FileWriter(configPath().toFile())) { GSON.toJson(obj, w); } } catch (Exception e) { ServerTabsMod.LOGGER.error("[ServerTabs] Failed to save config", e); @@ -138,6 +207,17 @@ private void applyDefaults() { rememberTab = true; assignOnAdd = true; serverTabAssignments = new HashMap<>(); + + worldTabs.clear(); + worldTabs.add(new TabEntry("all", "All", true)); + worldTabsEnabled = true; + worldDropdownEnabled = true; + worldTransitionSpeed = TransitionSpeed.MEDIUM; + worldSortingType = WorldSortingType.NONE; + worldDefaultTabId = "all"; + worldRememberTab = false; + worldAssignOnAdd = false; + worldTabAssignments = new HashMap<>(); } private void ensureAllTab() { @@ -145,8 +225,13 @@ private void ensureAllTab() { tabs.add(0, new TabEntry("all", "All", true)); } + private void ensureAllWorldTab() { + if (worldTabs.stream().noneMatch(t -> "all".equals(t.getId()))) + worldTabs.add(0, new TabEntry("all", "All", true)); + } + // ----------------------------------------------------------------------- - // Getters + // Getters — Server Tabs // ----------------------------------------------------------------------- public List getTabs() { return tabs; } @@ -163,7 +248,25 @@ public TabEntry getDefaultTab() { } // ----------------------------------------------------------------------- - // Setters + // Getters — World Tabs + // ----------------------------------------------------------------------- + + public List getWorldTabs() { return worldTabs; } + public boolean isWorldTabsEnabled() { return worldTabsEnabled; } + public boolean isWorldDropdownEnabled() { return worldDropdownEnabled; } + public TransitionSpeed getWorldTransitionSpeed() { return worldTransitionSpeed; } + public WorldSortingType getWorldSortingType() { return worldSortingType; } + public String getWorldDefaultTabId() { return worldDefaultTabId; } + public boolean isWorldRememberTab() { return worldRememberTab; } + public boolean isWorldAssignOnAdd() { return worldAssignOnAdd; } + + public TabEntry getWorldDefaultTab() { + return worldTabs.stream().filter(t -> t.getId().equals(worldDefaultTabId)) + .findFirst().orElse(worldTabs.isEmpty() ? null : worldTabs.get(0)); + } + + // ----------------------------------------------------------------------- + // Setters — Server Tabs // ----------------------------------------------------------------------- public void setDropdownEnabled(boolean v) { dropdownEnabled = v; save(); } @@ -174,7 +277,19 @@ public TabEntry getDefaultTab() { public void setAssignOnAdd(boolean v) { assignOnAdd = v; save(); } // ----------------------------------------------------------------------- - // Tab mutations + // Setters — World Tabs + // ----------------------------------------------------------------------- + + public void setWorldTabsEnabled(boolean v) { worldTabsEnabled = v; save(); } + public void setWorldDropdownEnabled(boolean v) { worldDropdownEnabled = v; save(); } + public void setWorldTransitionSpeed(TransitionSpeed v) { worldTransitionSpeed = v; save(); } + public void setWorldSortingType(WorldSortingType v) { worldSortingType = v; save(); } + public void setWorldDefaultTabId(String v) { worldDefaultTabId = v; save(); } + public void setWorldRememberTab(boolean v) { worldRememberTab = v; save(); } + public void setWorldAssignOnAdd(boolean v) { worldAssignOnAdd = v; save(); } + + // ----------------------------------------------------------------------- + // Tab mutations — Server Tabs // ----------------------------------------------------------------------- public void addTab(String name) { @@ -210,6 +325,43 @@ public void moveTabDown(int index) { Collections.swap(tabs, index, index + 1); save(); } + // ----------------------------------------------------------------------- + // Tab mutations — World Tabs + // ----------------------------------------------------------------------- + + public void addWorldTab(String name) { + String id = "world_" + name.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "_") + + "_" + System.currentTimeMillis(); + worldTabs.add(new TabEntry(id, name, false)); + save(); + } + + public void renameWorldTab(TabEntry tab, String newName) { + if (tab.isLocked()) return; + tab.setName(newName); save(); + } + + public void deleteWorldTab(TabEntry tab) { + if (tab.isLocked()) return; + String id = tab.getId(); + worldTabAssignments.values().forEach(s -> s.remove(id)); + worldTabs.remove(tab); + if (worldDefaultTabId.equals(id)) worldDefaultTabId = "all"; + save(); + } + + public void moveWorldTabUp(int index) { + if (index <= 0 || index >= worldTabs.size()) return; + if (worldTabs.get(index - 1).isLocked()) return; + Collections.swap(worldTabs, index, index - 1); save(); + } + + public void moveWorldTabDown(int index) { + if (index < 0 || index >= worldTabs.size() - 1) return; + if (worldTabs.get(index).isLocked()) return; + Collections.swap(worldTabs, index, index + 1); save(); + } + // ----------------------------------------------------------------------- // Server-tab assignments // ----------------------------------------------------------------------- @@ -219,18 +371,26 @@ private static String normaliseIp(String ip) { } public void assignServer(String serverIp, String tabId) { + assignServer(serverIp, tabId, true); + } + + public void assignServer(String serverIp, String tabId, boolean doSave) { if ("all".equals(tabId)) return; String key = normaliseIp(serverIp); if (key.isEmpty()) return; serverTabAssignments.computeIfAbsent(key, k -> new HashSet<>()).add(tabId); - save(); + if (doSave) save(); } public void unassignServer(String serverIp, String tabId) { + unassignServer(serverIp, tabId, true); + } + + public void unassignServer(String serverIp, String tabId, boolean doSave) { String key = normaliseIp(serverIp); Set ids = serverTabAssignments.get(key); if (ids != null) { ids.remove(tabId); if (ids.isEmpty()) serverTabAssignments.remove(key); } - save(); + if (doSave) save(); } public boolean serverInTab(String serverIp, String tabId) { @@ -251,4 +411,68 @@ public boolean toggleServerTab(String serverIp, String tabId) { if (serverInTab(serverIp, tabId)) { unassignServer(serverIp, tabId); return false; } else { assignServer(serverIp, tabId); return true; } } + + /** Removes the server from all non-locked tabs in one save. */ + public void deselectAllServerTabs(String serverIp) { + String key = normaliseIp(serverIp); + if (!key.isEmpty()) serverTabAssignments.remove(key); + save(); + } + + // ----------------------------------------------------------------------- + // World-tab assignments + // ----------------------------------------------------------------------- + + private static String normaliseWorldId(String worldId) { + return worldId == null ? "" : worldId.trim(); + } + + public void assignWorld(String worldId, String tabId) { + assignWorld(worldId, tabId, true); + } + + public void assignWorld(String worldId, String tabId, boolean doSave) { + if ("all".equals(tabId)) return; + String key = normaliseWorldId(worldId); + if (key.isEmpty()) return; + worldTabAssignments.computeIfAbsent(key, k -> new HashSet<>()).add(tabId); + if (doSave) save(); + } + + public void unassignWorld(String worldId, String tabId) { + unassignWorld(worldId, tabId, true); + } + + public void unassignWorld(String worldId, String tabId, boolean doSave) { + String key = normaliseWorldId(worldId); + Set ids = worldTabAssignments.get(key); + if (ids != null) { ids.remove(tabId); if (ids.isEmpty()) worldTabAssignments.remove(key); } + if (doSave) save(); + } + + public boolean worldInTab(String worldId, String tabId) { + if ("all".equals(tabId)) return true; + String key = normaliseWorldId(worldId); + Set ids = worldTabAssignments.get(key); + return ids != null && ids.contains(tabId); + } + + public Set getTabIdsForWorld(String worldId) { + String key = normaliseWorldId(worldId); + Set ids = worldTabAssignments.get(key); + return ids != null ? Collections.unmodifiableSet(ids) : Collections.emptySet(); + } + + public boolean toggleWorldTab(String worldId, String tabId) { + if ("all".equals(tabId)) return true; + if (worldInTab(worldId, tabId)) { unassignWorld(worldId, tabId); return false; } + else { assignWorld(worldId, tabId); return true; } + } + + /** Removes the world from all non-locked world tabs in one save. */ + public void deselectAllWorldTabs(String worldId) { + String key = normaliseWorldId(worldId); + if (!key.isEmpty()) worldTabAssignments.remove(key); + save(); + } } diff --git a/src/main/java/com/servertabs/WorldTabSessionState.java b/src/main/java/com/servertabs/WorldTabSessionState.java new file mode 100644 index 0000000..f1939b1 --- /dev/null +++ b/src/main/java/com/servertabs/WorldTabSessionState.java @@ -0,0 +1,33 @@ +package com.servertabs; + +/** + * Static state holder for the active world tab, survives SelectWorldScreen reinits. + * Automatically cleared when the game quits (static fields don't persist). + * + * Using null as sentinel means "not yet set — use the config default". + */ +public class WorldTabSessionState { + + private static String activeTabId = null; + + /** Returns the active world tab ID, falling back to the config default if not yet set. */ + public static String getActiveTabId() { + if (activeTabId == null) { + return TabConfig.getInstance().getWorldDefaultTabId(); + } + return activeTabId; + } + + /** Explicitly set the active world tab (persists across screen reinits). */ + public static void setActiveTabId(String id) { + activeTabId = id; + } + + /** + * Reset to null so the next call to getActiveTabId() returns the config default. + * Called when the user goes to the main menu and worldRememberTab is OFF. + */ + public static void resetToDefault() { + activeTabId = null; + } +}