diff --git a/patches/net/minecraft/client/gui/screens/PauseScreen.java.patch b/patches/net/minecraft/client/gui/screens/PauseScreen.java.patch index 5bbe0ad9a8d..58a62b0e2eb 100644 --- a/patches/net/minecraft/client/gui/screens/PauseScreen.java.patch +++ b/patches/net/minecraft/client/gui/screens/PauseScreen.java.patch @@ -4,7 +4,7 @@ } else { helper.addChild(this.openScreenButton(PLAYER_REPORTING, () -> new SocialInteractionsScreen(this))); } -+ helper.addChild(Button.builder(Component.translatable("fml.menu.mods"), button -> this.minecraft.setScreen(new net.neoforged.neoforge.client.gui.ModListScreen(this))).width(BUTTON_WIDTH_FULL).build(), 2); ++ helper.addChild(Button.builder(Component.translatable("fml.menu.mods"), button -> this.minecraft.setScreen(net.neoforged.neoforge.client.gui.modlist.ModListScreen.create())).width(BUTTON_WIDTH_FULL).build(), 2); this.disconnectButton = helper.addChild( Button.builder( diff --git a/patches/net/minecraft/client/gui/screens/TitleScreen.java.patch b/patches/net/minecraft/client/gui/screens/TitleScreen.java.patch index 3ba94479390..215625db8b5 100644 --- a/patches/net/minecraft/client/gui/screens/TitleScreen.java.patch +++ b/patches/net/minecraft/client/gui/screens/TitleScreen.java.patch @@ -1,6 +1,6 @@ --- a/net/minecraft/client/gui/screens/TitleScreen.java +++ b/net/minecraft/client/gui/screens/TitleScreen.java -@@ -104,11 +_,17 @@ +@@ -104,7 +_,7 @@ int copyrightWidth = this.font.width(COPYRIGHT_TEXT); int copyrightX = this.width - copyrightWidth - 2; int spacing = 24; @@ -9,16 +9,20 @@ if (this.minecraft.isDemo()) { topPos = this.createDemoMenuOptions(topPos, 24); } else { - topPos = this.createNormalMenuOptions(topPos, 24); -+ int modsOffset = SharedConstants.IS_RUNNING_IN_IDE ? 2 : -100; -+ int modsWidth = SharedConstants.IS_RUNNING_IN_IDE ? 98 : 200; -+ this.addRenderableWidget(new net.neoforged.neoforge.client.gui.widget.ModsButton(Button.builder(Component.translatable("fml.menu.mods"), button -> this.minecraft.setScreen(new net.neoforged.neoforge.client.gui.ModListScreen(this))) -+ .pos(this.width / 2 + modsOffset, topPos + 24).size(modsWidth, 20))); -+ if (!SharedConstants.IS_RUNNING_IN_IDE) -+ topPos += 24; // Move down Options, Quit, Language, and Accessibility buttons to make room for mods button (in-dev the test world button "handles" this) +@@ -112,6 +_,13 @@ } topPos = this.createTestWorldButton(topPos, 24); ++ // Neo: Add mods button to title screen ++ if (!SharedConstants.IS_RUNNING_IN_IDE) ++ topPos += 24; // Move down other buttons to make room for mods button (in-dev the test world button "handles" this) ++ int modsOffset = SharedConstants.IS_RUNNING_IN_IDE ? 2 : -100; ++ int modsWidth = SharedConstants.IS_RUNNING_IN_IDE ? 98 : 200; ++ this.addRenderableWidget(new net.neoforged.neoforge.client.gui.widget.ModsButton(Button.builder(Component.translatable("fml.menu.mods"), button -> this.minecraft.setScreen(net.neoforged.neoforge.client.gui.modlist.ModListScreen.create())) ++ .pos(this.width / 2 + modsOffset, topPos).size(modsWidth, 20))); + SpriteIconButton language = this.addRenderableWidget( + CommonButtons.language( + 20, button -> this.minecraft.setScreen(new LanguageSelectScreen(this, this.minecraft.options, this.minecraft.getLanguageManager())), true @@ -158,7 +_,7 @@ Button.builder( Component.literal("Create Test World"), button -> CreateWorldScreen.testWorld(this.minecraft, () -> this.minecraft.setScreen(this)) diff --git a/src/client/java/net/neoforged/neoforge/client/gui/ModListScreen.java b/src/client/java/net/neoforged/neoforge/client/gui/ModListScreen.java deleted file mode 100644 index a0a5df96796..00000000000 --- a/src/client/java/net/neoforged/neoforge/client/gui/ModListScreen.java +++ /dev/null @@ -1,483 +0,0 @@ -/* - * Copyright (c) Forge Development LLC and contributors - * SPDX-License-Identifier: LGPL-2.1-only - */ - -package net.neoforged.neoforge.client.gui; - -import com.mojang.blaze3d.platform.NativeImage; -import com.mojang.blaze3d.systems.RenderSystem; -import com.mojang.blaze3d.textures.AddressMode; -import com.mojang.blaze3d.textures.FilterMode; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.stream.Collectors; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.ActiveTextCollector; -import net.minecraft.client.gui.Font; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.TextAlignment; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.components.LogoRenderer; -import net.minecraft.client.gui.components.ObjectSelectionList; -import net.minecraft.client.gui.narration.NarrationElementOutput; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.client.renderer.texture.DynamicTexture; -import net.minecraft.client.renderer.texture.TextureManager; -import net.minecraft.locale.Language; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.Style; -import net.minecraft.resources.Identifier; -import net.minecraft.server.packs.PackLocationInfo; -import net.minecraft.server.packs.PackResources; -import net.minecraft.server.packs.repository.Pack; -import net.minecraft.server.packs.repository.PackSource; -import net.minecraft.server.packs.resources.IoSupplier; -import net.minecraft.util.FormattedCharSequence; -import net.minecraft.util.StringUtil; -import net.minecraft.util.Util; -import net.neoforged.fml.ModContainer; -import net.neoforged.fml.ModList; -import net.neoforged.fml.VersionChecker; -import net.neoforged.fml.i18n.FMLTranslations; -import net.neoforged.fml.i18n.MavenVersionTranslator; -import net.neoforged.fml.loading.FMLPaths; -import net.neoforged.neoforge.client.gui.widget.ModListWidget; -import net.neoforged.neoforge.client.gui.widget.ScrollPanel; -import net.neoforged.neoforge.common.CommonHooks; -import net.neoforged.neoforge.common.util.Size2i; -import net.neoforged.neoforge.resource.ResourcePackLoader; -import net.neoforged.neoforgespi.language.IModInfo; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.maven.artifact.versioning.ComparableVersion; -import org.jspecify.annotations.Nullable; - -public class ModListScreen extends Screen { - private static String stripControlCodes(String value) { - return StringUtil.stripColor(value); - } - - private static final Logger LOGGER = LogManager.getLogger(); - - private enum SortType implements Comparator { - NORMAL, - A_TO_Z { - @Override - protected int compare(String name1, String name2) { - return name1.compareTo(name2); - } - }, - Z_TO_A { - @Override - protected int compare(String name1, String name2) { - return name2.compareTo(name1); - } - }; - - Button button; - - protected int compare(String name1, String name2) { - return 0; - } - - @Override - public int compare(ModContainer o1, ModContainer o2) { - String name1 = stripControlCodes(o1.getModInfo().getDisplayName()).toLowerCase(Locale.ROOT); - String name2 = stripControlCodes(o2.getModInfo().getDisplayName()).toLowerCase(Locale.ROOT); - return compare(name1, name2); - } - - Component getButtonText() { - return Component.translatable("fml.menu.mods." + name().toLowerCase(Locale.ROOT)); - } - } - - private static final int PADDING = 6; - - private Screen parentScreen; - - private ModListWidget modList; - private InfoPanel modInfo; - private ModListWidget.ModEntry selected = null; - private int listWidth; - private List mods; - private final List unsortedMods; - private Button configButton, openModsFolderButton, doneButton; - - private int buttonMargin = 1; - private int numButtons = SortType.values().length; - private String lastFilterText = ""; - - private EditBox search; - - private boolean sorted = false; - private SortType sortType = SortType.NORMAL; - - public ModListScreen(Screen parentScreen) { - super(Component.translatable("fml.menu.mods.title")); - this.parentScreen = parentScreen; - this.unsortedMods = Collections.unmodifiableList(ModList.get().getSortedMods()); - this.mods = this.unsortedMods; - } - - class InfoPanel extends ScrollPanel { - @Nullable - private Identifier logoPath; - private Size2i logoDims = new Size2i(0, 0); - private List lines = Collections.emptyList(); - - InfoPanel(Minecraft mcIn, int widthIn, int heightIn, int topIn) { - super(mcIn, widthIn, heightIn, topIn, modList.getRight() + PADDING); - } - - void setInfo(List lines, Identifier logoPath, Size2i logoDims) { - this.logoPath = logoPath; - this.logoDims = logoDims; - this.lines = resizeContent(lines); - } - - void clearInfo() { - this.logoPath = null; - this.logoDims = new Size2i(0, 0); - this.lines = Collections.emptyList(); - } - - private List resizeContent(List lines) { - List ret = new ArrayList<>(); - for (String line : lines) { - if (line == null) { - ret.add(null); - continue; - } - - Component chat = CommonHooks.newChatWithLinks(line, false); - int maxTextLength = this.width - 12; - if (maxTextLength >= 0) { - ret.addAll(Language.getInstance().getVisualOrder(font.getSplitter().splitLines(chat, maxTextLength, Style.EMPTY))); - } - } - return ret; - } - - @Override - public int getContentHeight() { - int height = 50; - height += (lines.size() * font.lineHeight); - if (height < this.bottom - this.top - 8) - height = this.bottom - this.top - 8; - return height; - } - - @Override - protected int getScrollAmount() { - return font.lineHeight * 3; - } - - @Override - protected void drawPanel(GuiGraphicsExtractor guiGraphics, int entryRight, int relativeY, int mouseX, int mouseY) { - if (logoPath != null) { - // Draw the logo image inscribed in a rectangle with width entryWidth (minus some padding) and height 50 - int headerHeight = 50; - guiGraphics.blitInscribed(logoPath, left + PADDING, relativeY, width - (PADDING * 2), headerHeight, logoDims.width, logoDims.height, false, true); - relativeY += headerHeight + PADDING; - } - - for (FormattedCharSequence line : lines) { - if (line != null) { - guiGraphics.text(ModListScreen.this.font, line, left + PADDING, relativeY, 0xFFFFFFFF); - } - relativeY += font.lineHeight; - } - - final Style component = findTextLine(mouseX, mouseY); - if (component != null) { - guiGraphics.componentHoverEffect(ModListScreen.this.font, component, mouseX, mouseY); - } - } - - @Nullable - private Style findTextLine(final int mouseX, final int mouseY) { - if (!isMouseOver(mouseX, mouseY)) - return null; - - double offset = (mouseY - top - PADDING - border) + scrollDistance; - if (logoPath != null) { - offset -= 50; - } - if (offset <= 0) - return null; - - int lineIdx = (int) (offset / font.lineHeight); - if (lineIdx >= lines.size() || lineIdx < 0) - return null; - - FormattedCharSequence line = lines.get(lineIdx); - if (line != null) { - var styleFinder = new ActiveTextCollector.ClickableStyleFinder( - // TODO 1.21.11: The calculatin of Y needs to be validated, it should be relative to the vertical line origin - font, mouseX - left - border - 1, (int) (offset - (lineIdx * font.lineHeight))); - styleFinder.accept(TextAlignment.LEFT, 0, 0, line); - return styleFinder.result(); - } - return null; - } - - @Override - public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { - final Style component = findTextLine((int) event.x(), (int) event.y()); - if (component != null && component.getClickEvent() != null) { - defaultHandleGameClickEvent(component.getClickEvent(), minecraft, ModListScreen.this); - return true; - } - return super.mouseClicked(event, doubleClick); - } - - @Override - public NarrationPriority narrationPriority() { - return NarrationPriority.NONE; - } - - @Override - public void updateNarration(NarrationElementOutput output) {} - } - - @Override - public void init() { - for (var mod : mods) { - listWidth = Math.max(listWidth, getFontRenderer().width(mod.getModInfo().getDisplayName()) + 10); - listWidth = Math.max(listWidth, getFontRenderer().width(MavenVersionTranslator.artifactVersionToString(mod.getModInfo().getVersion())) + 5); - } - listWidth = Math.max(Math.min(listWidth, width / 3), 100); - listWidth += listWidth % numButtons != 0 ? (numButtons - listWidth % numButtons) : 0; - - int modInfoWidth = this.width - this.listWidth - (PADDING * 3); - int doneButtonWidth = Math.min(modInfoWidth, 200); - int y = this.height - 20 - PADDING; - int fullButtonHeight = PADDING + 20 + PADDING; - - doneButton = Button.builder(Component.translatable("gui.done"), b -> ModListScreen.this.onClose()).bounds(((listWidth + PADDING + this.width - doneButtonWidth) / 2), y, doneButtonWidth, 20).build(); - openModsFolderButton = Button.builder(Component.translatable("fml.menu.mods.openmodsfolder"), b -> Util.getPlatform().openFile(FMLPaths.MODSDIR.get().toFile())).bounds(6, y, this.listWidth, 20).build(); - y -= 20 + PADDING; - configButton = Button.builder(Component.translatable("fml.menu.mods.config"), b -> ModListScreen.this.displayModConfig()).bounds(6, y, this.listWidth, 20).build(); - y -= 14 + PADDING; - search = new EditBox(getFontRenderer(), PADDING, y, listWidth, 14, Component.translatable("fml.menu.mods.search")); - - this.modList = new ModListWidget(this, listWidth, fullButtonHeight, search.getY() - getFontRenderer().lineHeight - PADDING); - this.modList.setX(6); - this.modInfo = new InfoPanel(this.minecraft, modInfoWidth, this.height - PADDING - fullButtonHeight, PADDING); - - this.addRenderableWidget(modList); - this.addRenderableWidget(modInfo); - this.addRenderableWidget(search); - this.addRenderableWidget(doneButton); - this.addRenderableWidget(configButton); - this.addRenderableWidget(openModsFolderButton); - - search.setFocused(false); - search.setCanLoseFocus(true); - configButton.active = false; - - final int width = listWidth / numButtons; - int x = PADDING; - addRenderableWidget(SortType.NORMAL.button = Button.builder(SortType.NORMAL.getButtonText(), b -> resortMods(SortType.NORMAL)).bounds(x, PADDING, width - buttonMargin, 20).build()); - x += width + buttonMargin; - addRenderableWidget(SortType.A_TO_Z.button = Button.builder(SortType.A_TO_Z.getButtonText(), b -> resortMods(SortType.A_TO_Z)).bounds(x, PADDING, width - buttonMargin, 20).build()); - x += width + buttonMargin; - addRenderableWidget(SortType.Z_TO_A.button = Button.builder(SortType.Z_TO_A.getButtonText(), b -> resortMods(SortType.Z_TO_A)).bounds(x, PADDING, width - buttonMargin, 20).build()); - resortMods(SortType.NORMAL); - updateCache(); - } - - private void displayModConfig() { - if (selected == null) return; - try { - IConfigScreenFactory.getForMod(selected.getInfo()).map(f -> f.createScreen(selected.getContainer(), this)).ifPresent(newScreen -> this.minecraft.setScreen(newScreen)); - } catch (final Exception e) { - LOGGER.error("There was a critical issue trying to build the config GUI for {}", selected.getInfo().getModId(), e); - } - } - - @Override - public void tick() { - if (modList.getSelected() != selected) { - modList.setSelected(selected); - } - - if (!search.getValue().equals(lastFilterText)) { - reloadMods(); - sorted = false; - } - - if (!sorted) { - reloadMods(); - mods.sort(sortType); - modList.refreshList(); - if (selected != null) { - selected = modList.children().stream().filter(e -> e.getInfo() == selected.getInfo()).findFirst().orElse(null); - updateCache(); - } - sorted = true; - } - } - - public > void buildModList(Consumer modListViewConsumer, Function newEntry) { - mods.forEach(mod -> modListViewConsumer.accept(newEntry.apply(mod))); - } - - private void reloadMods() { - this.mods = this.unsortedMods.stream().filter(mi -> stripControlCodes(mi.getModInfo().getDisplayName()).toLowerCase(Locale.ROOT).contains(search.getValue().toLowerCase(Locale.ROOT))).collect(Collectors.toCollection(ArrayList::new)); - lastFilterText = search.getValue(); - } - - private void resortMods(SortType newSort) { - this.sortType = newSort; - - for (SortType sort : SortType.values()) { - if (sort.button != null) - sort.button.active = sortType != sort; - } - sorted = false; - } - - @Override - public void extractRenderState(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, float partialTick) { - super.extractRenderState(guiGraphics, mouseX, mouseY, partialTick); - Component text = Component.translatable("fml.menu.mods.search"); - int x = modList.getX() + ((modList.getRight() - modList.getX()) / 2) - (getFontRenderer().width(text) / 2); - guiGraphics.text(getFontRenderer(), text.getVisualOrderText(), x, search.getY() - getFontRenderer().lineHeight, 0xFFFFFFFF, false); - } - - public Minecraft getMinecraftInstance() { - return minecraft; - } - - public Font getFontRenderer() { - return font; - } - - public void setSelected(ModListWidget.ModEntry entry) { - this.selected = entry; - updateCache(); - } - - private void updateCache() { - if (selected == null) { - this.configButton.active = false; - this.modInfo.clearInfo(); - return; - } - IModInfo selectedMod = selected.getInfo(); - this.configButton.active = IConfigScreenFactory.getForMod(selectedMod).isPresent(); - List lines = new ArrayList<>(); - VersionChecker.CheckResult vercheck = VersionChecker.getResult(selectedMod); - - @SuppressWarnings("resource") - Pair logoData; - - if (selectedMod.getModId().equals(Identifier.DEFAULT_NAMESPACE)) { - logoData = Pair.of(LogoRenderer.MINECRAFT_LOGO, new Size2i(LogoRenderer.LOGO_TEXTURE_WIDTH, LogoRenderer.LOGO_TEXTURE_HEIGHT)); - } else { - logoData = selectedMod.getLogoFile().map(logoFile -> { - TextureManager tm = this.minecraft.getTextureManager(); - final Pack.ResourcesSupplier resourcePack = ResourcePackLoader.getPackFor(selectedMod.getModId()) - .orElse(ResourcePackLoader.getPackFor("neoforge").orElseThrow(() -> new RuntimeException("Can't find neoforge, WHAT!"))); - try (PackResources packResources = resourcePack.openPrimary(new PackLocationInfo("mod/" + selectedMod.getModId(), Component.empty(), PackSource.BUILT_IN, Optional.empty()))) { - NativeImage logo = null; - IoSupplier logoResource = packResources.getRootResource(logoFile.split("[/\\\\]")); - if (logoResource != null) - logo = NativeImage.read(logoResource.get()); - if (logo != null) { - var textureId = Identifier.fromNamespaceAndPath("neoforge", "modlogo"); - tm.register(textureId, new DynamicTexture(textureId::toString, logo) { - @Override - public void upload() { - // Use custom "blur" value which controls texture filtering (nearest-neighbor vs linear) - // TODO 1.21.11: Unclear if this is the best way of setting linear/nearest filtering - var filter = selectedMod.getLogoBlur() ? FilterMode.LINEAR : FilterMode.NEAREST; - sampler = RenderSystem.getSamplerCache().getSampler(AddressMode.CLAMP_TO_EDGE, AddressMode.CLAMP_TO_EDGE, filter, filter, false); - super.upload(); - } - }); - - return Pair.of(textureId, new Size2i(logo.getWidth(), logo.getHeight())); - } - } catch (IOException | IllegalArgumentException e) {} - return Pair.of(null, new Size2i(0, 0)); - }).orElse(Pair.of(null, new Size2i(0, 0))); - } - - lines.add(selectedMod.getDisplayName()); - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.version", MavenVersionTranslator.artifactVersionToString(selectedMod.getVersion()))); - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.idstate", selectedMod.getModId(), "LOADED")); // TODO: remove mod loading stages from here too - - // Normalizing line endings to LF because it is currently not automatically handled for us. Descriptions are already normalized. - selectedMod.getConfig().getConfigElement("credits").ifPresent(credits -> lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.credits", credits).replace("\r\n", "\n"))); - selectedMod.getConfig().getConfigElement("authors").ifPresent(authors -> lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.authors", authors).replace("\r\n", "\n"))); - selectedMod.getConfig().getConfigElement("displayURL").ifPresent(displayURL -> lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.displayurl", displayURL).replace("\r\n", "\n"))); - if (selectedMod.getOwningFile() == null || selectedMod.getOwningFile().getMods().size() == 1) - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.nochildmods")); - else - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.childmods", selectedMod.getOwningFile().getMods().stream().map(IModInfo::getDisplayName).collect(Collectors.joining(",")))); - - if (vercheck.status() == VersionChecker.Status.OUTDATED || vercheck.status() == VersionChecker.Status.BETA_OUTDATED) - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.updateavailable", vercheck.url() == null ? "" : vercheck.url()).replace("\r\n", "\n")); - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.license", selectedMod.getOwningFile().getLicense()).replace("\r\n", "\n")); - lines.add(null); - lines.add(FMLTranslations.getPattern("fml.menu.mods.info.description." + selectedMod.getModId(), selectedMod::getDescription)); - - /* Removed because people bitched that this information was misleading. - lines.add(null); - if (FMLEnvironment.secureJarsEnabled) { - lines.add(ForgeI18getOwningFile().getFile().n.parseMessage("fml.menu.mods.info.signature", selectedMod.getOwningFile().getCodeSigningFingerprint().orElse(ForgeI18n.parseMessage("fml.menu.mods.info.signature.unsigned")))); - lines.add(ForgeI18n.parseMessage("fml.menu.mods.info.trust", selectedMod.getOwningFile().getTrustData().orElse(ForgeI18n.parseMessage("fml.menu.mods.info.trust.noauthority")))); - } else { - lines.add(ForgeI18n.parseMessage("fml.menu.mods.info.securejardisabled")); - } - */ - - if ((vercheck.status() == VersionChecker.Status.OUTDATED || vercheck.status() == VersionChecker.Status.BETA_OUTDATED) && vercheck.changes().size() > 0) { - lines.add(null); - lines.add(FMLTranslations.parseMessage("fml.menu.mods.info.changelogheader")); - for (Entry entry : vercheck.changes().entrySet()) { - lines.add(" " + entry.getKey() + ":"); - lines.add(entry.getValue()); - lines.add(null); - } - } - - modInfo.setInfo(lines, logoData.getLeft(), logoData.getRight()); - } - - @Override - public void resize(int width, int height) { - String s = this.search.getValue(); - SortType sort = this.sortType; - ModListWidget.ModEntry selected = this.selected; - this.init(width, height); - this.search.setValue(s); - this.selected = selected; - if (!this.search.getValue().isEmpty()) - reloadMods(); - if (sort != SortType.NORMAL) - resortMods(sort); - updateCache(); - } - - @Override - public void onClose() { - this.minecraft.setScreen(this.parentScreen); - } -} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/BlankModDisplayInfo.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/BlankModDisplayInfo.java new file mode 100644 index 00000000000..6feea0679d0 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/BlankModDisplayInfo.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import java.net.URI; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/// A blank [mod display info][ModDisplayInfo]. +final class BlankModDisplayInfo implements ModDisplayInfo { + public static final BlankModDisplayInfo INSTANCE = new BlankModDisplayInfo(); + + private BlankModDisplayInfo() {} + + @Override + public String id() { + return ""; + } + + @Override + public Component displayName() { + return Component.empty(); + } + + @Override + public String version() { + return ""; + } + + @Override + public Component authors() { + return Component.empty(); + } + + @Override + public Component credits() { + return Component.empty(); + } + + @Override + public Component description() { + return Component.empty(); + } + + @Override + public Component license() { + return Component.empty(); + } + + @Override + public @Nullable ImageResource logo() { + return null; + } + + @Override + public @Nullable ImageResource icon() { + return null; + } + + @Override + public @Nullable URI displayUrl() { + return null; + } + + @Override + public @Nullable URI issuesUrl() { + return null; + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/ChangelogScreen.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ChangelogScreen.java new file mode 100644 index 00000000000..6110df20884 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ChangelogScreen.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import static net.minecraft.network.chat.Component.translatable; + +import com.mojang.logging.LogUtils; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.FocusableTextWidget; +import net.minecraft.client.gui.components.ScrollableLayout; +import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.neoforged.fml.VersionChecker; +import org.apache.maven.artifact.versioning.ComparableVersion; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +@ApiStatus.Internal +class ChangelogScreen extends Screen { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final @Nullable Screen lastScreen; + private final ModDisplayInfo info; + private final VersionChecker.CheckResult checkResult; + + @Nullable + private HeaderAndFooterLayout layout; + + protected ChangelogScreen(@Nullable Screen lastScreen, ModDisplayInfo info, VersionChecker.CheckResult checkResult) { + super(Component.translatable("neoforge.screen.mods.changelog.title", info.displayName())); + this.lastScreen = lastScreen; + this.info = info; + this.checkResult = checkResult; + } + + @Override + protected void init() { + super.init(); + + this.layout = new HeaderAndFooterLayout(this); + this.layout.addTitleHeader(Component.translatable("neoforge.screen.mods.changelog.title", info.displayName()), this.font); + + // Footer + final LinearLayout footer = layout.addToFooter(new LinearLayout(0, 0, LinearLayout.Orientation.HORIZONTAL)); + footer.spacing(4).defaultCellSetting().paddingTop(5); + + final Button updateSiteButton = footer.addChild(Button.builder(translatable("neoforge.screen.mods.changelog.open_site"), + _ -> clickUrlAction(minecraft, this, URI.create(this.checkResult.url()))).build()); + updateSiteButton.active = false; + if (checkResult.url() != null) { + String rawUrl = checkResult.url(); + try { + new URI(rawUrl); + updateSiteButton.active = true; + } catch (URISyntaxException exception) { + LOGGER.warn("Failed to create update site URI for mod {} update checker: {}", info.id(), rawUrl); + } + } + footer.addChild(Button.builder(CommonComponents.GUI_BACK, _ -> this.onClose()).build()); + + // Contents + final LinearLayout body = new LinearLayout(0, 0, LinearLayout.Orientation.VERTICAL).spacing(4); + + if (checkResult.changes().isEmpty()) { + body.addChild(FocusableTextWidget.builder(Component.translatable("neoforge.screen.mods.changelog.no_changelog").withStyle(ChatFormatting.ITALIC), this.font) + .alwaysShowBorder(false) + .backgroundFill(FocusableTextWidget.BackgroundFill.NEVER) + .maxWidth(310) + .build() + .setCentered(false)); + } else { + for (Map.Entry updateEntry : checkResult.changes().entrySet()) { + body.addChild(FocusableTextWidget.builder(Component.translatable("neoforge.screen.mods.changelog.entry", + Component.literal(updateEntry.getKey().toString()).withStyle(ChatFormatting.BOLD), + Component.literal(updateEntry.getValue())), + this.font) + .alwaysShowBorder(false) + .backgroundFill(FocusableTextWidget.BackgroundFill.NEVER) + .maxWidth(310) + .build() + .setCentered(false)); + } + } + + layout.addToContents(new ScrollableLayout(this.minecraft, body, this.layout.getContentHeight())).setMinWidth(310); + + this.layout.visitWidgets(this::addRenderableWidget); + this.repositionElements(); + } + + @Override + protected void repositionElements() { + assert this.layout != null; + this.layout.arrangeElements(); + } + + @Override + public void onClose() { + this.minecraft.setScreen(lastScreen); + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/ConfigurationScreenFactory.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ConfigurationScreenFactory.java new file mode 100644 index 00000000000..ca1992b409d --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ConfigurationScreenFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import java.util.function.UnaryOperator; +import net.minecraft.client.gui.screens.Screen; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.client.gui.IConfigScreenFactory; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.VisibleForTesting; +import org.jspecify.annotations.Nullable; + +/// Configuration screen factory used by the mod list screen. For internal use only. +@FunctionalInterface +@ApiStatus.Internal +@VisibleForTesting +public interface ConfigurationScreenFactory { + // unary operator takes in previous screen (to return to later) + @Nullable + UnaryOperator create(ModDisplayInfo displayInfo); + + ConfigurationScreenFactory DEFAULT = displayInfo -> { + final ModContainer container = ModList.get().getModContainerById(displayInfo.id()).orElse(null); + if (container == null) return null; + + final IConfigScreenFactory factory = container.getCustomExtension(IConfigScreenFactory.class).orElse(null); + if (factory == null) return null; + + return parentScreen -> factory.createScreen(container, parentScreen); + }; +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/DefaultModDisplayInfo.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/DefaultModDisplayInfo.java new file mode 100644 index 00000000000..9fa3a8cf28f --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/DefaultModDisplayInfo.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import com.mojang.logging.LogUtils; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Objects; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.resources.Identifier; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.ModList; +import net.neoforged.fml.i18n.FMLTranslations; +import net.neoforged.neoforge.resource.ResourcePackLoader; +import net.neoforged.neoforgespi.language.IModFileInfo; +import net.neoforged.neoforgespi.locating.IModFile; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +/// The default implementation of [ModDisplayInfo]. +public class DefaultModDisplayInfo implements ModDisplayInfo { + private static final Logger LOGGER = LogUtils.getLogger(); + + private final ModContainer container; + + public DefaultModDisplayInfo(ModContainer container) { + this.container = container; + } + + /// {@inheritDoc} + @Override + public String id() { + return container.getModId(); + } + + /// {@inheritDoc} + @Override + public Component displayName() { + return Component.literal(container.getModInfo().getDisplayName()); + } + + /// {@inheritDoc} + @Override + public String version() { + return container.getModInfo().getVersion().toString(); + } + + /// {@inheritDoc} This pulls from the `authors` key of the mod info. + @Override + public Component authors() { + return container.getModInfo().getConfig().getConfigElement("authors") + .map(Component::literal) + .orElseGet(Component::empty); + } + + /// {@inheritDoc} This pulls from the `credits` key of the mod info. + @Override + public Component credits() { + return container.getModInfo().getConfig().getConfigElement("credits") + .map(Component::literal) + .orElseGet(Component::empty); + } + + /// {@inheritDoc} This uses the translation key `neoforge.screen.mods.info.description.[modid]` if available, where `[modid]` is the [mod ID][#id()], with a fallback to the `description` key of the mod info. + @Override + public Component description() { + //noinspection UnstableApiUsage + return Component.translatable(FMLTranslations.getPattern("neoforge.screen.mods.info.description." + id(), container.getModInfo()::getDescription)); + } + + /// {@inheritDoc} This uses the `license` key of the mod file info. If the `licenseURL` key of the mod file info is set and is a valid URL, then this component has an [open URL click action][ClickEvent.Action#OPEN_URL] with that URL. + @Override + public Component license() { + MutableComponent licenseText = Component.literal(container.getModInfo().getOwningFile().getLicense()); + if (container.getModInfo().getOwningFile().getConfig().getConfigElement("licenseURL").orElse(null) instanceof String licenseURL) { + try { + final URI uri = new URI(licenseURL); + return licenseText.withStyle(style -> style + .withUnderlined(true) + .withClickEvent(new ClickEvent.OpenUrl(uri))); + } catch (URISyntaxException e) { + LOGGER.warn("Failed to create license URL {} for mod ID {}", licenseURL, id()); + } + } + return licenseText; + } + + /// {@inheritDoc} This uses the `logoFile` key of the mod file info or, if not available, the mod info. + /// + /// @see #convertPath(String) + @Override + @Nullable + public ImageResource logo() { + return container.getModInfo().getLogoFile().map(this::convertPath).orElse(null); + } + + /// {@inheritDoc} This uses the `iconFile` key of the mod file info or, if not available, the mod info. + /// + /// @see #convertPath(String) + @Override + @Nullable + public ImageResource icon() { + if (container.getModInfo().getConfig().getConfigElement("iconFile") + .or(() -> container.getModInfo().getOwningFile().getConfig().getConfigElement("iconFile")) + .orElse(null) instanceof String iconFile) { + return convertPath(iconFile); + } + return null; + } + + /// Converts a given path string into an [ImageResource]. + /// + /// The logic for converting a path is as follows, in order: + /// - If the path contains a pound sign (`#`), it is taken as a [root resource][ImageResource#packRoot(String, String)] with the parts before and after the pound sign taken as the pack ID and the resource path, respectively. + /// - If the path has a colon (`:`), is it taken as a [pack asset][ImageResource#packAsset(Identifier)]. + /// - Otherwise, it is assumed to be a root resource with the mod's resource pack as determined by [ResourcePackLoader#getPackName(IModFile)]. + /// + /// @param path the path to convert + /// @return the image resource + public ImageResource convertPath(String path) { + if (path.indexOf('#') > 0) { + // Contains a pound sign -- it's a root resource, with parts of "#" + String[] split = path.split("#", 2); + return ImageResource.packRoot(split[0], split[1]); + } else if (path.indexOf(Identifier.NAMESPACE_SEPARATOR) > 0) { + // Contains a colon, therefore an identifier -- it's a pack resource + return ImageResource.packAsset(Identifier.parse(path)); + } else { + // It's a root resource; get from the mod's resource pack + IModFileInfo modFileInfo = ModList.get().getModFileById(id()); + String packId = ResourcePackLoader.getPackName(modFileInfo.getFile()); + return ImageResource.packRoot(packId, path); + } + } + + /// {@inheritDoc} This uses the `displayURL` or, if not available, the `modUrl` key from the mod info. + @Override + @Nullable + public URI displayUrl() { + return container.getModInfo().getConfig().getConfigElement("displayURL") + .or(() -> container.getModInfo().getConfig().getConfigElement("modUrl")) + .map(URI::create) + .orElse(null); + } + + /// {@inheritDoc} This uses the `issueTrackerURL` key of the mod file info or, if not available, the mod info. + @Override + @Nullable + public URI issuesUrl() { + return container.getModInfo().getConfig().getConfigElement("issueTrackerURL") + .or(() -> container.getModInfo().getOwningFile().getConfig().getConfigElement("issueTrackerURL")) + .map(URI::create) + .orElse(null); + } + + public ModContainer container() { + return container; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (DefaultModDisplayInfo) obj; + return Objects.equals(this.container, that.container); + } + + @Override + public int hashCode() { + return Objects.hash(container); + } + + @Override + public String toString() { + return "DefaultModDisplayInfo[" + container.getModId() + ']'; + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/ImageResource.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ImageResource.java new file mode 100644 index 00000000000..5c4acce346d --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ImageResource.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import java.io.InputStream; +import java.util.regex.Pattern; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.resources.IoSupplier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; + +/// An image resource. This is primarily used for [mod display info][ModDisplayInfo]. +/// +/// @see #packRoot(String, String) +/// @see #packAsset(Identifier) +public sealed interface ImageResource { + /// Retrieves the image resource contents from the given resource manager. If the resource does not exist, this returns `null`. + /// + /// @param resourceManager the resource manager, which is expected to contain client-side resource packs + /// @return the supplier for the image bytes + @Nullable + IoSupplier get(ResourceManager resourceManager); + + /// Creates an image resource that exists within a specific pack and path. + /// + /// The image resource is always tied to the specific pack ID, and is unaffected by any another pack providing a resource at the same path. + /// + /// @param packId the pack ID + /// @param path the path to the image resource + /// @return an image resource for the given pack and path + static ImageResource packRoot(String packId, String path) { + return new PackRoot(packId, path); + } + + /// Creates an image resource for a given location, which is searched among the active packs. + /// + /// The image resource will be searched in the active resource manager, and is thus affected by resource pack ordering, filtering, + /// and overriding of resources. + static ImageResource packAsset(Identifier id) { + return new PackAsset(id); + } + + @ApiStatus.Internal + record PackRoot(String packId, String path) implements ImageResource { + private static final Pattern PATH_SPLITTER = Pattern.compile("[/\\\\]"); + + public @Nullable IoSupplier get(ResourceManager resourceManager) { + // We are not responsible for closing the PackResources + //noinspection resource + PackResources packResources = resourceManager.listPacks().filter(resource -> resource.packId().equals(packId)).findAny().orElse(null); + if (packResources == null) return null; + return packResources.getRootResource(PATH_SPLITTER.split(path)); + } + + @Override + public String toString() { + return packId + "[" + path + "]"; + } + } + + @ApiStatus.Internal + record PackAsset(Identifier path) implements ImageResource { + @Override + public @Nullable IoSupplier get(ResourceManager resourceManager) { + Resource resource = resourceManager.getResource(path).orElse(null); + if (resource == null) return null; + return resource::open; + } + + @Override + public String toString() { + return path.toString(); + } + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/ModDisplayInfo.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ModDisplayInfo.java new file mode 100644 index 00000000000..c0304c03193 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ModDisplayInfo.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import java.net.URI; +import net.minecraft.network.chat.Component; +import net.neoforged.fml.IExtensionPoint; +import net.neoforged.neoforge.client.gui.IConfigScreenFactory; +import org.jspecify.annotations.Nullable; + +/// An extension point for information displayed on the [mod list screen][ModListScreen]. +/// +/// Unless otherwise stated in the documentation, provided components may contain [click actions][net.minecraft.network.chat.ClickEvent] +/// and [hover actions][net.minecraft.network.chat.HoverEvent]. +/// +/// @see IConfigScreenFactory Extension point for custom configuration screens +public interface ModDisplayInfo extends IExtensionPoint { + /// {@return the mod ID} This is used to retrieve the mod container, [configuration screen extension point][IConfigScreenFactory], + /// and other relevant information. + String id(); + + /// {@return the display name} This is always displayed, even if [empty][Component#empty()]. + /// Click actions do not work on this component. + Component displayName(); + + /// {@return the mod version} This is always displayed, even if [empty][Component#empty()]. + String version(); + + /// {@return the mod authors} This is displayed if it is not [an empty component][Component#empty()]. + Component authors(); + + /// {@return the credits} This is displayed if it is not [an empty component][Component#empty()]. + Component credits(); + + /// {@return the mod authors} This is displayed if it is not [an empty component][Component#empty()]. + Component description(); + + /// {@return the mod license} This is always displayed, even if [empty][Component#empty()]. + Component license(); + + /// {@return the logo displayed in the info panel, or `null`} + /// + /// The logo is rendered with its original aspect ratio, bounded by the width of the info panel (ordinarily + /// {@value ModListScreen#INFO_PANEL_WIDTH} pixels) and a maximum height of {@value ModListScreen#LOGO_HEIGHT} pixels. + @Nullable + ImageResource logo(); // rendered as rectangle + + /// {@return the icon displayed in the mod list, or `null`} + /// + /// The icon is rendered as a square with sides of {@value ModListScreen#ICON_SIZE} pixels. + @Nullable + ImageResource icon(); // rendered as a square + + /// {@return the URL for the mod homepage, or `null`} If `null`, the homepage button is disabled. + @Nullable + URI displayUrl(); + + /// {@return the URL for the mod issues page, or `null`} If `null`, the issues page button is disabled. + @Nullable + URI issuesUrl(); +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/ModListScreen.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ModListScreen.java new file mode 100644 index 00000000000..d9f6101e6f2 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/ModListScreen.java @@ -0,0 +1,743 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import static net.minecraft.network.chat.Component.translatable; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableList.Builder; +import com.mojang.blaze3d.platform.NativeImage; +import com.mojang.logging.LogUtils; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Path; +import java.time.Month; +import java.time.MonthDay; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractStringWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.FocusableTextWidget; +import net.minecraft.client.gui.components.ImageWidget; +import net.minecraft.client.gui.components.LogoRenderer; +import net.minecraft.client.gui.components.MultiLineTextWidget; +import net.minecraft.client.gui.components.ObjectSelectionList; +import net.minecraft.client.gui.components.ScrollableLayout; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.layouts.EqualSpacingLayout; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.GridLayout; +import net.minecraft.client.gui.layouts.GridLayout.RowHelper; +import net.minecraft.client.gui.layouts.HeaderAndFooterLayout; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.layouts.LinearLayout.Orientation; +import net.minecraft.client.gui.layouts.SpacerElement; +import net.minecraft.client.gui.screens.ConfirmLinkScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.client.renderer.texture.MissingTextureAtlasSprite; +import net.minecraft.client.renderer.texture.TextureManager; +import net.minecraft.locale.Language; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.contents.PlainTextContents; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.IoSupplier; +import net.minecraft.util.ARGB; +import net.minecraft.util.CommonLinks; +import net.minecraft.util.SpecialDates; +import net.minecraft.util.Util; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.ModList; +import net.neoforged.fml.VersionChecker; +import net.neoforged.fml.loading.FMLConfig; +import net.neoforged.fml.loading.FMLEnvironment; +import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.neoforge.client.gui.widget.BackgroundWithPipingWidget; +import net.neoforged.neoforge.client.gui.widget.ResizableTextureImageWidget; +import net.neoforged.neoforge.client.gui.widget.SolidColorWidget; +import net.neoforged.neoforge.common.NeoForgeMod; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; + +@ApiStatus.Internal +public class ModListScreen extends Screen { + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final int SIDEBAR_CONTROLS_WIDTH = 150; + private static final int SIDEBAR_SORT_BUTTON_WIDTH = 50; + private static final int SIDEBAR_CONTROLS_HEIGHT = Button.DEFAULT_HEIGHT; + private static final int SIDEBAR_MODS_LIST_WIDTH = 150; + static final int INFO_PANEL_WIDTH = 250; + private static final int INFO_PANEL_FRAME_PADDING = 2; + static final int ICON_SIZE = 24; + static final int LOGO_HEIGHT = 50; + + private final ImmutableList mods; + private final Path modsFolder; + private final ConfigurationScreenFactory configFactory; + private final VersionCheckResultSupplier versionCheck; + + private final List allEntries; + private SortType currentSort = SortType.A_TO_Z; + + @Nullable + private HeaderAndFooterLayout layout; + @Nullable + private EqualSpacingLayout fixedSidebarLayout; + @Nullable + private ModsList displayList; + private ModListScreen.@Nullable ModInfoPanel displayPanel; + @Nullable + private EditBox search; + + public static ModListScreen create() { + final Builder mods = ImmutableList.builder(); + + for (ModContainer container : ModList.get().getSortedMods()) { + final ModDisplayInfo displayInfo; + final String modId = container.getModId(); + if (modId.equalsIgnoreCase("minecraft") && !FMLEnvironment.isProduction()) { + // This is a special case in development because the Minecraft mods.toml information is hardcoded in FML + // TODO: remove in the future once FML is updated to match + displayInfo = new DefaultModDisplayInfo(container) { + @Override + public ImageResource icon() { + return ImageResource.packRoot("vanilla", "pack.png"); + } + + @Override + public ImageResource logo() { + return ImageResource.packAsset(LogoRenderer.MINECRAFT_LOGO); + } + + @Override + public Component authors() { + return Component.literal("Mojang Studios"); + } + + @Override + public Component license() { + return Component.literal("Minecraft EULA").withStyle(style -> style + .withUnderlined(true) + .withClickEvent(new ClickEvent.OpenUrl(CommonLinks.EULA))); + } + + @Override + public URI displayUrl() { + return URI.create("https://minecraft.net"); + } + + @Override + public URI issuesUrl() { + return URI.create("https://bugs.mojang.com"); + } + + @Override + public Component description() { + return Component.empty(); + } + }; + } else { + displayInfo = container.getCustomExtension(ModDisplayInfo.class) + .orElseGet(() -> new DefaultModDisplayInfo(container)); + } + mods.add(displayInfo); + } + + return new ModListScreen(mods.build(), FMLPaths.MODSDIR.get(), ConfigurationScreenFactory.DEFAULT, VersionCheckResultSupplier.DEFAULT); + } + + private ModListScreen(ImmutableList mods, Path modsFolder, ConfigurationScreenFactory configFactory, VersionCheckResultSupplier versionCheck) { + super(translatable("neoforge.screen.mods.title")); + this.mods = mods; + this.modsFolder = modsFolder; + this.allEntries = new ArrayList<>(mods.size()); + this.configFactory = configFactory; + this.versionCheck = versionCheck; + } + + @Override + protected void init() { + super.init(); + this.layout = new HeaderAndFooterLayout(this, 8, 38); + + // Footer + final LinearLayout footer = layout.addToFooter(new LinearLayout(0, 0, Orientation.HORIZONTAL)); + footer.spacing(4).defaultCellSetting().paddingTop(5); + + footer.addChild(Button.builder(Component.translatable("neoforge.screen.mods.button.open_folder"), + _ -> Util.getPlatform().openPath(modsFolder)).build()); + footer.addChild(Button.builder(CommonComponents.GUI_BACK, _ -> ModListScreen.this.onClose()).build()); + + // Content + final GridLayout contentBase = layout.addToContents(new GridLayout()).columnSpacing(-3); + contentBase.defaultCellSetting().alignVerticallyTop().alignHorizontallyCenter().padding(0); + final RowHelper contentBaseHelper = contentBase.createRowHelper(3); + + final LinearLayout sidebar = contentBaseHelper.addChild(LinearLayout.vertical(), contentBaseHelper.newCellSettings().paddingVertical(-INFO_PANEL_FRAME_PADDING)); + sidebar.spacing(6).defaultCellSetting().alignVerticallyTop(); + final LinearLayout main = contentBaseHelper.addChild(LinearLayout.vertical(), 2); + + this.fixedSidebarLayout = sidebar.addChild(new EqualSpacingLayout(SIDEBAR_CONTROLS_WIDTH, SIDEBAR_CONTROLS_HEIGHT, EqualSpacingLayout.Orientation.HORIZONTAL)); + + this.search = this.fixedSidebarLayout.addChild(new EditBox(this.font, SIDEBAR_CONTROLS_WIDTH - SIDEBAR_SORT_BUTTON_WIDTH - 4, SIDEBAR_CONTROLS_HEIGHT, Component.translatable("neoforge.screen.mods.search"))); + this.search.setHint(Component.translatable("neoforge.screen.mods.search").withStyle(ChatFormatting.GRAY)); + this.search.setFocused(false); + this.search.setCanLoseFocus(true); + this.search.setResponder(_ -> this.updateModsList()); + + this.fixedSidebarLayout.addChild(CycleButton.builder(SortType::getName, this.currentSort) + .displayOnlyValue() + .withValues(SortType.values()) + .withTooltip(sort -> Tooltip.create(sort.getHover())) + // 20 is the default button height + .create(0, 0, SIDEBAR_SORT_BUTTON_WIDTH, SIDEBAR_CONTROLS_HEIGHT, translatable("neoforge.screen.mods.button.sort"), (_, newValue) -> { + this.currentSort = newValue; + this.updateModsList(); + })); + + this.fixedSidebarLayout.arrangeElements(); // Arrange to figure out the height + this.displayList = sidebar.addChild(new ModsList(this.layout.getContentHeight() - this.fixedSidebarLayout.getHeight() - 5)); + + this.displayPanel = new ModInfoPanel(INFO_PANEL_WIDTH, this.layout.getContentHeight()); + main.addChild(this.displayPanel.getMainLayout()); + + this.allEntries.clear(); + for (ModDisplayInfo mod : mods) { + this.allEntries.add(this.displayList.new Entry(this.versionCheck.get(mod.id()), mod)); + } + this.updateModsList(); + + this.layout.visitWidgets(this::addRenderableWidget); + this.repositionElements(); + } + + @Override + protected void repositionElements() { + assert this.layout != null; + assert this.displayList != null; + assert this.displayPanel != null; + assert this.fixedSidebarLayout != null; + this.displayPanel.updateHeight(this.layout.getContentHeight()); + this.displayList.updateSizeAndPosition(SIDEBAR_MODS_LIST_WIDTH, this.layout.getContentHeight() - this.fixedSidebarLayout.getHeight() - 5, 0); + this.displayPanel.getMainLayout().arrangeElements(); + this.layout.arrangeElements(); + this.displayList.setScrollAmount(this.displayList.scrollAmount()); + } + + @Override + public void onClose() { + super.onClose(); + final TextureManager textureManager = this.minecraft.getTextureManager(); + for (ModsList.Entry entry : this.allEntries) { + if (entry.iconData != null) { + textureManager.release(entry.iconData.sprite); + } + } + assert this.displayPanel != null; + this.displayPanel.close(); + } + + private void updateModsList() { + if (this.displayList == null) return; + + final ArrayList entries = new ArrayList<>(this.allEntries); + this.currentSort.sort(entries); + assert this.search != null; + final String filter = this.search.getValue().toLowerCase(Locale.ROOT); + if (!filter.isEmpty()) { + entries.removeIf(entry -> !entry.displayInfo.displayName().getString().toLowerCase(Locale.ROOT).contains(filter)); + } + final ModsList.Entry selected = this.displayList.getSelected(); + this.displayList.replaceEntries(entries); + if (entries.contains(selected)) { + // Reselect the previously-selected if possible + this.displayList.setSelected(selected); + } else if (entries.size() == 1) { + // If there is only one entry, select that + this.displayList.setSelected(entries.getFirst()); + } + } + + private record ImageData(Identifier sprite, int width, int height) {} + + @Nullable + private ImageData loadImage(String type, String modId, ImageResource imageResource) { + final IoSupplier resource = imageResource.get(this.minecraft.getResourceManager()); + + if (resource == null) { + LOGGER.warn("Failed to find {} resource {} for mod ID {} as it did not exist", type, imageResource, modId); + return null; + } + + final NativeImage image; + try (InputStream imageStream = resource.get()) { + image = NativeImage.read(imageStream); + } catch (IOException e) { + LOGGER.warn("Failed to load {} resource {} for mod ID {}", type, imageResource, modId); + return null; + } + + final TextureManager textureManager = this.minecraft.getTextureManager(); + final Identifier sprite = Identifier.fromNamespaceAndPath("neoforge", "mod/" + type + "/" + modId); + textureManager.register(sprite, new DynamicTexture(sprite::toString, image)); + + return new ImageData(sprite, image.getWidth(), image.getHeight()); + } + + private class ModsList extends ObjectSelectionList { + public ModsList(int height) { + // minecraft, width, height, y, itemHeight + super(ModListScreen.this.minecraft, SIDEBAR_MODS_LIST_WIDTH, height, 0, ICON_SIZE + 4); + // 24 pixels for the icon, 4 pixels padding for top and bottom (2 pixels each) + } + + @Override + public void clearEntries() { + super.clearEntries(); + this.setSelected(null); + } + + // Make the entries fit the container, and shrink if the scrollbar is present + @Override + public int getRowLeft() { + return this.getX() + 3; + } + + @Override + public int getRowWidth() { + int rowWidth = this.getWidth() - 6; + if (this.scrollable()) { + // Shrink if scrollbar is present + rowWidth -= this.scrollbarWidth(); + } + return rowWidth; + } + + @Override + protected int scrollBarX() { + return this.getRight() - this.scrollbarWidth(); + } + + @Override + public void setSelected(ModsList.@Nullable Entry selectedEntry) { + super.setSelected(selectedEntry); + assert ModListScreen.this.displayPanel != null; + ModListScreen.this.displayPanel.updateSelected(selectedEntry); + } + + class Entry extends ObjectSelectionList.Entry { + private static final Identifier VERSION_CHECK_ICONS = Identifier.fromNamespaceAndPath(NeoForgeMod.MOD_ID, "textures/gui/version_check_icons.png"); + final VersionChecker.@Nullable CheckResult checkResult; + final ModDisplayInfo displayInfo; + @Nullable + final ImageData iconData; + + Entry(VersionChecker.@Nullable CheckResult checkResult, ModDisplayInfo displayInfo) { + this.checkResult = checkResult; + this.displayInfo = displayInfo; + if (displayInfo.icon() != null) { + this.iconData = ModListScreen.this.loadImage("icon", displayInfo.id(), Objects.requireNonNull(displayInfo.icon())); + } else { + this.iconData = null; + } + } + + @Override + public Component getNarration() { + return Component.translatable("narrator.select", Component.translatable("neoforge.screen.mods.list.narration", displayInfo.displayName(), displayInfo.version())); + } + + @Override + public void extractContent(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float a) { + int left = this.getContentX(); + int top = this.getContentY(); + int textLeft = left + 2; + + if (iconData != null) { + graphics.blit(RenderPipelines.GUI_TEXTURED, iconData.sprite, left, top, 0.0F, 0.0F, ICON_SIZE, ICON_SIZE, ICON_SIZE, ICON_SIZE); + textLeft += ICON_SIZE + 4; + } + int maxTextWidth = getRowWidth() - textLeft + left - 4; + + if (checkResult != null && checkResult.status().shouldDraw() && FMLConfig.getBoolConfigValue(FMLConfig.ConfigValue.VERSION_CHECK)) { + graphics.blit( + RenderPipelines.GUI_TEXTURED, + VERSION_CHECK_ICONS, + this.getContentRight() - 10, + this.getContentYMiddle() - (8 / 2), + checkResult.status().getSheetOffset() * 8, + (checkResult.status().isAnimated() && ((System.currentTimeMillis() / 800 & 1) == 1)) ? 8 : 0, + 8, + 8, + 64, + 16); + maxTextWidth -= 14; + } + + top += 4; // padding + + final Language language = Language.getInstance(); + graphics.text(ModListScreen.this.font, language.getVisualOrder(ModListScreen.this.font.ellipsize(displayInfo.displayName(), maxTextWidth)), textLeft, top, 0xFFFFFFFF); + top += ModListScreen.this.font.lineHeight; + graphics.text(ModListScreen.this.font, language.getVisualOrder(ModListScreen.this.font.ellipsize(Component.literal(displayInfo.version()), maxTextWidth)), textLeft, top, 0xFF000000 + Objects.requireNonNull(ChatFormatting.GRAY.getColor())); + } + } + } + + private class ModInfoPanel implements Closeable { + private static final MonthDay APRIL_FOOLS = MonthDay.of(Month.APRIL, 1); + private static final int MAIN_PADDING = 4; // Padding between sections + + private final int width; + private final ResizableTextureImageWidget logoWidget; + private final MultiLineTextWidget displayNameWidget; + private final MultiLineTextWidget idAndVersionWidget; + private final MultiLineTextWidget newerVersionWidget; + private final Button newerVersionButton; + private final MultiLineTextWidget licenseWidget; + private final MultiLineTextWidget authorsWidget; + private final MultiLineTextWidget creditsWidget; + private final SolidColorWidget separator; + private final Button homepageButton; + private final Button issuesButton; + private final Button configButton; + private final MultiLineTextWidget descriptionWidget; + private ModsList.@Nullable Entry selected; + @Nullable + private ImageData logoData; + @Nullable + private UnaryOperator configScreenFactory; + + private static final int BUTTON_PANEL_HEIGHT = 26; + + private final LinearLayout mainLayout; + private final FrameLayout contentFrame; + private final BackgroundWithPipingWidget backgroundWithPipingWidget; + private final ScrollableLayout scrollableContentContainer; + + private final ImageWidget squirr; + + public ModInfoPanel(int width, int height) { + this.mainLayout = LinearLayout.vertical(); + this.mainLayout.defaultCellSetting().alignHorizontallyCenter(); + + this.contentFrame = this.mainLayout.addChild(new FrameLayout(width, height - BUTTON_PANEL_HEIGHT)); + + LinearLayout contentLayout = new LinearLayout(0, 0, Orientation.VERTICAL); + this.width = width; + + // Ensure layout width fills the whole width + contentLayout.addChild(SpacerElement.width(width)); + + this.logoWidget = contentLayout.addChild( + new ResizableTextureImageWidget(0, 0, 0, 0, MissingTextureAtlasSprite.getLocation(), 0, 0), + contentLayout.newCellSettings().paddingTop(INFO_PANEL_FRAME_PADDING).alignHorizontallyCenter()); + + contentLayout.addChild(SpacerElement.height(MAIN_PADDING)); + + this.displayNameWidget = buildTextWidget(contentLayout).setCentered(true); + this.displayNameWidget.setComponentClickHandler(null); // Disallow clicks for the display name + this.idAndVersionWidget = buildTextWidget(contentLayout).setCentered(true); + + this.newerVersionWidget = contentLayout.addChild(FocusableTextWidget.builder(Component.empty(), font, 2) + .alwaysShowBorder(false) + .backgroundFill(FocusableTextWidget.BackgroundFill.NEVER) + .maxWidth(width) + .build() + .setCentered(true), + contentLayout.newCellSettings().paddingVertical(3)); + this.newerVersionWidget.setComponentClickHandler(style -> { + ClickEvent clickEvent = style.getClickEvent(); + if (clickEvent != null) { + defaultHandleClickEvent(clickEvent, ModListScreen.this.minecraft, ModListScreen.this); + } + }); + this.newerVersionButton = contentLayout.addChild(Button.builder(translatable("neoforge.screen.mods.button.changelog"), + _ -> { + if (this.selected != null && this.selected.checkResult != null) { + this.openChangelogScreen(this.selected.checkResult); + } + }).build(), + contentLayout.newCellSettings().alignHorizontallyCenter()); + + contentLayout.addChild(SpacerElement.height(MAIN_PADDING)); + + this.licenseWidget = buildTextWidget(contentLayout); + this.authorsWidget = buildTextWidget(contentLayout); + this.creditsWidget = buildTextWidget(contentLayout); + + this.separator = contentLayout.addChild( + new SolidColorWidget(width - 8, 1).setColor(ARGB.opaque(Objects.requireNonNull(ChatFormatting.GRAY.getColor()))).calculateShadow(), + contentLayout.newCellSettings().paddingVertical(MAIN_PADDING).alignHorizontallyCenter()); + + this.descriptionWidget = buildTextWidget(contentLayout); + + this.backgroundWithPipingWidget = this.contentFrame.addChild(new BackgroundWithPipingWidget( + ModListScreen.this.minecraft, + 0, 0, + INFO_PANEL_WIDTH + INFO_PANEL_FRAME_PADDING * 2, height - BUTTON_PANEL_HEIGHT + INFO_PANEL_FRAME_PADDING * 2), + this.contentFrame.newChildLayoutSettings().padding(-INFO_PANEL_FRAME_PADDING)); + + this.scrollableContentContainer = this.contentFrame.addChild( + new ScrollableLayout(ModListScreen.this.minecraft, contentLayout, height - BUTTON_PANEL_HEIGHT), + this.contentFrame.newChildLayoutSettings().alignVerticallyTop()); + + this.squirr = this.contentFrame.addChild(ImageWidget.texture( + 32, 30, + Identifier.fromNamespaceAndPath("neoforge", "textures/gui/bigsquirr.png"), + 32, 30), this.contentFrame.newChildLayoutSettings().alignVerticallyBottom().alignHorizontallyRight().paddingRight(16).paddingBottom(-1)); + + EqualSpacingLayout buttonsLayout = this.mainLayout.addChild(new EqualSpacingLayout(width, BUTTON_PANEL_HEIGHT, EqualSpacingLayout.Orientation.HORIZONTAL)); + buttonsLayout.defaultChildLayoutSetting().alignVerticallyBottom().paddingHorizontal(-INFO_PANEL_FRAME_PADDING); + + final int buttonSpacing = 3; + final int buttonWidth = width / 3 - (buttonSpacing / 2); + final int buttonHeight = Button.DEFAULT_HEIGHT; + + this.homepageButton = buttonsLayout.addChild(Button.builder(Component.translatable("neoforge.screen.mods.button.homepage"), + _ -> { + if (displayInfo().displayUrl() != null) { + ConfirmLinkScreen.confirmLinkNow(ModListScreen.this, Objects.requireNonNull(displayInfo().displayUrl())); + } + }).size(buttonWidth, buttonHeight).build()); + this.issuesButton = buttonsLayout.addChild(Button.builder(Component.translatable("neoforge.screen.mods.button.issues"), + _ -> { + if (displayInfo().issuesUrl() != null) { + ConfirmLinkScreen.confirmLinkNow(ModListScreen.this, Objects.requireNonNull(displayInfo().issuesUrl())); + } + }).size(buttonWidth, buttonHeight).build()); + this.configButton = buttonsLayout.addChild(Button.builder(Component.translatable("neoforge.screen.mods.button.config"), + _ -> { + if (this.configScreenFactory != null) { + ModListScreen.this.minecraft.setScreen(this.configScreenFactory.apply(ModListScreen.this)); + } + }).size(buttonWidth, buttonHeight).build()); + + this.reset(); + } + + private MultiLineTextWidget buildTextWidget(LinearLayout layout) { + MultiLineTextWidget widget = layout.addChild(FocusableTextWidget.builder(Component.empty(), font, 2) + .alwaysShowBorder(false) + .backgroundFill(FocusableTextWidget.BackgroundFill.NEVER) + .maxWidth(width) + .build() + .setCentered(false)); + widget.setComponentClickHandler(style -> { + ClickEvent clickEvent = style.getClickEvent(); + if (clickEvent != null) { + defaultHandleClickEvent(clickEvent, ModListScreen.this.minecraft, ModListScreen.this); + } + }); + return widget; + } + + public LinearLayout getMainLayout() { + return mainLayout; + } + + private ModDisplayInfo displayInfo() { + if (this.selected == null) return BlankModDisplayInfo.INSTANCE; + return this.selected.displayInfo; + } + + public void updateHeight(int height) { + int infoHeight = height - BUTTON_PANEL_HEIGHT; + this.contentFrame.setMinHeight(infoHeight); + this.backgroundWithPipingWidget.setHeight(infoHeight); + this.scrollableContentContainer.setMaxHeight(infoHeight); + } + + public void updateSelected(ModsList.@Nullable Entry newSelected) { + if (newSelected == this.selected) return; // No change in selection + this.selected = newSelected; + this.update(); + assert ModListScreen.this.layout != null; + assert ModListScreen.this.displayPanel != null; + ModListScreen.this.displayPanel.getMainLayout().arrangeElements(); + ModListScreen.this.layout.arrangeElements(); + ModListScreen.this.repositionElements(); + } + + /// Resets layout to a blank state. + private void reset() { + if (this.logoData != null) { + final TextureManager textureManager = ModListScreen.this.minecraft.getTextureManager(); + textureManager.release(logoData.sprite); + this.logoData = null; + } + this.logoWidget.updateResource(MissingTextureAtlasSprite.getLocation(), 0, 0); + + hideTextWidget(this.displayNameWidget); + hideTextWidget(this.idAndVersionWidget); + hideTextWidget(this.newerVersionWidget); + this.newerVersionButton.visible = this.newerVersionButton.active = false; + this.newerVersionButton.setHeight(0); + hideTextWidget(this.licenseWidget); + hideTextWidget(this.authorsWidget); + hideTextWidget(this.creditsWidget); + + this.homepageButton.active = false; + this.issuesButton.active = false; + this.configScreenFactory = null; + this.configButton.active = false; + this.separator.visible = false; + hideTextWidget(this.descriptionWidget); + + this.squirr.visible = false; + } + + private void hideTextWidget(AbstractStringWidget widget) { + widget.setMessage(Component.empty()); + widget.visible = false; + widget.setHeight(0); + } + + /// Updates the layout based on the selected info. + public void update() { + reset(); + if (this.selected == null) return; // Do nothing if nothing is selected + ModDisplayInfo displayInfo = this.selected.displayInfo; + + final ImageResource logoResource = displayInfo.logo(); + if (logoResource != null) { + // Load new logo data + if (displayInfo.id().equals("minecraft")) { + // Special-case for the 'minecraft' mod: render the logo using LogoRenderer + float scaleFactor = Math.min(1F, (float) width / LogoRenderer.LOGO_TEXTURE_WIDTH); + int logoWidth = (int) (LogoRenderer.LOGO_TEXTURE_WIDTH * scaleFactor); + this.logoWidget.useMinecraftLogo(logoWidth); + } else { + this.logoData = loadImage("logo", displayInfo.id(), logoResource); + if (this.logoData != null) { + float widthScaleFactor = Math.min(1F, (float) this.width / this.logoData.width()); + float heightScaleFactor = Math.min(1F, (float) LOGO_HEIGHT / this.logoData.height()); + float scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); + int logoWidth = (int) (this.logoData.width() * scaleFactor); + int logoHeight = (int) (this.logoData.height() * scaleFactor); + this.logoWidget.updateResource(this.logoData.sprite(), logoWidth, logoHeight); + } + } + } + + this.displayNameWidget.setMessage(displayInfo.displayName()); + this.displayNameWidget.visible = true; + this.idAndVersionWidget.setMessage(Component.translatable( + "neoforge.screen.mods.info.subtitle", + Component.literal(displayInfo.id()).withStyle(style -> style + .withItalic(true) + .withHoverEvent(new HoverEvent.ShowText(Component.translatable("neoforge.screen.mods.info.subtitle.modid.click"))) + .withClickEvent(new ClickEvent.CopyToClipboard(displayInfo.id()))), + Component.literal(displayInfo.version()).withStyle(style -> style + .withHoverEvent(new HoverEvent.ShowText(Component.translatable("neoforge.screen.mods.info.subtitle.version.click"))) + .withClickEvent(new ClickEvent.CopyToClipboard(displayInfo.version())))) + .withStyle(ChatFormatting.GRAY)); + this.idAndVersionWidget.visible = true; + VersionChecker.CheckResult checkResult = this.selected.checkResult; + if (checkResult != null) { + this.newerVersionWidget.setMessage(Component.translatable( + "neoforge.screen.mods.info.update", + Component.literal(checkResult.target().toString()) + .withStyle(style -> style.withItalic(false))) + .withStyle(style -> style.withItalic(true))); + this.newerVersionWidget.visible = true; + + this.newerVersionButton.setHeight(Button.DEFAULT_HEIGHT); + this.newerVersionButton.visible = this.newerVersionButton.active = true; + } + + this.licenseWidget.setMessage(Component.translatable( + "neoforge.screen.mods.info.license", + displayInfo.license().copy().withStyle(ChatFormatting.WHITE).withStyle(style -> style.withBold(false))).withStyle(ChatFormatting.GRAY).withStyle(style -> style.withBold(true))); + this.licenseWidget.visible = true; + if (containsText(displayInfo.authors())) { + this.authorsWidget.setMessage(Component.translatable( + "neoforge.screen.mods.info.authors", + displayInfo.authors().copy().withStyle(ChatFormatting.WHITE).withStyle(style -> style.withBold(false))).withStyle(ChatFormatting.GRAY).withStyle(style -> style.withBold(true))); + this.authorsWidget.visible = true; + } + if (containsText(displayInfo.credits())) { + this.creditsWidget.setMessage(Component.translatable( + "neoforge.screen.mods.info.credits", + displayInfo.credits().copy().withStyle(ChatFormatting.WHITE).withStyle(style -> style.withBold(false))).withStyle(ChatFormatting.GRAY).withStyle(style -> style.withBold(true))); + this.creditsWidget.visible = true; + } + + this.homepageButton.active = displayInfo.displayUrl() != null; + this.issuesButton.active = displayInfo.issuesUrl() != null; + this.configScreenFactory = ModListScreen.this.configFactory.create(displayInfo); + this.configButton.active = this.configScreenFactory != null; + + // Hardcoded case from ModInfo + if (containsText(displayInfo.description()) && !displayInfo.description().getString().equals("MISSING DESCRIPTION")) { + this.separator.visible = true; + this.descriptionWidget.setMessage(displayInfo.description()); + this.descriptionWidget.visible = true; + } + + if (displayInfo.id().equals("neoforge") && SpecialDates.dayNow().equals(APRIL_FOOLS)) { + this.squirr.visible = true; + } + } + + private void openChangelogScreen(VersionChecker.CheckResult checkResult) { + ModListScreen.this.minecraft.setScreen(new ChangelogScreen(ModListScreen.this, displayInfo(), checkResult)); + } + + private static boolean containsText(Component component) { + return !component.getContents().equals(PlainTextContents.EMPTY) && !component.getString().isEmpty(); + } + + @Override + public void close() { + this.reset(); + } + } + + private static final Comparator COMPARATOR_BY_NAME = Comparator.comparing(c -> c.displayInfo.displayName().getString().toLowerCase(Locale.ROOT)); + + private enum SortType { + A_TO_Z("neoforge.screen.mods.sort.a_to_z", list -> list.sort(COMPARATOR_BY_NAME)), + Z_TO_A("neoforge.screen.mods.sort.z_to_a", list -> list.sort(COMPARATOR_BY_NAME.reversed())); + + private final String translationKey; + private final Consumer> sorter; + + SortType(String translationKey, Consumer> sorter) { + this.translationKey = translationKey; + this.sorter = sorter; + } + + public Component getName() { + return Component.translatable(this.translationKey); + } + + public Component getHover() { + return Component.translatable(this.translationKey + ".hover"); + } + + public void sort(List list) { + sorter.accept(list); + } + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/VersionCheckResultSupplier.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/VersionCheckResultSupplier.java new file mode 100644 index 00000000000..b1a040b101f --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/VersionCheckResultSupplier.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.modlist; + +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.ModList; +import net.neoforged.fml.VersionChecker; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.VisibleForTesting; +import org.jspecify.annotations.Nullable; + +/// Version check result supplier used by the mod list screen. For internal use only. +@FunctionalInterface +@ApiStatus.Internal +@VisibleForTesting +public interface VersionCheckResultSupplier { + VersionChecker.@Nullable CheckResult get(String modId); + + VersionCheckResultSupplier DEFAULT = modId -> ModList.get().getModContainerById(modId) + .map(ModContainer::getModInfo) + .map(VersionChecker::getResult) + .filter(result -> result.status() == VersionChecker.Status.OUTDATED || result.status() == VersionChecker.Status.BETA_OUTDATED) + .orElse(null); +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/modlist/package-info.java b/src/client/java/net/neoforged/neoforge/client/gui/modlist/package-info.java new file mode 100644 index 00000000000..62cacae76f3 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/modlist/package-info.java @@ -0,0 +1,9 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +@NullMarked +package net.neoforged.neoforge.client.gui.modlist; + +import org.jspecify.annotations.NullMarked; diff --git a/src/client/java/net/neoforged/neoforge/client/gui/widget/BackgroundWithPipingWidget.java b/src/client/java/net/neoforged/neoforge/client/gui/widget/BackgroundWithPipingWidget.java new file mode 100644 index 00000000000..5dd7c3e9798 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/widget/BackgroundWithPipingWidget.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.widget; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.navigation.FocusNavigationEvent; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +/// Renders the background and top/bottom piping from [net.minecraft.client.gui.components.AbstractSelectionList]. +public class BackgroundWithPipingWidget extends AbstractWidget { + private static final Identifier INWORLD_MENU_LIST_BACKGROUND = Identifier.withDefaultNamespace("textures/gui/inworld_menu_list_background.png"); + private static final Identifier MENU_LIST_BACKGROUND = Identifier.withDefaultNamespace("textures/gui/menu_list_background.png"); + + @Nullable + private final Minecraft minecraft; + + public BackgroundWithPipingWidget(@Nullable Minecraft minecraft, int x, int y, int width, int height) { + super(x, y, width, height, Component.empty()); + this.minecraft = minecraft; + } + + public BackgroundWithPipingWidget(int x, int y, int width, int height) { + this(null, x, y, width, height); + } + + @Override + public boolean isMouseOver(double mouseX, double mouseY) { + return false; // Avoid capturing mouse events + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + Identifier headerSeparator = this.minecraft == null || this.minecraft.level == null ? Screen.INWORLD_HEADER_SEPARATOR : Screen.HEADER_SEPARATOR; + Identifier footerSeparator = this.minecraft == null || this.minecraft.level == null ? Screen.INWORLD_FOOTER_SEPARATOR : Screen.FOOTER_SEPARATOR; + graphics.blit(RenderPipelines.GUI_TEXTURED, headerSeparator, this.getX(), this.getY() - 2, 0.0F, 0.0F, this.getWidth(), 2, 32, 2); + graphics.blit(RenderPipelines.GUI_TEXTURED, footerSeparator, this.getX(), this.getBottom(), 0.0F, 0.0F, this.getWidth(), 2, 32, 2); + + Identifier menuListBackground = this.minecraft == null || this.minecraft.level == null ? INWORLD_MENU_LIST_BACKGROUND : MENU_LIST_BACKGROUND; + graphics.blit( + RenderPipelines.GUI_TEXTURED, + menuListBackground, + this.getX(), + this.getY(), + (float) this.getRight(), + (float) this.getBottom(), + this.getWidth(), + this.getHeight(), + 32, + 32); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + // No-op + } + + @Override + public void playDownSound(SoundManager soundManager) {} + + @Override + public boolean isActive() { + return false; + } + + @Override + public @Nullable ComponentPath nextFocusPath(FocusNavigationEvent navigationEvent) { + return null; + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/widget/ModListWidget.java b/src/client/java/net/neoforged/neoforge/client/gui/widget/ModListWidget.java deleted file mode 100644 index ad710cd36d2..00000000000 --- a/src/client/java/net/neoforged/neoforge/client/gui/widget/ModListWidget.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (c) Forge Development LLC and contributors - * SPDX-License-Identifier: LGPL-2.1-only - */ - -package net.neoforged.neoforge.client.gui.widget; - -import net.minecraft.client.gui.Font; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.components.ObjectSelectionList; -import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.client.renderer.RenderPipelines; -import net.minecraft.locale.Language; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.FormattedText; -import net.minecraft.resources.Identifier; -import net.neoforged.fml.ModContainer; -import net.neoforged.fml.VersionChecker; -import net.neoforged.fml.i18n.MavenVersionTranslator; -import net.neoforged.neoforge.client.gui.ModListScreen; -import net.neoforged.neoforge.common.NeoForgeMod; -import net.neoforged.neoforgespi.language.IModInfo; - -public class ModListWidget extends ObjectSelectionList { - private static String stripControlCodes(String value) { - return net.minecraft.util.StringUtil.stripColor(value); - } - - private static final Identifier VERSION_CHECK_ICONS = Identifier.fromNamespaceAndPath(NeoForgeMod.MOD_ID, "textures/gui/version_check_icons.png"); - private final int listWidth; - - private ModListScreen parent; - - public ModListWidget(ModListScreen parent, int listWidth, int top, int bottom) { - super(parent.getMinecraftInstance(), listWidth, bottom - top, top, parent.getFontRenderer().lineHeight * 2 + 8); - this.parent = parent; - this.listWidth = listWidth; - //this.setRenderBackground(false); // TODO: Porting 1.20.5 still needed? - } - - @Override - protected int scrollBarX() { - return this.listWidth; - } - - @Override - public int getRowWidth() { - return this.listWidth; - } - - public void refreshList() { - this.clearEntries(); - parent.buildModList(this::addEntry, mod -> new ModEntry(mod, this.parent)); - } - - public class ModEntry extends ObjectSelectionList.Entry { - private final ModContainer container; - private final ModListScreen parent; - - ModEntry(ModContainer info, ModListScreen parent) { - this.container = info; - this.parent = parent; - } - - @Override - public Component getNarration() { - return Component.translatable("narrator.select", container.getModInfo().getDisplayName()); - } - - @Override - public void extractContent(GuiGraphicsExtractor guiGraphics, int mouseX, int mouseY, boolean hovered, float partialTick) { - Component name = Component.literal(stripControlCodes(container.getModInfo().getDisplayName())); - Component version = Component.literal(stripControlCodes(MavenVersionTranslator.artifactVersionToString(container.getModInfo().getVersion()))); - VersionChecker.CheckResult vercheck = VersionChecker.getResult(container.getModInfo()); - int left = getContentX(); - int top = getContentY(); - Font font = this.parent.getFontRenderer(); - guiGraphics.text(font, Language.getInstance().getVisualOrder(FormattedText.composite(font.substrByWidth(name, listWidth))), left + 3, top + 2, 0xFFFFFFFF, false); - guiGraphics.text(font, Language.getInstance().getVisualOrder(FormattedText.composite(font.substrByWidth(version, listWidth))), left + 3, top + 2 + font.lineHeight, 0xFFCCCCCC, false); - if (vercheck.status().shouldDraw()) { - //TODO: Consider adding more icons for visualization - int entryHeight = getContentHeight(); - guiGraphics.blit(RenderPipelines.GUI_TEXTURED, VERSION_CHECK_ICONS, getX() + width - 12, top + entryHeight / 4, vercheck.status().getSheetOffset() * 8, (vercheck.status().isAnimated() && ((System.currentTimeMillis() / 800 & 1)) == 1) ? 8 : 0, 8, 8, 64, 16); - } - } - - @Override - public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { - // clicking on a selected item a second time unselects it - parent.setSelected(isFocused() ? null : this); - ModListWidget.this.setSelected(isFocused() ? null : this); - return false; - } - - @Override - public void setFocused(boolean focused) { - // ignore focus loss so the item stays selected when tabbing to the config button - if (focused) { - parent.setSelected(this); - ModListWidget.this.setSelected(this); - } - } - - @Override - public boolean isFocused() { - return ModListWidget.this.getSelected() == this; - } - - public IModInfo getInfo() { - return container.getModInfo(); - } - - public ModContainer getContainer() { - return container; - } - } -} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/widget/ResizableTextureImageWidget.java b/src/client/java/net/neoforged/neoforge/client/gui/widget/ResizableTextureImageWidget.java new file mode 100644 index 00000000000..9563da46b21 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/widget/ResizableTextureImageWidget.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.widget; + +import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.ImageWidget; +import net.minecraft.client.gui.components.LogoRenderer; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.gui.navigation.FocusNavigationEvent; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.sounds.SoundManager; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +/// A variation of [ImageWidget.Texture] for variable size textures, with support for the Minecraft (with edition) logo. +public class ResizableTextureImageWidget extends AbstractWidget { + private @Nullable LogoRenderer logoRenderer; + private Identifier texture; + private int textureWidth; + private int textureHeight; + + public ResizableTextureImageWidget(int x, int y, int width, int height, Identifier texture, int textureWidth, int textureHeight) { + super(x, y, width, height, CommonComponents.EMPTY); + this.texture = texture; + this.textureWidth = textureWidth; + this.textureHeight = textureHeight; + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) {} + + @Override + public void playDownSound(SoundManager soundManager) {} + + @Override + public boolean isActive() { + return false; + } + + @Override + public @Nullable ComponentPath nextFocusPath(FocusNavigationEvent navigationEvent) { + return null; + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + if (logoRenderer != null) { + graphics.pose().pushMatrix(); + // Slightly shrink the logo + float adjust = 6; + graphics.pose().translate(this.getX() + adjust / 2, this.getY()); + graphics.pose().scale(((float) LogoRenderer.LOGO_WIDTH - adjust) / LogoRenderer.LOGO_WIDTH); + logoRenderer.extractRenderState(graphics, this.getWidth(), 1.0F, 0); + graphics.pose().popMatrix(); + } else { + graphics.blit( + RenderPipelines.GUI_TEXTURED, + this.texture, + this.getX(), + this.getY(), + 0.0F, + 0.0F, + this.getWidth(), + this.getHeight(), + this.textureWidth, + this.textureHeight); + } + } + + public void updateResource(Identifier identifier, int textureWidth, int textureHeight) { + this.texture = identifier; + this.textureWidth = textureWidth; + this.textureHeight = textureHeight; + this.logoRenderer = null; + this.setSize(textureWidth, textureHeight); + } + + public void useMinecraftLogo(int width) { + this.logoRenderer = new LogoRenderer(true); + this.textureWidth = width; + this.textureHeight = LogoRenderer.LOGO_HEIGHT + 7; // Padding for the edition overlap + this.setSize(textureWidth, textureHeight); + } +} diff --git a/src/client/java/net/neoforged/neoforge/client/gui/widget/SolidColorWidget.java b/src/client/java/net/neoforged/neoforge/client/gui/widget/SolidColorWidget.java new file mode 100644 index 00000000000..448e7374674 --- /dev/null +++ b/src/client/java/net/neoforged/neoforge/client/gui/widget/SolidColorWidget.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.client.gui.widget; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.network.chat.Component; +import net.minecraft.util.ARGB; + +/// Renders a solid color in the space of the widget, with drop shadow. On creation, the color and shadow color are transparent; use [#setColor(int)] and [#setShadowColor(int)] to change the colors. +/// +/// The drop shadow is rendered outside the widget bounds, alike to the drop shadow of text. +public class SolidColorWidget extends AbstractWidget { + private int color; + private int shadowColor; + + /// Creates a new instance of this widget. + /// + /// @param x the X position + /// @param y the Y position + /// @param width the width of the widget + /// @param height the height of the widget + public SolidColorWidget(int x, int y, int width, int height) { + super(x, y, width, height, Component.empty()); + } + + /// Creates a new instance of this widget. + /// + /// @param width the width of the widget + /// @param height the height of the widget + public SolidColorWidget(int width, int height) { + this(0, 0, width, height); + } + + /// Sets the main color. + /// + /// @param color the new main color + /// @return this instance, for chaining + @CanIgnoreReturnValue + public SolidColorWidget setColor(int color) { + this.color = color; + return this; + } + + /// {@return the main color} + public int getColor() { + return color; + } + + /// Sets the shadow color. + /// + /// @param shadowColor the new shadow color + /// @return this instance, for chaining + @CanIgnoreReturnValue + public SolidColorWidget setShadowColor(int shadowColor) { + this.shadowColor = shadowColor; + return this; + } + + /// {@return the shadow color} + public int getShadowColor() { + return shadowColor; + } + + /// Calculate the shadow color based off the main color. + /// + /// The calculation is the same used for the drop shadow of text: 25% of the original RGB color (alpha preserved). + /// + /// @return this instance, for chaining + @CanIgnoreReturnValue + public SolidColorWidget calculateShadow() { + return setShadowColor(ARGB.scaleRGB(this.getColor(), 0.25F)); + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + graphics.fill(RenderPipelines.GUI, getX() + 1, getY() + 1, getX() + 1 + getWidth(), getY() + 1 + getHeight(), shadowColor); + graphics.fill(RenderPipelines.GUI, getX(), getY(), getX() + getWidth(), getY() + getHeight(), color); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) {} + + @Override + public boolean shouldTakeFocusAfterInteraction() { + return false; + } + + @Override + public boolean isActive() { + return false; // Never active + } + + @Override + protected void handleCursor(GuiGraphicsExtractor graphics) { + // Do nothing + } +} diff --git a/src/main/java/net/neoforged/neoforge/resource/ResourcePackLoader.java b/src/main/java/net/neoforged/neoforge/resource/ResourcePackLoader.java index 28bfe49853e..05df4d1cbde 100644 --- a/src/main/java/net/neoforged/neoforge/resource/ResourcePackLoader.java +++ b/src/main/java/net/neoforged/neoforge/resource/ResourcePackLoader.java @@ -104,7 +104,7 @@ private static void packFinder(Map modResource continue; } var modFileInfo = modFile.getModFileInfo(); - final String name = "mod/" + e.getKey().getModInfos().stream().map(IModInfo::getModId).collect(Collectors.joining(",")); + final String name = getPackName(modFile); final String version = e.getKey().getModInfos().stream().map(IModInfo::getVersion).map(ArtifactVersion::toString).collect(Collectors.joining(",")); final String packName = e.getKey().getFileName(); @@ -260,12 +260,16 @@ public static List getPackNames(PackType packType) { .filter(packType == PackType.CLIENT_RESOURCES ? IModFileInfo::showAsResourcePack : IModFileInfo::showAsDataPack) .map(IModFileInfo::getFile) .filter(ResourcePackLoader::hasResourcePack) - .map(mf -> "mod/" + mf.getModInfos().stream().map(IModInfo::getModId).collect(Collectors.joining())) + .map(ResourcePackLoader::getPackName) .toList()); ids.add(packType == PackType.CLIENT_RESOURCES ? MOD_RESOURCES_ID : MOD_DATA_ID); return ids; } + public static String getPackName(IModFile mf) { + return "mod/" + mf.getModInfos().stream().map(IModInfo::getModId).collect(Collectors.joining()); + } + private static boolean hasResourcePack(IModFile mf) { // Vanilla resources are already considered, so don't create another entry for the Minecraft mod-file return mf.getModInfos().stream().noneMatch(m -> "minecraft".equals(m.getModId())); diff --git a/src/main/resources/assets/neoforge/lang/en_us.json b/src/main/resources/assets/neoforge/lang/en_us.json index f72b15c70b7..a7f2b42c28f 100644 --- a/src/main/resources/assets/neoforge/lang/en_us.json +++ b/src/main/resources/assets/neoforge/lang/en_us.json @@ -1,28 +1,5 @@ { "fml.menu.mods": "Mods", - "fml.menu.mods.title": "Mods", - "fml.menu.mods.normal": "Off", - "fml.menu.mods.search": "Search", - "fml.menu.mods.a_to_z": "A-Z", - "fml.menu.mods.z_to_a": "Z-A", - "fml.menu.mods.config": "Config", - "fml.menu.mods.openmodsfolder": "Open mods folder", - "fml.menu.modoptions": "Mod Options...", - "fml.menu.mods.info.version": "Version: %1$s", - "fml.menu.mods.info.idstate": "ModID: %1$s State: {1,lower}", - "fml.menu.mods.info.credits": "Credits: %1$s", - "fml.menu.mods.info.authors": "Authors: %1$s", - "fml.menu.mods.info.displayurl": "Homepage: %1$s", - "fml.menu.mods.info.license": "License: %1$s", - "fml.menu.mods.info.securejardisabled": "Secure mod features disabled, update JDK", - "fml.menu.mods.info.signature": "Signature: %1$s", - "fml.menu.mods.info.signature.unsigned": "UNSIGNED", - "fml.menu.mods.info.trust": "Trust: %1$s", - "fml.menu.mods.info.trust.noauthority": "None", - "fml.menu.mods.info.nochildmods": "No child mods found", - "fml.menu.mods.info.childmods": "Child mods: %1$s", - "fml.menu.mods.info.updateavailable": "Update available: %1$s", - "fml.menu.mods.info.changelogheader": "Changelog:", "fml.menu.multiplayer.compatible": "Compatible FML modded server\n{0,choice,1#1 mod|1<%1$s mods} present", "fml.menu.multiplayer.incompatible": "Incompatible FML modded server", "fml.menu.multiplayer.incompatible.extra": "Incompatible FML modded server\n%1$s", @@ -48,6 +25,30 @@ "fml.button.open.mods.folder": "Open Mods Folder", "fml.button.continue.launch": "Proceed to main menu", + "neoforge.screen.mods.button.open_folder": "Open Mods Folder", + "neoforge.screen.mods.button.sort": "Sort", + "neoforge.screen.mods.button.homepage": "Homepage", + "neoforge.screen.mods.button.issues": "Issues", + "neoforge.screen.mods.button.config": "Config", + "neoforge.screen.mods.button.changelog": "See update changelog", + "neoforge.screen.mods.sort.a_to_z": "A-Z", + "neoforge.screen.mods.sort.a_to_z.hover": "In alphabetical order by display name.", + "neoforge.screen.mods.sort.z_to_a": "Z-A", + "neoforge.screen.mods.sort.z_to_a.hover": "In reverse alphabetical order by display name.", + "neoforge.screen.mods.search": "Search", + "neoforge.screen.mods.list.narration": "Mod %s with version %s", + "neoforge.screen.mods.info.update": "Update available: %s", + "neoforge.screen.mods.info.subtitle": "%s %s", + "neoforge.screen.mods.info.subtitle.modid.click": "Click to copy mod ID", + "neoforge.screen.mods.info.subtitle.version.click": "Click to copy version", + "neoforge.screen.mods.info.authors": "Authors: %s", + "neoforge.screen.mods.info.credits": "Credits: %s", + "neoforge.screen.mods.info.license": "License: %s", + "neoforge.screen.mods.changelog.title": "Update changelog for %s", + "neoforge.screen.mods.changelog.open_site": "Open update site", + "neoforge.screen.mods.changelog.no_changelog": "No changelog provided.", + "neoforge.screen.mods.changelog.entry": "%s: %s", + "fml.modmismatchscreen.missingmods.client": "Your client is missing the following mods, install these mods to join this server:", "fml.modmismatchscreen.missingmods.server": "The server is missing the following mods, remove these mods from your client to join this server:", "fml.modmismatchscreen.mismatchedmods": "The following mod versions do not match, install the same version of these mods that the server has to join this server:", diff --git a/src/main/resources/assets/neoforge/textures/gui/bigsquirr.png b/src/main/resources/assets/neoforge/textures/gui/bigsquirr.png new file mode 100644 index 00000000000..5b4f7072c43 Binary files /dev/null and b/src/main/resources/assets/neoforge/textures/gui/bigsquirr.png differ diff --git a/src/main/resources/neoforged_icon.png b/src/main/resources/neoforged_icon.png new file mode 100644 index 00000000000..e3c09ef3020 Binary files /dev/null and b/src/main/resources/neoforged_icon.png differ diff --git a/src/main/templates/minecraft.neoforge.mods.toml b/src/main/templates/minecraft.neoforge.mods.toml index dea3bb8d92e..412362ed910 100644 --- a/src/main/templates/minecraft.neoforge.mods.toml +++ b/src/main/templates/minecraft.neoforge.mods.toml @@ -1,8 +1,11 @@ modLoader="minecraft" license="Minecraft EULA" +licenseURL="https://aka.ms/MinecraftEULA" [[mods]] modId="minecraft" version="${minecraft_version}" displayName="Minecraft" authors="Mojang Studios" description="" +logoFile="minecraft:textures/gui/title/minecraft.png" # For completeness; the mod list screen will use the logo renderer +iconFile="vanilla#pack.png" diff --git a/src/main/templates/neoforge.mods.toml b/src/main/templates/neoforge.mods.toml index 8b727b91fa4..bde121e2ed6 100644 --- a/src/main/templates/neoforge.mods.toml +++ b/src/main/templates/neoforge.mods.toml @@ -1,7 +1,6 @@ modLoader="javafml" loaderVersion="[3,]" issueTrackerURL="https://github.com/neoforged/NeoForge/issues" -logoFile="neoforged_logo.png" license="LGPL v2.1" [[mods]] @@ -10,11 +9,12 @@ license="LGPL v2.1" version="${version}" #updateJSONURL="https://maven.neoforged.net/releases/net/neoforged/forge/promotions_slim.json" displayName="NeoForge" - credits="Anyone who has contributed on Github and supports our development" + credits="Everyone who contributed on GitHub and supports our development" authors="The NeoForged Team" - description=''' - NeoForge, a NEW broad compatibility API. - ''' + description="A free, open-source, community-oriented modding API for Minecraft: Java Edition." + displayURL="https://neoforged.net" + logoFile="neoforged_logo.png" + iconFile="neoforged_icon.png" [[mixins]] config = "neoforge.mixins.json"