diff --git a/build.gradle b/build.gradle index 25080bf25..67b1befc2 100644 --- a/build.gradle +++ b/build.gradle @@ -115,6 +115,7 @@ dependencies { include "net.fabricmc:tiny-remapper:0.13.0" include "net.fabricmc:class-tweaker:0.3.0-beta.2" include "net.fabricmc:mapping-io:0.7.1" + include "com.formdev:flatlaf:3.7.1:no-natives" development "io.github.llamalad7:mixinextras-fabric:$mixin_extras_version" @@ -206,6 +207,7 @@ tasks.register('fatJar', ShadowJar) { relocate 'net.fabricmc.classtweaker', 'net.fabricmc.loader.impl.lib.classtweaker' relocate 'net.fabricmc.tinyremapper', 'net.fabricmc.loader.impl.lib.tinyremapper' relocate 'net.fabricmc.mappingio', 'net.fabricmc.loader.impl.lib.mappingio' + relocate 'com.formdev.flatlaf', 'net.fabricmc.loader.impl.lib.flatlaf' exclude 'about.html' exclude 'sat4j.version' diff --git a/src/main/java/net/fabricmc/loader/impl/FabricLoaderImpl.java b/src/main/java/net/fabricmc/loader/impl/FabricLoaderImpl.java index 406a4c6ba..5e3f75fbe 100644 --- a/src/main/java/net/fabricmc/loader/impl/FabricLoaderImpl.java +++ b/src/main/java/net/fabricmc/loader/impl/FabricLoaderImpl.java @@ -199,7 +199,7 @@ public void load() { setup(); } catch (ModResolutionException exception) { if (exception.getCause() == null) { - throw FormattedException.ofLocalized("exception.incompatible", exception.getMessage()); + throw FormattedException.ofLocalized("exception.incompatible", exception.getMessage(), exception); } else { throw FormattedException.ofLocalized("exception.incompatible", exception); } @@ -469,7 +469,7 @@ public boolean isDevelopmentEnvironment() { return FabricLauncherBase.getLauncher().isDevelopment(); } - private void addMod(ModCandidateImpl candidate) throws ModResolutionException { + private void addMod(ModCandidateImpl candidate) { ModContainerImpl container = new ModContainerImpl(candidate); mods.add(container); modMap.put(candidate.getId(), container); diff --git a/src/main/java/net/fabricmc/loader/impl/discovery/ModResolutionException.java b/src/main/java/net/fabricmc/loader/impl/discovery/ModResolutionException.java index 3d5b6dad4..615f3d19c 100644 --- a/src/main/java/net/fabricmc/loader/impl/discovery/ModResolutionException.java +++ b/src/main/java/net/fabricmc/loader/impl/discovery/ModResolutionException.java @@ -16,17 +16,32 @@ package net.fabricmc.loader.impl.discovery; +import net.fabricmc.loader.impl.gui.FabricStatusTree.DependencyGuiData; + @SuppressWarnings("serial") public class ModResolutionException extends Exception { + private final DependencyGuiData dependencyGuiData; public ModResolutionException(String s) { super(s); + this.dependencyGuiData = null; } public ModResolutionException(String format, Object... args) { super(String.format(format, args)); + this.dependencyGuiData = null; + } + + public ModResolutionException(String format, DependencyGuiData dependencyGuiData, Object... args) { + super(String.format(format, args)); + this.dependencyGuiData = dependencyGuiData; } public ModResolutionException(String s, Throwable t) { super(s, t); + this.dependencyGuiData = null; + } + + public DependencyGuiData getDependencyGuiData() { + return dependencyGuiData; } } diff --git a/src/main/java/net/fabricmc/loader/impl/discovery/ModResolver.java b/src/main/java/net/fabricmc/loader/impl/discovery/ModResolver.java index d3bdab058..89b7326b8 100644 --- a/src/main/java/net/fabricmc/loader/impl/discovery/ModResolver.java +++ b/src/main/java/net/fabricmc/loader/impl/discovery/ModResolver.java @@ -38,6 +38,7 @@ import net.fabricmc.loader.impl.metadata.ModDependencyImpl; import net.fabricmc.loader.impl.util.log.Log; import net.fabricmc.loader.impl.util.log.LogCategory; +import net.fabricmc.loader.impl.gui.FabricStatusTree.DependencyGuiData; public class ModResolver { public static List resolve(Collection candidates, EnvType envType, Map> envDisabledMods) throws ModResolutionException { @@ -144,7 +145,10 @@ private static List findCompatibleSet(Collection> entries = new ArrayList<>(result.fix.inactiveMods.entrySet()); // sort by root, id, version - entries.sort(new Comparator>() { - @Override - public int compare(Entry o1, Entry o2) { - ModCandidateImpl a = o1.getKey(); - ModCandidateImpl b = o2.getKey(); - - if (a.isRoot() != b.isRoot()) { - return a.isRoot() ? -1 : 1; - } + entries.sort((Comparator>) (o1, o2) -> { + ModCandidateImpl a = o1.getKey(); + ModCandidateImpl b = o2.getKey(); - return ModCandidateImpl.ID_VERSION_COMPARATOR.compare(a, b); + if (a.isRoot() != b.isRoot()) { + return a.isRoot() ? -1 : 1; } + + return ModCandidateImpl.ID_VERSION_COMPARATOR.compare(a, b); }); for (Map.Entry entry : entries) { @@ -121,6 +129,164 @@ public int compare(Entry o1, Entry o2) return sw.toString(); } + static DependencyGuiData gatherErrorData(ModSolver.Result result, Map selectedMods, Map> modsById, + Map> envDisabledMods, EnvType envType) { + DependencyGuiData data = new DependencyGuiData(); + + if (result.fix != null) { + formatFixData(result.fix, selectedMods, modsById, envDisabledMods, envType, data); + } + + List matches = new ArrayList<>(); + + for (Explanation explanation : result.reason) { + assert explanation.error.isDependencyError; + addIconSource(data, explanation.mod); + + ModDependency dep = explanation.dep; + ModCandidateImpl selected = selectedMods.get(dep.getModId()); + + if (selected != null) { + matches.add(selected); + } else { + List candidates = modsById.get(dep.getModId()); + if (candidates != null) matches.addAll(candidates); + } + + for (ModCandidateImpl match : matches) { + addIconSource(data, match); + addIconSource(data, dep.getModId(), match); + } + + addErrorToData(data, explanation.mod, explanation.dep, matches); + matches.clear(); + } + + return data; + } + + private static void formatFixData(ModSolver.Fix fix, + Map selectedMods, Map> modsById, + Map> envDisabledMods, EnvType envType, + DependencyGuiData data) { + for (AddModVar mod : fix.modsToAdd) { + Set envDisabledAlternatives = envDisabledMods.get(mod.getId()); + String text; + + if (envDisabledAlternatives == null) { + text = Localization.format("resolution.solution.addMod", + mod.getId(), + formatVersionRequirements(mod.getVersionIntervals())); + } else { + String envKey = String.format("environment.%s", envType.name().toLowerCase(Locale.ENGLISH)); + + text = Localization.format("resolution.solution.replaceModEnvDisabled", + formatOldMods(envDisabledAlternatives), + mod.getId(), + formatVersionRequirements(mod.getVersionIntervals()), + Localization.format(envKey)); + } + + data.addSuggestedChange(text, mod.getId()); + } + + for (ModCandidateImpl mod : fix.modsToRemove) { + addIconSource(data, mod); + data.addSuggestedChange(Localization.format("resolution.solution.removeMod", getName(mod), getVersion(mod), mod.getLocalPath()), mod.getId()); + } + + for (Entry> entry : fix.modReplacements.entrySet()) { + AddModVar newMod = entry.getKey(); + List oldMods = entry.getValue(); + String oldModsFormatted = formatOldMods(oldMods); + String targetId = oldMods.isEmpty() ? newMod.getId() : oldMods.get(0).getId(); + + for (ModCandidateImpl oldMod : oldMods) { + addIconSource(data, oldMod); + } + + if (oldMods.size() != 1 || !oldMods.get(0).getId().equals(newMod.getId())) { + String newModName = newMod.getId(); + ModCandidateImpl alt = selectedMods.get(newMod.getId()); + + if (alt != null) { + newModName = getName(alt); + } else { + List alts = modsById.get(newMod.getId()); + if (alts != null && !alts.isEmpty()) newModName = getName(alts.get(0)); + } + + data.addSuggestedChange(Localization.format("resolution.solution.replaceMod", + oldModsFormatted, + newModName, + formatVersionRequirements(newMod.getVersionIntervals())), targetId); + } else { + ModCandidateImpl oldMod = oldMods.get(0); + targetId = oldMod.getId(); + boolean hasOverlap = !VersionInterval.and(newMod.getVersionIntervals(), + Collections.singletonList(new VersionIntervalImpl(oldMod.getVersion(), true, oldMod.getVersion(), true))).isEmpty(); + + if (!hasOverlap) { + data.addSuggestedChange(Localization.format("resolution.solution.replaceModVersion", + oldModsFormatted, + formatVersionRequirements(newMod.getVersionIntervals())), targetId); + } else { + DependencyGuiSuggestedChange suggestedChange = data.addSuggestedChange(Localization.format("resolution.solution.replaceModVersionDifferent", + oldModsFormatted, + formatVersionRequirements(newMod.getVersionIntervals())), targetId); + addReplaceModVersionDifferentDetails(suggestedChange, fix, oldMod); + } + } + } + } + + private static void addReplaceModVersionDifferentDetails(DependencyGuiSuggestedChange suggestedChange, ModSolver.Fix fix, ModCandidateImpl oldMod) { + boolean foundAny = false; + + for (ModDependency dep : oldMod.getDependencies()) { + if (dep.getKind().isSoft()) continue; + + ModCandidateImpl mod = fix.activeMods.get(dep.getModId()); + + if (mod != null) { + if (dep.matches(mod.getVersion()) != dep.getKind().isPositive()) { + suggestedChange.addDetail(Localization.format("resolution.solution.replaceModVersionDifferent.reqSupportedModVersion", + mod.getId(), + getVersion(mod))); + foundAny = true; + } + + continue; + } + + for (AddModVar addMod : fix.modReplacements.keySet()) { + if (addMod.getId().equals(dep.getModId())) { + suggestedChange.addDetail(Localization.format("resolution.solution.replaceModVersionDifferent.reqSupportedModVersions", + addMod.getId(), + formatVersionRequirements(addMod.getVersionIntervals()))); + foundAny = true; + break; + } + } + } + + if (!foundAny) { + suggestedChange.addDetail(Localization.format("resolution.solution.replaceModVersionDifferent.unknown")); + } + } + + private static void addErrorToData(DependencyGuiData data, ModCandidateImpl mod, ModDependency dep, List matches) { + String dependencyId = dep.getModId(); + String dependencyDisplayName = matches.isEmpty() ? dependencyId : formatDisplayNameWithId(matches.get(0)); + String versionRequirement = formatVersionRequirements(dep.getVersionIntervals()); + DependencyGuiRequirementKind kind = dep.getKind() == ModDependency.Kind.BREAKS + ? DependencyGuiRequirementKind.CONFLICT + : DependencyGuiRequirementKind.DEPENDENCY; + + data.addDependency(dependencyId, dependencyDisplayName, versionRequirement, kind); + data.addAffectedMod(mod.getId(), getDisplayName(mod), getVersion(mod)).addRequirement(dependencyId, dependencyDisplayName, versionRequirement, kind); + } + private static void formatFix(ModSolver.Fix fix, ModSolver.Result result, Map selectedMods, Map> modsById, Map> envDisabledMods, EnvType envType, @@ -401,6 +567,138 @@ private static String formatOldMods(Collection mods) { return formatEnumeration(ret, true); } + private static void addIconSource(DependencyGuiData data, ModCandidateImpl candidate) { + if (candidate == null) { + return; + } + + addIconSource(data, candidate.getId(), candidate); + } + + private static void addIconSource(DependencyGuiData data, String id, ModCandidateImpl candidate) { + if (id == null || id.isEmpty() || candidate == null) { + return; + } + + Optional iconPath = candidate.getMetadata().getIconPath(32); + + if (!iconPath.isPresent()) { + return; + } + + List paths; + byte[] iconBytes = null; + + if (candidate.hasPath()) { + paths = candidate.getPaths().stream() + .map(Path::toString) + .collect(Collectors.toList()); + iconBytes = readIconBytes(candidate, iconPath.get()); + } else { + paths = Collections.emptyList(); + iconBytes = readIconBytes(candidate, iconPath.get()); + } + + data.addIconSource(id, iconPath.get(), paths, iconBytes); + } + + private static byte[] readIconBytes(ModCandidateImpl candidate, String iconPath) { + if (candidate.hasPath()) { + byte[] iconBytes = readIconBytes(candidate.getPaths(), iconPath); + + if (iconBytes != null) { + return iconBytes; + } + } + + if (candidate.isBuiltin()) { + return null; + } + + Path tempDir = null; + Path tempPath = null; + + try { + tempDir = Files.createTempDirectory("fabric-loader-icon"); + tempPath = candidate.copyToDir(tempDir, true); + return readIconBytes(Collections.singletonList(tempPath), iconPath); + } catch (IOException | RuntimeException ignored) { + return null; + } finally { + if (tempPath != null) { + try { + Files.deleteIfExists(tempPath); + } catch (IOException ignored) { + // Ignore cleanup failure. + } + } + + if (tempDir != null) { + try { + Files.deleteIfExists(tempDir); + } catch (IOException ignored) { + // Ignore cleanup failure. + } + } + } + } + + private static byte[] readIconBytes(List paths, String iconPath) { + String normalizedIconPath = iconPath.replace('\\', '/'); + + for (Path path : paths) { + try { + if (Files.isDirectory(path)) { + Path resolvedIconPath = path; + + for (String part : normalizedIconPath.split("/")) { + if (!part.isEmpty()) { + resolvedIconPath = resolvedIconPath.resolve(part); + } + } + + if (Files.isRegularFile(resolvedIconPath)) { + return Files.readAllBytes(resolvedIconPath); + } + } else { + try (ZipFile zip = new ZipFile(path.toFile())) { + ZipEntry entry = zip.getEntry(normalizedIconPath); + + if (entry != null) { + try (InputStream input = zip.getInputStream(entry)) { + return readAllBytes(input); + } + } + } + } + } catch (IOException ignored) { + // Invalid or unreadable icons should not prevent the error UI from opening. + } + } + + return null; + } + + private static byte[] readAllBytes(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + + while ((read = input.read(buffer)) >= 0) { + output.write(buffer, 0, read); + } + + return output.toByteArray(); + } + + private static String getDisplayName(ModCandidateImpl candidate) { + return candidate.getMetadata().getName(); + } + + private static String formatDisplayNameWithId(ModCandidateImpl candidate) { + return String.format("%s (%s)", getDisplayName(candidate), candidate.getId()); + } + private static String getName(ModCandidateImpl candidate) { String typePrefix; diff --git a/src/main/java/net/fabricmc/loader/impl/gui/FabricGuiEntry.java b/src/main/java/net/fabricmc/loader/impl/gui/FabricGuiEntry.java index 9fd4b49c8..f72d3c27e 100644 --- a/src/main/java/net/fabricmc/loader/impl/gui/FabricGuiEntry.java +++ b/src/main/java/net/fabricmc/loader/impl/gui/FabricGuiEntry.java @@ -29,6 +29,8 @@ import net.fabricmc.loader.impl.FabricLoaderImpl; import net.fabricmc.loader.impl.game.GameProvider; +import net.fabricmc.loader.impl.discovery.ModResolutionException; +import net.fabricmc.loader.impl.gui.FabricStatusTree.DependencyGuiData; import net.fabricmc.loader.impl.gui.FabricStatusTree.FabricBasicButtonType; import net.fabricmc.loader.impl.gui.FabricStatusTree.FabricStatusTab; import net.fabricmc.loader.impl.gui.FabricStatusTree.FabricTreeWarningLevel; @@ -97,6 +99,22 @@ public static void main(String[] args) throws Exception { System.exit(0); } + private static DependencyGuiData findDependencyGuiData(Throwable exception) { + while (exception != null) { + if (exception instanceof ModResolutionException) { + DependencyGuiData data = ((ModResolutionException) exception).getDependencyGuiData(); + + if (data != null) { + return data; + } + } + + exception = exception.getCause(); + } + + return null; + } + /** @param exitAfter If true then this will call {@link System#exit(int)} after showing the gui, otherwise this will * return normally. */ public static void displayCriticalError(Throwable exception, boolean exitAfter) { @@ -128,6 +146,12 @@ public static void displayError(String mainText, Throwable exception, Consumer modIconCache = new HashMap<>(); + private static final Map uiIconCache = new HashMap<>(); + private static Map dependencyGuiIconSources = java.util.Collections.emptyMap(); + private static JComponent suggestedChangesSection; + + private static final Color ERROR = new Color(232, 65, 75); + private static final Color INFO = new Color(38, 112, 218); + private static final int PAGE_MARGIN = 32; static void open(FabricStatusTree tree, boolean shouldWait) throws Exception { if (GraphicsEnvironment.isHeadless()) { throw new HeadlessException(); } - // Set MacOS specific system props + // Set macOS specific system props System.setProperty("apple.awt.application.appearance", "system"); System.setProperty("apple.awt.application.name", tree.title); - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + setupLookAndFeel(); + open0(tree, shouldWait); } + private static void setupLookAndFeel() { + ClassLoader oldContextClassLoader = Thread.currentThread().getContextClassLoader(); + + try { + LookAndFeel lookAndFeel = createFlatLookAndFeel(); + ClassLoader flatLafClassLoader = lookAndFeel.getClass().getClassLoader(); + + Thread.currentThread().setContextClassLoader(flatLafClassLoader); + UIManager.setLookAndFeel(lookAndFeel); + setUiDefaultsClassLoader(flatLafClassLoader); + + if (!isLookAndFeelUsable()) { + throw new IllegalStateException("FlatLaf did not install complete Swing UI defaults"); + } + } catch (Throwable t) { + setupFallbackLookAndFeel(oldContextClassLoader); + } finally { + Thread.currentThread().setContextClassLoader(oldContextClassLoader); + } + } + + private static LookAndFeel createFlatLookAndFeel() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + boolean macOS = osName.contains("mac") || osName.contains("darwin"); + boolean darkMode = Boolean.parseBoolean(System.getProperty("fabric.loader.gui.darkMode")); + + if (macOS) { + return darkMode ? new FlatMacDarkLaf() : new FlatMacLightLaf(); + } + + return darkMode ? new FlatDarkLaf() : new FlatLightLaf(); + } + + private static void setupFallbackLookAndFeel(ClassLoader contextClassLoader) { + try { + Thread.currentThread().setContextClassLoader(contextClassLoader); + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + + if (!isLookAndFeelUsable()) { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } + } catch (Throwable ignored) { + try { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } catch (Throwable ignoredAgain) { + // Swing will report the original problem when creating components. + } + } + } + + private static void setUiDefaultsClassLoader(ClassLoader classLoader) { + UIDefaults defaults = UIManager.getDefaults(); + + if (defaults != null && classLoader != null) { + defaults.put("ClassLoader", classLoader); + } + } + + private static boolean isLookAndFeelUsable() { + UIDefaults defaults = UIManager.getDefaults(); + return defaults != null + && canLoadUiDelegate(defaults, "PanelUI") + && canLoadUiDelegate(defaults, "LabelUI") + && canLoadUiDelegate(defaults, "ButtonUI") + && canLoadUiDelegate(defaults, "RootPaneUI") + && UIManager.getFont("Label.font") != null; + } + + private static boolean canLoadUiDelegate(UIDefaults defaults, String key) { + Object value = defaults.get(key); + + if (value == null) { + return false; + } + + if (!(value instanceof String)) { + return true; + } + + ClassLoader classLoader = (ClassLoader) defaults.get("ClassLoader"); + + if (classLoader == null) { + classLoader = Thread.currentThread().getContextClassLoader(); + } + + try { + Class.forName((String) value, false, classLoader); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + private static void open0(FabricStatusTree tree, boolean shouldWait) throws Exception { CountDownLatch guiTerminatedLatch = new CountDownLatch(1); SwingUtilities.invokeAndWait(() -> { + Thread.currentThread().setContextClassLoader(FabricMainWindow.class.getClassLoader()); createUi(guiTerminatedLatch, tree); }); @@ -101,6 +247,8 @@ private static void open0(FabricStatusTree tree, boolean shouldWait) throws Exce } private static void createUi(CountDownLatch onCloseLatch, FabricStatusTree tree) { + suggestedChangesSection = null; + JFrame window = new JFrame(); window.setVisible(false); window.setTitle(tree.title); @@ -113,8 +261,8 @@ private static void createUi(CountDownLatch onCloseLatch, FabricStatusTree tree) e.printStackTrace(); } - window.setMinimumSize(new Dimension(640, 480)); - window.setPreferredSize(new Dimension(800, 480)); + window.setMinimumSize(new Dimension(720, 500)); + window.setPreferredSize(new Dimension(980, 640)); window.setLocationByPlatform(true); window.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); window.addWindowListener(new WindowAdapter() { @@ -125,73 +273,772 @@ public void windowClosed(WindowEvent e) { }); Container contentPane = window.getContentPane(); + contentPane.setLayout(new BorderLayout()); + contentPane.add(createHeader(tree), BorderLayout.NORTH); + contentPane.add(createMainContent(tree), BorderLayout.CENTER); + + if (!tree.buttons.isEmpty()) { + contentPane.add(createButtonPanel(window, onCloseLatch, tree.buttons), BorderLayout.SOUTH); + } + + sizeWindowToShowSuggestedChanges(window); + window.setVisible(true); + window.requestFocus(); + } + + private static void sizeWindowToShowSuggestedChanges(JFrame window) { + window.pack(); + + Dimension min = new Dimension(980, 640); + window.setSize(Math.max(window.getWidth(), min.width), Math.max(window.getHeight(), min.height)); + + if (suggestedChangesSection == null) { + return; + } + + SwingUtilities.invokeLater(() -> { + JScrollPane scrollPane = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, suggestedChangesSection); + + if (scrollPane == null) { + return; + } + + Rectangle sectionBounds = SwingUtilities.convertRectangle( + suggestedChangesSection.getParent(), + suggestedChangesSection.getBounds(), + scrollPane.getViewport().getView()); + int visibleBottom = scrollPane.getViewport().getViewPosition().y + scrollPane.getViewport().getExtentSize().height; + int neededExtra = sectionBounds.y + sectionBounds.height - visibleBottom; + + if (neededExtra <= 0) { + return; + } + + GraphicsConfiguration gc = window.getGraphicsConfiguration(); + Rectangle screen = gc.getBounds(); + Insets insets = Toolkit.getDefaultToolkit().getScreenInsets(gc); + int maxHeight = screen.height - insets.top - insets.bottom - 80; + int newHeight = Math.min(window.getHeight() + neededExtra, maxHeight); + + if (newHeight > window.getHeight()) { + window.setSize(window.getWidth(), newHeight); + } + }); + } + + private static JPanel createHeader(FabricStatusTree tree) { + JPanel header = new JPanel(new BorderLayout(18, 0)); + header.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, borderColor()), + BorderFactory.createEmptyBorder(26, PAGE_MARGIN, 26, PAGE_MARGIN))); + + JLabel icon = new JLabel(loadUiIcon("/ui/icon/error_x24.png", 66, ERROR)); + header.add(icon, BorderLayout.WEST); + + JPanel text = new JPanel(); + text.setOpaque(false); + text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS)); + + JLabel title = new JLabel(stripHtml(tree.mainText == null || tree.mainText.isEmpty() ? tree.title : tree.mainText)); + title.setFont(deriveFont(title, Font.BOLD, 2.0f)); + text.add(title); + text.add(Box.createVerticalStrut(6)); + + JLabel subtitle = new JLabel(Localization.format(isIncompatibleMods(tree) + ? "gui.dependency.subtitle.incompatible" + : "gui.dependency.subtitle.generic")); + subtitle.setFont(deriveFont(subtitle, 1.2f)); + subtitle.setForeground(secondaryTextColor()); + text.add(subtitle); + + header.add(text, BorderLayout.CENTER); + return header; + } + + private static Component createMainContent(FabricStatusTree tree) { + DependencyGuiData structuredData = tree.getDependencyGuiData(); + dependencyGuiIconSources = structuredData != null ? structuredData.iconSources : java.util.Collections.emptyMap(); + + if (structuredData != null) { + DependencyUiData data = DependencyUiData.from(structuredData); + + if (data.hasContent()) { + return createDependencyPanel(data); + } + } + + return createGeneralPanel(tree); + } + + private static Component createDependencyPanel(DependencyUiData data) { + if (!data.modIssues.isEmpty()) { + return createModIssueBrowser(data); + } + + JPanel page = new JPanel(); + page.setLayout(new BoxLayout(page, BoxLayout.Y_AXIS)); + page.setBorder(BorderFactory.createEmptyBorder(28, PAGE_MARGIN, 28, PAGE_MARGIN)); + + int section = 1; + + if (!data.otherActions.isEmpty()) { + JComponent suggestedSection = leftAligned(createNumberedSection(section++, Localization.format("gui.dependency.section.suggestedChanges"), + Localization.format("gui.dependency.section.suggestedChanges.desc"), createActionRows(data))); + suggestedChangesSection = suggestedSection; + page.add(suggestedSection); + } + + if (!data.dependencies.isEmpty()) { + if (section > 1) { + page.add(createSectionGap()); + page.add(createPageSeparator()); + page.add(createSectionGap()); + } + + page.add(leftAligned(createNumberedSection(section++, Localization.format("gui.dependency.section.whatsMissing"), + Localization.format("gui.dependency.section.whatsMissing.desc"), createDependencyRows(data.dependencies.values())))); + } + + if (!data.conflicts.isEmpty()) { + if (section > 1) { + page.add(createSectionGap()); + page.add(createPageSeparator()); + page.add(createSectionGap()); + } + + page.add(leftAligned(createNumberedSection(section++, Localization.format("gui.dependency.section.conflictsOverview"), + Localization.format("gui.dependency.section.conflictsOverview.desc"), createDependencyRows(data.conflicts.values())))); + } + + if (!data.dependants.isEmpty()) { + if (section > 1) { + page.add(createSectionGap()); + page.add(createPageSeparator()); + page.add(createSectionGap()); + } + + page.add(leftAligned(createNumberedSection(section, Localization.format("gui.dependency.section.whoNeedsIt"), + Localization.format("gui.dependency.section.whoNeedsIt.desc"), createDependantRows(data)))); + } + + return wrapScrollable(page); + } + + private static Component createModIssueBrowser(DependencyUiData data) { + CardLayout layout = new CardLayout(); + JPanel cards = new JPanel(layout); + Map cardNames = new LinkedHashMap<>(); + + for (ModIssue issue : data.modIssues.values()) { + cardNames.put(issue.getKey(), "detail-" + cardNames.size()); + } + + cards.add(wrapScrollable(createModIssueOverviewPage(data, issue -> layout.show(cards, cardNames.get(issue.getKey())))), "overview"); + + for (ModIssue issue : data.modIssues.values()) { + cards.add(wrapScrollable(createModIssueDetailPage(issue, () -> layout.show(cards, "overview"))), cardNames.get(issue.getKey())); + } + + return cards; + } + + private static JScrollPane wrapScrollable(Component component) { + JScrollPane scrollPane = new JScrollPane(component); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + return scrollPane; + } + + private static JPanel createModIssueOverviewPage(DependencyUiData data, Consumer onSelect) { + JPanel page = new JPanel(); + page.setLayout(new BoxLayout(page, BoxLayout.Y_AXIS)); + page.setBorder(BorderFactory.createEmptyBorder(28, PAGE_MARGIN, 28, PAGE_MARGIN)); + + int section = 1; + + if (!data.otherActions.isEmpty()) { + JComponent suggestedSection = leftAligned(createNumberedSection(section++, Localization.format("gui.dependency.section.suggestedChanges"), + Localization.format("gui.dependency.section.suggestedChanges.desc"), createActionRows(data))); + suggestedChangesSection = suggestedSection; + page.add(suggestedSection); + } + + if (section > 1) { + page.add(createSectionGap()); + page.add(createPageSeparator()); + page.add(createSectionGap()); + } + + page.add(leftAligned(createNumberedSection(section++, Localization.format("gui.dependency.section.affectedMods"), + Localization.format("gui.dependency.section.affectedMods.desc"), createModIssueRows(data, onSelect)))); + + if (!data.dependencies.isEmpty()) { + page.add(createSectionGap()); + page.add(createPageSeparator()); + page.add(createSectionGap()); + page.add(leftAligned(createNumberedSection(section++, Localization.format("gui.dependency.section.missingOverview"), + Localization.format("gui.dependency.section.missingOverview.desc"), createDependencyRows(data.dependencies.values())))); + } + + if (!data.conflicts.isEmpty()) { + page.add(createSectionGap()); + page.add(createPageSeparator()); + page.add(createSectionGap()); + page.add(leftAligned(createNumberedSection(section, Localization.format("gui.dependency.section.conflictsOverview"), + Localization.format("gui.dependency.section.conflictsOverview.desc"), createDependencyRows(data.conflicts.values())))); + } + + return page; + } + + private static JPanel createModIssueRows(DependencyUiData data, Consumer onSelect) { + JPanel rows = createRowsPanel(); + + for (ModIssue issue : data.modIssues.values()) { + RoundedPanel row = createCardPanel(); + row.setLayout(new BorderLayout(18, 0)); + row.setBorder(BorderFactory.createEmptyBorder(12, 16, 12, 16)); + + JPanel left = new JPanel(new BorderLayout(14, 0)); + left.setOpaque(false); + Icon modIcon = loadModIcon(issue.modId, 34); + left.add(createIconLabel(modIcon != null ? modIcon : loadUiIcon("/ui/icon/document_x24.png", 34, secondaryTextColor()), 44), BorderLayout.WEST); + + JPanel text = new JPanel(); + text.setOpaque(false); + text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS)); + + JLabel name = new JLabel(html("" + escape(issue.modDisplayName) + "" + + (issue.modId.isEmpty() ? "" : " (" + escape(issue.modId) + ")"))); + name.setFont(deriveFont(name, 1.07f)); + name.setAlignmentX(Component.LEFT_ALIGNMENT); + text.add(name); + text.add(Box.createVerticalStrut(4)); + + JLabel summary = new JLabel(issue.getSummaryText()); + summary.setFont(deriveFont(summary, 1.0f)); + summary.setForeground(secondaryTextColor()); + summary.setAlignmentX(Component.LEFT_ALIGNMENT); + text.add(summary); + + left.add(centerVertically(text), BorderLayout.CENTER); + row.add(left, BorderLayout.CENTER); + + if (!issue.modVersion.isEmpty()) { + JLabel pill = createPill(issue.modVersion); + pill.setHorizontalAlignment(SwingConstants.CENTER); + pill.setPreferredSize(new Dimension(96, pill.getPreferredSize().height)); + row.add(pill, BorderLayout.EAST); + } + + attachSelectableRowHandler(row, () -> onSelect.accept(issue)); + + rows.add(row); + rows.add(Box.createVerticalStrut(8)); + } + + trimTrailingSpacer(rows); + return rows; + } + + private static JPanel createModIssueDetailPage(ModIssue issue, Runnable onBack) { + JPanel page = new JPanel(); + page.setLayout(new BoxLayout(page, BoxLayout.Y_AXIS)); + page.setBorder(BorderFactory.createEmptyBorder(28, PAGE_MARGIN, 28, PAGE_MARGIN)); + + JButton backButton = createSecondaryButton(Localization.format("gui.dependency.button.back")); + backButton.addActionListener(event -> onBack.run()); + page.add(leftAligned(backButton)); + page.add(Box.createVerticalStrut(18)); + + RoundedPanel hero = createCardPanel(); + hero.setLayout(new BorderLayout(16, 0)); + hero.setBorder(BorderFactory.createEmptyBorder(18, 20, 18, 20)); + + JPanel heroLeft = new JPanel(new BorderLayout(14, 0)); + heroLeft.setOpaque(false); + Icon modIcon = loadModIcon(issue.modId, 42); + heroLeft.add(new JLabel(modIcon != null ? modIcon : loadUiIcon("/ui/icon/document_x24.png", 42, secondaryTextColor())), BorderLayout.WEST); + + JPanel heroText = new JPanel(); + heroText.setOpaque(false); + heroText.setLayout(new BoxLayout(heroText, BoxLayout.Y_AXIS)); + + JLabel heroTitle = new JLabel(html("" + escape(issue.modDisplayName) + "" + + (issue.modId.isEmpty() ? "" : " (" + escape(issue.modId) + ")"))); + heroTitle.setFont(deriveFont(heroTitle, Font.BOLD, 1.50f)); + heroText.add(heroTitle); + heroText.add(Box.createVerticalStrut(6)); + + JLabel heroSubtitle = new JLabel(issue.getDetailSubtitle()); + heroSubtitle.setFont(deriveFont(heroSubtitle, 1.02f)); + heroSubtitle.setForeground(secondaryTextColor()); + heroText.add(heroSubtitle); + + heroLeft.add(heroText, BorderLayout.CENTER); + hero.add(heroLeft, BorderLayout.CENTER); + + if (!issue.modVersion.isEmpty()) { + JLabel pill = createPill(issue.modVersion); + pill.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(borderColor()), + BorderFactory.createEmptyBorder(8, 12, 8, 12))); + hero.add(pill, BorderLayout.EAST); + } + + page.add(leftAligned(hero)); + page.add(Box.createVerticalStrut(24)); + page.add(leftAligned(createDetailSection(issue.getDetailSectionTitle(), + issue.getDetailSectionDescription(), createModRequirementRows(issue)))); + + return page; + } - if (tree.mainText != null && !tree.mainText.isEmpty()) { - JLabel errorLabel = new JLabel(tree.mainText); - errorLabel.setHorizontalAlignment(SwingConstants.CENTER); - Font font = errorLabel.getFont(); - errorLabel.setFont(font.deriveFont(font.getSize() * 2.0f)); - contentPane.add(errorLabel, BorderLayout.NORTH); + private static JPanel createModRequirementRows(ModIssue issue) { + JPanel rows = createRowsPanel(); + + for (DependencyRequirement requirement : issue.getGroupedRequirements().values()) { + RoundedPanel row = createCardPanel(); + row.setLayout(new BorderLayout(16, 0)); + row.setBorder(BorderFactory.createEmptyBorder(14, 18, 14, 18)); + + JPanel left = new JPanel(new BorderLayout(14, 0)); + left.setOpaque(false); + Icon requirementIcon = loadModIcon(requirement.id, 30); + left.add(new JLabel(requirementIcon != null ? requirementIcon : loadUiIcon("/ui/icon/document_x24.png", 30, secondaryTextColor())), BorderLayout.WEST); + + JPanel text = new JPanel(); + text.setOpaque(false); + text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS)); + + JLabel name = new JLabel(requirement.displayName); + name.setFont(deriveFont(name, Font.BOLD, 1.10f)); + text.add(name); + + for (String version : requirement.versions) { + text.add(Box.createVerticalStrut(4)); + JLabel line = new JLabel(html("" + escape(formatIssueVersionText(version, requirement.conflict)) + "")); + line.setFont(deriveFont(line, 0.98f)); + text.add(line); + } + + left.add(text, BorderLayout.CENTER); + row.add(left, BorderLayout.CENTER); + + rows.add(row); + rows.add(Box.createVerticalStrut(8)); + } + + trimTrailingSpacer(rows); + return rows; + } + + private static JPanel createNumberedSection(int number, String title, String description, Component rows) { + JPanel section = new JPanel(new BorderLayout(16, 0)); + section.setOpaque(false); + section.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel badge = new JLabel(Integer.toString(number), SwingConstants.CENTER); + badge.setOpaque(false); + badge.setForeground(Color.WHITE); + badge.setFont(deriveFontSize(badge, Font.BOLD, 15f)); + badge.setIcon(loadUiIcon("/ui/icon/circle_x24.png", 38, ERROR)); + badge.setHorizontalTextPosition(SwingConstants.CENTER); + badge.setVerticalTextPosition(SwingConstants.CENTER); + badge.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); + section.add(badge, BorderLayout.WEST); + + JPanel content = new JPanel(); + content.setOpaque(false); + content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS)); + + JLabel titleLabel = new JLabel(title); + titleLabel.setFont(deriveFont(titleLabel, Font.BOLD, 1.30f)); + titleLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + content.add(titleLabel); + content.add(Box.createVerticalStrut(6)); + + JLabel descriptionLabel = new JLabel(description); + descriptionLabel.setFont(deriveFont(descriptionLabel, 1.0f)); + descriptionLabel.setForeground(secondaryTextColor()); + descriptionLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + content.add(descriptionLabel); + content.add(Box.createVerticalStrut(14)); + + if (rows instanceof JComponent) { + ((JComponent) rows).setAlignmentX(Component.LEFT_ALIGNMENT); + } + + content.add(rows); + section.add(content, BorderLayout.CENTER); + return section; + } + + private static JPanel createDependencyRows(Iterable dependencies) { + JPanel rows = createRowsPanel(); + + for (DependencyRequirement dependency : dependencies) { + RoundedPanel row = createCardPanel(); + row.setLayout(new BorderLayout(16, 0)); + row.setBorder(BorderFactory.createEmptyBorder(14, 18, 14, 18)); + + JPanel left = new JPanel(new BorderLayout(14, 0)); + left.setOpaque(false); + Icon dependencyIcon = loadModIcon(dependency.id, 30); + left.add(new JLabel(dependencyIcon != null ? dependencyIcon : loadUiIcon("/ui/icon/document_x24.png", 30, secondaryTextColor())), BorderLayout.WEST); + + JLabel name = new JLabel(dependency.displayName); + name.setFont(deriveFont(name, Font.BOLD, 1.12f)); + left.add(name, BorderLayout.CENTER); + row.add(left, BorderLayout.CENTER); + + JLabel version = new JLabel(html("" + escape(formatIssueVersionText(dependency.getSummaryVersion(), dependency.conflict)) + "")); + version.setFont(deriveFont(version, 1.0f)); + row.add(version, BorderLayout.EAST); + + rows.add(row); + rows.add(Box.createVerticalStrut(8)); + } + + trimTrailingSpacer(rows); + return rows; + } + + private static JPanel createDependantRows(DependencyUiData data) { + JPanel rows = createRowsPanel(); + + for (DependantRequirement dependant : data.dependants) { + RoundedPanel row = createCardPanel(); + row.setBackground(tintedColor(ERROR, 0.06f)); + row.setBorderColor(tintedColor(ERROR, 0.22f)); + row.setLayout(new BorderLayout(16, 0)); + row.setBorder(BorderFactory.createEmptyBorder(14, 18, 14, 18)); + + JPanel left = new JPanel(new BorderLayout(14, 0)); + left.setOpaque(false); + Icon modIcon = loadModIcon(dependant.modId, 34); + left.add(new JLabel(modIcon != null ? modIcon : loadUiIcon("/ui/icon/document_x24.png", 34, secondaryTextColor())), BorderLayout.WEST); + + JPanel text = new JPanel(); + text.setOpaque(false); + text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS)); + + JLabel name = new JLabel(html("" + escape(dependant.modDisplayName) + "" + + (dependant.modId.isEmpty() ? "" : " (" + escape(dependant.modId) + ")"))); + name.setFont(deriveFont(name, 1.05f)); + text.add(name); + text.add(Box.createVerticalStrut(3)); + + JLabel requires = new JLabel(html(escape(Localization.format(dependant.conflict ? "gui.dependency.conflictsWith" : "gui.dependency.requires")) + " " + escape(dependant.dependencyDisplayName) + "" + + (dependant.requiredVersion.isEmpty() ? "" : " (" + escape(formatIssueVersionText(dependant.requiredVersion, dependant.conflict)) + ")"))); + requires.setFont(deriveFont(requires, 1.0f)); + text.add(requires); + + left.add(text, BorderLayout.CENTER); + row.add(left, BorderLayout.CENTER); + + if (!dependant.modVersion.isEmpty()) { + JLabel pill = createPill(dependant.modVersion); + row.add(pill, BorderLayout.EAST); + } + + rows.add(row); + rows.add(Box.createVerticalStrut(8)); + } + + trimTrailingSpacer(rows); + return rows; + } + + private static JPanel createActionRows(DependencyUiData data) { + JPanel rows = createRowsPanel(); + + for (SuggestedAction action : data.otherActions) { + RoundedPanel row = createCardPanel(); + row.setLayout(new BorderLayout(12, 0)); + row.setBorder(BorderFactory.createEmptyBorder(14, 18, 14, 18)); + row.add(new JLabel(getActionIcon(action.targetId, 24)), BorderLayout.WEST); + + JPanel text = new JPanel(); + text.setOpaque(false); + text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS)); + + JLabel main = new JLabel(action.text); + main.setAlignmentX(Component.LEFT_ALIGNMENT); + text.add(main); + + for (String detail : action.details) { + text.add(Box.createVerticalStrut(4)); + JLabel detailLabel = new JLabel(html("• " + escape(detail))); + detailLabel.setFont(deriveFont(detailLabel, 0.96f)); + detailLabel.setForeground(secondaryTextColor()); + detailLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + text.add(detailLabel); + } + + row.add(text, BorderLayout.CENTER); + rows.add(row); + rows.add(Box.createVerticalStrut(8)); + } + + trimTrailingSpacer(rows); + return rows; + } + + private static Icon getActionIcon(String targetId, int size) { + if (targetId != null && !targetId.isEmpty()) { + Icon icon = loadModIcon(targetId, size); + + if (icon != null) { + return icon; + } + } + + return loadUiIcon("/ui/icon/document_x24.png", size, secondaryTextColor()); + } + + private static JPanel createDetailSection(String title, String description, Component rows) { + JPanel section = new JPanel(); + section.setOpaque(false); + section.setLayout(new BoxLayout(section, BoxLayout.Y_AXIS)); + section.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel titleLabel = new JLabel(title); + titleLabel.setFont(deriveFont(titleLabel, Font.BOLD, 1.30f)); + titleLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + section.add(titleLabel); + section.add(Box.createVerticalStrut(6)); + + JLabel descriptionLabel = new JLabel(description); + descriptionLabel.setFont(deriveFont(descriptionLabel, 1.0f)); + descriptionLabel.setForeground(secondaryTextColor()); + descriptionLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + section.add(descriptionLabel); + section.add(Box.createVerticalStrut(14)); + + if (rows instanceof JComponent) { + ((JComponent) rows).setAlignmentX(Component.LEFT_ALIGNMENT); + } + + section.add(rows); + return section; + } + + private static JLabel createIconLabel(Icon icon, int size) { + JLabel label = new JLabel(icon, SwingConstants.CENTER); + label.setVerticalAlignment(SwingConstants.CENTER); + Dimension dimension = new Dimension(size, size); + label.setMinimumSize(dimension); + label.setPreferredSize(dimension); + label.setMaximumSize(dimension); + return label; + } + + private static JPanel centerVertically(Component component) { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setOpaque(false); + GridBagConstraints constraints = new GridBagConstraints(); + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.weightx = 1.0; + panel.add(component, constraints); + return panel; + } + + private static JPanel createRowsPanel() { + JPanel rows = new JPanel(); + rows.setOpaque(false); + rows.setLayout(new BoxLayout(rows, BoxLayout.Y_AXIS)); + rows.setAlignmentX(Component.LEFT_ALIGNMENT); + return rows; + } + + private static RoundedPanel createCardPanel() { + RoundedPanel panel = new RoundedPanel(10); + panel.setBackground(cardColor()); + panel.setBorderColor(borderColor()); + panel.setAlignmentX(Component.LEFT_ALIGNMENT); + return panel; + } + + private static JLabel createPill(String text) { + JLabel label = new JLabel(text); + label.setOpaque(true); + label.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(borderColor()), + BorderFactory.createEmptyBorder(6, 10, 6, 10))); + label.setBackground(backgroundColor()); + return label; + } + + private static JButton createSecondaryButton(String text) { + JButton button = new JButton(text); + button.putClientProperty("JButton.buttonType", "roundRect"); + button.putClientProperty("JButton.arc", 999); + button.putClientProperty("JComponent.minimumWidth", 132); + button.setFocusable(false); + return button; + } + + private static JSeparator createPageSeparator() { + JSeparator separator = new JSeparator(); + separator.setAlignmentX(Component.LEFT_ALIGNMENT); + return separator; + } + + private static Component createSectionGap() { + return Box.createVerticalStrut(24); + } + + private static void attachSelectableRowHandler(RoundedPanel row, Runnable action) { + Color normalBorder = borderColor(); + Color hoverBorder = accentColor(); + Color pressedBorder = darkerColor(accentColor(), 0.78f); + Color normalBackground = cardColor(); + Color hoverBackground = tintedColor(INFO, 0.06f); + Color pressedBackground = tintedColor(INFO, 0.12f); + + MouseAdapter listener = new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + applyRowState(row, hoverBorder, hoverBackground); + } + + @Override + public void mouseExited(MouseEvent e) { + if (!row.contains(SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), row))) { + applyRowState(row, normalBorder, normalBackground); + } + } + + @Override + public void mousePressed(MouseEvent e) { + applyRowState(row, pressedBorder, pressedBackground); + } + + @Override + public void mouseReleased(MouseEvent e) { + boolean inside = row.contains(SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), row)); + + if (inside) { + // Reset before switching cards so the overview row is not left highlighted when navigating back. + applyRowState(row, normalBorder, normalBackground); + action.run(); + } else { + applyRowState(row, normalBorder, normalBackground); + } + } + }; + + installRowMouseHandler(row, listener); + } + + private static void applyRowState(RoundedPanel row, Color border, Color background) { + row.setBorderColor(border); + row.setBackground(background); + row.repaint(); + } + + private static void installRowMouseHandler(Component component, MouseAdapter listener) { + component.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + component.addMouseListener(listener); + + if (component instanceof Container) { + for (Component child : ((Container) component).getComponents()) { + installRowMouseHandler(child, listener); + } } + } + + private static T leftAligned(T component) { + component.setAlignmentX(Component.LEFT_ALIGNMENT); + return component; + } + + private static void trimTrailingSpacer(JPanel panel) { + int count = panel.getComponentCount(); + if (count > 0) { + panel.remove(count - 1); + } + } + + private static Component createGeneralPanel(FabricStatusTree tree) { IconSet icons = new IconSet(); if (tree.tabs.isEmpty()) { - FabricStatusTab tab = new FabricStatusTab("Opening Errors"); - tab.addChild("No tabs provided! (Something is very broken)").setError(); - contentPane.add(createTreePanel(tab.node, tab.filterLevel, icons), BorderLayout.CENTER); + FabricStatusTab tab = new FabricStatusTab(Localization.format("gui.error.openingErrors")); + tab.addChild(Localization.format("gui.error.noTabs")).setError(); + return createTreePanel(tab.node, tab.filterLevel, icons); } else if (tree.tabs.size() == 1) { FabricStatusTab tab = tree.tabs.get(0); - contentPane.add(createTreePanel(tab.node, tab.filterLevel, icons), BorderLayout.CENTER); + return createTreePanel(tab.node, tab.filterLevel, icons); } else { JTabbedPane tabs = new JTabbedPane(); - contentPane.add(tabs, BorderLayout.CENTER); for (FabricStatusTab tab : tree.tabs) { tabs.addTab(tab.node.name, createTreePanel(tab.node, tab.filterLevel, icons)); } + + return tabs; } + } - if (!tree.buttons.isEmpty()) { - JPanel buttons = new JPanel(); - contentPane.add(buttons, BorderLayout.SOUTH); - buttons.setLayout(new FlowLayout(FlowLayout.TRAILING)); - - for (FabricStatusButton button : tree.buttons) { - JButton btn = new JButton(button.text); - buttons.add(btn); - btn.addActionListener(event -> { - if (button.type == FabricBasicButtonType.CLICK_ONCE) btn.setEnabled(false); - - if (button.clipboard != null) { - try { - StringSelection clipboard = new StringSelection(button.clipboard); - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(clipboard, clipboard); - } catch (IllegalStateException e) { - //Clipboard unavailable? - } - } + private static JPanel createButtonPanel(JFrame window, CountDownLatch onCloseLatch, List sourceButtons) { + JPanel outer = new JPanel(new BorderLayout()); + outer.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, borderColor()), + BorderFactory.createEmptyBorder(18, PAGE_MARGIN, 18, PAGE_MARGIN))); - if (button.shouldClose) { - window.dispose(); - } + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEADING, 0, 0)); + left.setOpaque(false); + JPanel right = new JPanel(new FlowLayout(FlowLayout.TRAILING, 10, 0)); + right.setOpaque(false); + + for (FabricStatusButton button : sourceButtons) { + JButton btn = new JButton(button.text); + + if (button.clipboard != null) { + btn.setIcon(loadUiIcon("/ui/icon/clipboard_x24.png", 18, INFO)); + } - if (button.shouldContinue) { - onCloseLatch.countDown(); + btn.addActionListener(event -> { + if (button.type == FabricBasicButtonType.CLICK_ONCE) btn.setEnabled(false); + + if (button.clipboard != null) { + try { + StringSelection clipboard = new StringSelection(button.clipboard); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(clipboard, clipboard); + } catch (IllegalStateException e) { + // Clipboard unavailable? } - }); + } + + if (button.shouldClose) { + window.dispose(); + } + + if (button.shouldContinue) { + onCloseLatch.countDown(); + } + }); + + if (button.clipboard != null && !button.shouldClose) { + left.add(btn); + } else { + right.add(btn); } } - window.pack(); - window.setVisible(true); - window.requestFocus(); + outer.add(left, BorderLayout.WEST); + outer.add(right, BorderLayout.EAST); + return outer; } private static JPanel createTreePanel(FabricStatusNode rootNode, FabricTreeWarningLevel minimumWarningLevel, IconSet iconSet) { - JPanel panel = new JPanel(); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + JPanel panel = new JPanel(new BorderLayout()); + panel.setBorder(BorderFactory.createEmptyBorder(18, PAGE_MARGIN, 18, PAGE_MARGIN)); TreeNode treeNode = new CustomTreeNode(null, rootNode, minimumWarningLevel); @@ -216,7 +1063,8 @@ private static JPanel createTreePanel(FabricStatusNode rootNode, FabricTreeWarni tree.setCellRenderer(new CustomTreeCellRenderer(iconSet)); JScrollPane scrollPane = new JScrollPane(tree); - panel.add(scrollPane); + scrollPane.setBorder(BorderFactory.createLineBorder(borderColor())); + panel.add(scrollPane, BorderLayout.CENTER); return panel; } @@ -248,19 +1096,671 @@ private static void setTaskBarImage(Image image) { } } - static final class IconSet { - /** Map of IconInfo -> Integer Size -> Real Icon. */ - private final Map> icons = new HashMap<>(); + private static Font defaultFont() { + Font font = UIManager.getFont("Label.font"); + return font != null ? font : new Font(Font.DIALOG, Font.PLAIN, 12); + } - public Icon get(IconInfo info) { - // TODO: HDPI + private static Font deriveFont(Component component, float scale) { + Font font = component.getFont(); + if (font == null) font = defaultFont(); + return font.deriveFont(Math.max(1f, font.getSize2D() * scale)); + } - int scale = 16; - Map map = icons.get(info); + private static Font deriveFont(Component component, int style, float scale) { + Font font = component.getFont(); + if (font == null) font = defaultFont(); + return font.deriveFont(style, Math.max(1f, font.getSize2D() * scale)); + } - if (map == null) { - icons.put(info, map = new HashMap<>()); - } + private static Font deriveFontSize(Component component, int style, float size) { + Font font = component.getFont(); + if (font == null) font = defaultFont(); + return font.deriveFont(style, Math.max(1f, size)); + } + + private static boolean isIncompatibleMods(FabricStatusTree tree) { + String text = tree.mainText == null ? "" : tree.mainText.toLowerCase(); + return text.contains("incompatible") && text.contains("mod"); + } + + private static Color backgroundColor() { + Color color = UIManager.getColor("Panel.background"); + return color == null ? Color.WHITE : color; + } + + private static Color cardColor() { + Color color = UIManager.getColor("TextField.background"); + return color == null ? backgroundColor() : color; + } + + private static Color borderColor() { + Color color = UIManager.getColor("Component.borderColor"); + return color == null ? new Color(210, 214, 220) : color; + } + + private static Color secondaryTextColor() { + Color color = UIManager.getColor("Label.disabledForeground"); + return color == null ? new Color(105, 110, 118) : color; + } + + private static Color accentColor() { + Color color = UIManager.getColor("Component.focusColor"); + return color == null ? INFO : color; + } + + private static Color darkerColor(Color color, float multiplier) { + return new Color( + Math.max(0, Math.round(color.getRed() * multiplier)), + Math.max(0, Math.round(color.getGreen() * multiplier)), + Math.max(0, Math.round(color.getBlue() * multiplier))); + } + + private static Color tintedColor(Color color, float amount) { + Color base = backgroundColor(); + int r = Math.min(255, Math.round(base.getRed() * (1.0f - amount) + color.getRed() * amount)); + int g = Math.min(255, Math.round(base.getGreen() * (1.0f - amount) + color.getGreen() * amount)); + int b = Math.min(255, Math.round(base.getBlue() * (1.0f - amount) + color.getBlue() * amount)); + return new Color(r, g, b); + } + + private static String colorToHex(Color color) { + return String.format("#%02x%02x%02x", color.getRed(), color.getGreen(), color.getBlue()); + } + + private static String formatVersionText(String version) { + if (version.startsWith("version ")) { + return Localization.format("gui.dependency.versionPrefix", version.substring("version ".length())); + } + + return version; + } + + private static String formatIssueVersionText(String version, boolean conflict) { + String formatted = formatVersionText(version); + + if (formatted.isEmpty()) { + return formatted; + } + + return conflict ? Localization.format("gui.dependency.conflict.version", formatted) : formatted; + } + + private static Icon loadModIcon(String modId, int size) { + if (modId == null || modId.isEmpty()) { + return null; + } + + String cacheKey = modId + "@" + size; + Icon cached = modIconCache.get(cacheKey); + + if (cached != null) { + return cached; + } + + Icon icon = findModIcon(modId, size); + + if (icon != null) { + modIconCache.put(cacheKey, icon); + } + + return icon; + } + + private static Icon findModIcon(String modId, int size) { + Icon bundledIcon = loadBundledModIcon(modId, size); + + if (bundledIcon != null) { + return bundledIcon; + } + + DependencyGuiIconSource iconSource = dependencyGuiIconSources.get(modId); + + if (iconSource != null) { + Icon icon = loadIconFromSerializedSource(iconSource, size); + + if (icon != null) { + return icon; + } + } + + for (ModCandidateImpl candidate : getDiscoveredModCandidates()) { + if (!modId.equals(candidate.getId())) { + continue; + } + + Optional iconPath = candidate.getMetadata().getIconPath(size); + + if (!iconPath.isPresent() || !candidate.hasPath()) { + continue; + } + + Icon icon = loadIconFromModPaths(candidate.getPaths(), iconPath.get(), size); + + if (icon != null) { + return icon; + } + } + + return null; + } + + private static Icon loadBundledModIcon(String modId, int size) { + if ("minecraft".equals(modId)) { + return loadBundledIcon("/ui/icon/minecraft_x32.png", size); + } + + if ("java".equals(modId)) { + return loadBundledIcon("/ui/icon/java_x32.png", size); + } + + return null; + } + + private static Icon loadBundledIcon(String path, int size) { + try { + BufferedImage image = loadImage(path); + return new ImageIcon(scaleImage(image, size)); + } catch (IOException e) { + return null; + } + } + + private static Icon loadUiIcon(String path, int size, Color color) { + String cacheKey = path + "@" + size + "@" + color.getRGB(); + Icon cached = uiIconCache.get(cacheKey); + + if (cached != null) { + return cached; + } + + try { + BufferedImage image = tintImage(loadImage(path), color); + Icon icon = new ImageIcon(scaleImage(image, size)); + uiIconCache.put(cacheKey, icon); + return icon; + } catch (IOException e) { + return missingIcon(); + } + } + + private static BufferedImage tintImage(BufferedImage image, Color color) { + BufferedImage tinted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB); + int colorRgb = color.getRGB() & 0x00_FF_FF_FF; + + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int alpha = image.getRGB(x, y) >>> 24; + + if (alpha != 0) { + tinted.setRGB(x, y, (alpha << 24) | colorRgb); + } + } + } + + return tinted; + } + + private static Optional findModDisplayName(String modId) { + if (modId == null || modId.isEmpty()) { + return Optional.empty(); + } + + for (ModCandidateImpl candidate : getDiscoveredModCandidates()) { + if (modId.equals(candidate.getId())) { + String name = candidate.getMetadata().getName(); + + if (name != null && !name.isEmpty()) { + return Optional.of(name); + } + + return Optional.of(candidate.getId()); + } + } + + return Optional.empty(); + } + + @SuppressWarnings("unchecked") + private static List getDiscoveredModCandidates() { + try { + Field field = FabricLoaderImpl.class.getDeclaredField("modCandidates"); + field.setAccessible(true); + Object value = field.get(FabricLoaderImpl.INSTANCE); + + if (value instanceof List) { + return (List) value; + } + } catch (Throwable ignored) { + // The GUI can also run in a forked process where discovered mod candidates are unavailable. + } + + return java.util.Collections.emptyList(); + } + + private static Icon loadIconFromSerializedSource(DependencyGuiIconSource iconSource, int size) { + if (iconSource.iconBytes.length > 0) { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(iconSource.iconBytes)); + + if (image != null) { + return new ImageIcon(scaleImage(image, size)); + } + } catch (IOException ignored) { + // Fall back to path based loading below. + } + } + + List paths = new ArrayList<>(); + + for (String path : iconSource.paths) { + if (path != null && !path.isEmpty()) { + paths.add(java.nio.file.Paths.get(path)); + } + } + + return loadIconFromModPaths(paths, iconSource.iconPath, size); + } + + private static Icon loadIconFromModPaths(List paths, String iconPath, int size) { + String normalizedIconPath = iconPath.replace('\\', '/'); + + for (Path path : paths) { + try { + BufferedImage image; + + if (Files.isDirectory(path)) { + Path resolvedIconPath = path; + + for (String part : normalizedIconPath.split("/")) { + if (!part.isEmpty()) { + resolvedIconPath = resolvedIconPath.resolve(part); + } + } + + if (!Files.isRegularFile(resolvedIconPath)) { + continue; + } + + image = ImageIO.read(resolvedIconPath.toFile()); + } else { + try (ZipFile zip = new ZipFile(path.toFile())) { + ZipEntry entry = zip.getEntry(normalizedIconPath); + + if (entry == null) { + continue; + } + + try (InputStream input = zip.getInputStream(entry)) { + image = ImageIO.read(input); + } + } + } + + if (image == null) { + continue; + } + + return new ImageIcon(scaleImage(image, size)); + } catch (Throwable ignored) { + // Invalid, missing or unreadable icons should not prevent the error UI from opening. + } + } + + return null; + } + + private static Image scaleImage(BufferedImage image, int size) { + if (image.getWidth() == size && image.getHeight() == size) { + return image; + } + + return image.getScaledInstance(size, size, Image.SCALE_SMOOTH); + } + + private static String html(String body) { + return "" + body + ""; + } + + private static String escape(String text) { + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static String stripHtml(String text) { + return text.replace("", "").replace("", ""); + } + + private static final class SuggestedAction { + final String text; + String targetId; + final List details = new ArrayList<>(); + + SuggestedAction(String text, String targetId, List details) { + this.text = text; + this.targetId = targetId == null ? "" : targetId; + addDetails(details); + } + + void addDetails(List details) { + if (details == null) { + return; + } + + for (String detail : details) { + if (detail != null && !detail.isEmpty() && !this.details.contains(detail)) { + this.details.add(detail); + } + } + } + } + + private static final class DependencyUiData { + final Map dependencies = new LinkedHashMap<>(); + final Map conflicts = new LinkedHashMap<>(); + final Map modIssues = new LinkedHashMap<>(); + final List dependants = new ArrayList<>(); + final List otherActions = new ArrayList<>(); + + static DependencyUiData from(DependencyGuiData source) { + DependencyUiData data = new DependencyUiData(); + + for (DependencyGuiSuggestedChange suggestedChange : source.suggestedChanges) { + data.addSuggestedAction(suggestedChange.text, suggestedChange.targetId, suggestedChange.details); + } + + for (DependencyGuiDependency dependency : source.dependencies.values()) { + DependencyRequirement target = data.getOrCreateDependency(dependency.id, dependency.displayName, dependency.kind == DependencyGuiRequirementKind.CONFLICT); + + for (String versionRequirement : dependency.versionRequirements) { + target.addVersion(versionRequirement); + } + } + + for (DependencyGuiMod sourceMod : source.affectedMods.values()) { + ModIssue issue = new ModIssue(sourceMod.displayName, sourceMod.id, sourceMod.version); + data.modIssues.put(issue.getKey(), issue); + + for (DependencyGuiRequirement sourceRequirement : sourceMod.requirements) { + boolean conflict = sourceRequirement.kind == DependencyGuiRequirementKind.CONFLICT; + DependencyRequirement dependency = data.getOrCreateDependency(sourceRequirement.dependencyId, sourceRequirement.dependencyDisplayName, conflict); + dependency.addVersion(sourceRequirement.versionRequirement); + issue.addRequirement(sourceRequirement.dependencyId, sourceRequirement.dependencyDisplayName, sourceRequirement.versionRequirement, conflict); + data.dependants.add(new DependantRequirement(sourceMod.displayName, sourceMod.id, sourceMod.version, + sourceRequirement.dependencyDisplayName, sourceRequirement.versionRequirement, conflict)); + } + } + + return data; + } + + boolean hasContent() { + return !dependencies.isEmpty() || !conflicts.isEmpty() || !modIssues.isEmpty() || !dependants.isEmpty() || !otherActions.isEmpty(); + } + + private void addSuggestedAction(String text, String targetId, List details) { + for (SuggestedAction action : otherActions) { + if (action.text.equals(text)) { + action.addDetails(details); + + if (action.targetId.isEmpty() && targetId != null && !targetId.isEmpty()) { + action.targetId = targetId; + } + + return; + } + } + + otherActions.add(new SuggestedAction(text, targetId, details)); + } + + private DependencyRequirement getOrCreateDependency(String id, String displayName, boolean conflict) { + displayName = canonicalDisplayName(id, displayName); + Map targetMap = conflict ? conflicts : dependencies; + DependencyRequirement existing = targetMap.get(id); + + if (existing == null) { + existing = new DependencyRequirement(id, displayName, "", conflict); + targetMap.put(id, existing); + } + + return existing; + } + + private static String canonicalDisplayName(String id, String fallbackDisplayName) { + return findModDisplayName(id).map(s -> s + " (" + id + ")").orElse(fallbackDisplayName); + } + } + + private static final class DependencyRequirement { + final String id; + final String displayName; + final boolean conflict; + final List versions = new ArrayList<>(); + + DependencyRequirement(String id, String displayName, String version, boolean conflict) { + this.id = id; + this.displayName = displayName; + this.conflict = conflict; + addVersion(version); + } + + void addVersion(String version) { + if (version != null && !version.isEmpty() && !versions.contains(version)) { + versions.add(version); + } + } + + String getSummaryVersion() { + if (versions.isEmpty()) return ""; + if (versions.size() == 1) return versions.get(0); + return Localization.format("gui.dependency.multipleVersionRequirements"); + } + } + + private static final class RequirementEntry { + final String dependencyId; + final String dependencyDisplayName; + final String requiredVersion; + final boolean conflict; + + RequirementEntry(String dependencyId, String dependencyDisplayName, String requiredVersion, boolean conflict) { + this.dependencyId = dependencyId; + this.dependencyDisplayName = dependencyDisplayName; + this.requiredVersion = requiredVersion; + this.conflict = conflict; + } + } + + private static final class ModIssue { + final String modDisplayName; + final String modId; + final String modVersion; + final List requirements = new ArrayList<>(); + + ModIssue(String modDisplayName, String modId, String modVersion) { + this.modDisplayName = modDisplayName; + this.modId = modId; + this.modVersion = modVersion; + } + + void addRequirement(String dependencyId, String dependencyDisplayName, String requiredVersion, boolean conflict) { + dependencyDisplayName = DependencyUiData.canonicalDisplayName(dependencyId, dependencyDisplayName); + + for (RequirementEntry entry : requirements) { + if (entry.dependencyId.equals(dependencyId) && entry.requiredVersion.equals(requiredVersion) && entry.conflict == conflict) { + return; + } + } + + requirements.add(new RequirementEntry(dependencyId, dependencyDisplayName, requiredVersion, conflict)); + } + + String getKey() { + return modId.isEmpty() ? modDisplayName + "@" + modVersion : modId; + } + + int getDependencyCount() { + int count = 0; + + for (RequirementEntry requirement : requirements) { + if (!requirement.conflict) count++; + } + + return count; + } + + int getConflictCount() { + int count = 0; + + for (RequirementEntry requirement : requirements) { + if (requirement.conflict) count++; + } + + return count; + } + + String getSummaryText() { + if (requirements.isEmpty()) return Localization.format("gui.dependency.mod.noDetails"); + + int dependencyCount = getDependencyCount(); + int conflictCount = getConflictCount(); + + if (dependencyCount > 0 && conflictCount > 0) { + return Localization.format("gui.dependency.mod.summary.mixed", dependencyCount, conflictCount); + } + + if (conflictCount > 0) { + if (conflictCount == 1) return Localization.format("gui.dependency.mod.conflictSummary.one", getRequirement(0, true).dependencyDisplayName); + if (conflictCount == 2) return Localization.format("gui.dependency.mod.conflictSummary.two", getRequirement(0, true).dependencyDisplayName, getRequirement(1, true).dependencyDisplayName); + return Localization.format("gui.dependency.mod.conflictSummary.many", conflictCount, getRequirement(0, true).dependencyDisplayName, getRequirement(1, true).dependencyDisplayName, conflictCount - 2); + } + + if (dependencyCount == 1) return Localization.format("gui.dependency.mod.summary.one", getRequirement(0, false).dependencyDisplayName); + if (dependencyCount == 2) return Localization.format("gui.dependency.mod.summary.two", getRequirement(0, false).dependencyDisplayName, getRequirement(1, false).dependencyDisplayName); + return Localization.format("gui.dependency.mod.summary.many", dependencyCount, getRequirement(0, false).dependencyDisplayName, getRequirement(1, false).dependencyDisplayName, dependencyCount - 2); + } + + private RequirementEntry getRequirement(int index, boolean conflict) { + int current = 0; + + for (RequirementEntry requirement : requirements) { + if (requirement.conflict == conflict) { + if (current++ == index) return requirement; + } + } + + throw new IndexOutOfBoundsException(String.valueOf(index)); + } + + String getDetailSubtitle() { + int dependencyCount = getDependencyCount(); + int conflictCount = getConflictCount(); + + if (dependencyCount > 0 && conflictCount > 0) { + return Localization.format("gui.dependency.mod.detailSubtitle.mixed", requirements.size()); + } + + if (conflictCount > 0) { + return conflictCount == 1 ? Localization.format("gui.dependency.mod.conflictDetailSubtitle.one") + : Localization.format("gui.dependency.mod.conflictDetailSubtitle.many", conflictCount); + } + + return requirements.size() == 1 ? Localization.format("gui.dependency.mod.detailSubtitle.one") + : Localization.format("gui.dependency.mod.detailSubtitle.many", requirements.size()); + } + + String getDetailSectionTitle() { + if (getDependencyCount() > 0 && getConflictCount() > 0) return Localization.format("gui.dependency.section.dependencyIssues"); + if (getConflictCount() > 0) return Localization.format("gui.dependency.section.conflictingMods"); + return Localization.format("gui.dependency.section.requiredDependencies"); + } + + String getDetailSectionDescription() { + if (getDependencyCount() > 0 && getConflictCount() > 0) return Localization.format("gui.dependency.section.dependencyIssues.desc"); + if (getConflictCount() > 0) return Localization.format("gui.dependency.section.conflictingMods.desc"); + return Localization.format("gui.dependency.section.requiredDependencies.desc"); + } + + Map getGroupedRequirements() { + Map grouped = new LinkedHashMap<>(); + + for (RequirementEntry entry : requirements) { + String key = entry.dependencyId + "@" + entry.conflict; + DependencyRequirement existing = grouped.get(key); + + if (existing == null) { + existing = new DependencyRequirement(entry.dependencyId, entry.dependencyDisplayName, entry.requiredVersion, entry.conflict); + grouped.put(key, existing); + } else { + existing.addVersion(entry.requiredVersion); + } + } + + return grouped; + } + } + + private static final class DependantRequirement { + final String modDisplayName; + final String modId; + final String modVersion; + final String dependencyDisplayName; + final String requiredVersion; + final boolean conflict; + + DependantRequirement(String modDisplayName, String modId, String modVersion, String dependencyDisplayName, String requiredVersion, boolean conflict) { + this.modDisplayName = modDisplayName; + this.modId = modId; + this.modVersion = modVersion; + this.dependencyDisplayName = dependencyDisplayName; + this.requiredVersion = requiredVersion; + this.conflict = conflict; + } + } + + private static final class RoundedPanel extends JPanel { + private static final long serialVersionUID = -2742482680797954853L; + + private final int arc; + private Color borderColor = borderColor(); + + RoundedPanel(int arc) { + this.arc = arc; + setOpaque(false); + } + + void setBorderColor(Color borderColor) { + this.borderColor = borderColor; + } + + @Override + public Dimension getMaximumSize() { + Dimension preferred = getPreferredSize(); + return new Dimension(Integer.MAX_VALUE, preferred.height); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(getBackground()); + g2.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1, arc, arc); + g2.setColor(borderColor); + g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, arc, arc); + g2.dispose(); + super.paintComponent(g); + } + } + + static final class IconSet { + /** Map of IconInfo -> Integer Size -> Real Icon. */ + private final Map> icons = new HashMap<>(); + + public Icon get(IconInfo info) { + // TODO: HDPI + + int scale = 16; + Map map = icons.computeIfAbsent(info, k -> new HashMap<>()); Icon icon = map.get(scale); @@ -524,7 +2024,7 @@ public boolean isLeaf() { @Override public Enumeration children() { return new Enumeration() { - Iterator it = displayedChildren.iterator(); + final Iterator it = displayedChildren.iterator(); @Override public boolean hasMoreElements() { diff --git a/src/main/java/net/fabricmc/loader/impl/gui/FabricStatusTree.java b/src/main/java/net/fabricmc/loader/impl/gui/FabricStatusTree.java index 2cad01fb9..9883d7019 100644 --- a/src/main/java/net/fabricmc/loader/impl/gui/FabricStatusTree.java +++ b/src/main/java/net/fabricmc/loader/impl/gui/FabricStatusTree.java @@ -23,8 +23,11 @@ import java.io.StringWriter; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; import java.util.Locale; import java.util.Objects; import java.util.Set; @@ -59,7 +62,7 @@ public enum FabricBasicButtonType { /** Sends the status message to the main application, then disables itself. */ CLICK_ONCE, /** Sends the status message to the main application, remains enabled. */ - CLICK_MANY; + CLICK_MANY } /** No icon is displayed. */ @@ -94,6 +97,7 @@ public enum FabricBasicButtonType { public final String mainText; public final List tabs = new ArrayList<>(); public final List buttons = new ArrayList<>(); + private DependencyGuiData dependencyGuiData; public FabricStatusTree(String title, String mainText) { Objects.requireNonNull(title, "null title"); @@ -114,6 +118,10 @@ public FabricStatusTree(DataInputStream is) throws IOException { for (int i = is.readInt(); i > 0; i--) { buttons.add(new FabricStatusButton(is)); } + + if (is.readBoolean()) { + dependencyGuiData = new DependencyGuiData(is); + } } public void writeTo(DataOutputStream os) throws IOException { @@ -130,6 +138,12 @@ public void writeTo(DataOutputStream os) throws IOException { for (FabricStatusButton button : buttons) { button.writeTo(os); } + + os.writeBoolean(dependencyGuiData != null); + + if (dependencyGuiData != null) { + dependencyGuiData.writeTo(os); + } } public FabricStatusTab addTab(String name) { @@ -144,6 +158,322 @@ public FabricStatusButton addButton(String text, FabricBasicButtonType type) { return button; } + public DependencyGuiData getDependencyGuiData() { + return dependencyGuiData; + } + + public DependencyGuiData createDependencyGuiData() { + dependencyGuiData = new DependencyGuiData(); + return dependencyGuiData; + } + + public void setDependencyGuiData(DependencyGuiData dependencyGuiData) { + this.dependencyGuiData = dependencyGuiData; + } + + public enum DependencyGuiRequirementKind { + DEPENDENCY, + CONFLICT + } + + public static final class DependencyGuiData { + public final List suggestedChanges = new ArrayList<>(); + public final Map dependencies = new LinkedHashMap<>(); + public final Map affectedMods = new LinkedHashMap<>(); + public final Map iconSources = new LinkedHashMap<>(); + + public DependencyGuiData() { } + + DependencyGuiData(DataInputStream is) throws IOException { + for (int i = is.readInt(); i > 0; i--) { + suggestedChanges.add(new DependencyGuiSuggestedChange(is)); + } + + for (int i = is.readInt(); i > 0; i--) { + DependencyGuiDependency dependency = new DependencyGuiDependency(is); + dependencies.put(dependency.id + "@" + dependency.kind.name(), dependency); + } + + for (int i = is.readInt(); i > 0; i--) { + DependencyGuiMod mod = new DependencyGuiMod(is); + affectedMods.put(mod.id, mod); + } + + for (int i = is.readInt(); i > 0; i--) { + DependencyGuiIconSource iconSource = new DependencyGuiIconSource(is); + iconSources.put(iconSource.id, iconSource); + } + } + + void writeTo(DataOutputStream os) throws IOException { + os.writeInt(suggestedChanges.size()); + + for (DependencyGuiSuggestedChange suggestedChange : suggestedChanges) { + suggestedChange.writeTo(os); + } + + os.writeInt(dependencies.size()); + + for (DependencyGuiDependency dependency : dependencies.values()) { + dependency.writeTo(os); + } + + os.writeInt(affectedMods.size()); + + for (DependencyGuiMod mod : affectedMods.values()) { + mod.writeTo(os); + } + + os.writeInt(iconSources.size()); + + for (DependencyGuiIconSource iconSource : iconSources.values()) { + iconSource.writeTo(os); + } + } + + public DependencyGuiSuggestedChange addSuggestedChange(String text, String targetId) { + DependencyGuiSuggestedChange suggestedChange = new DependencyGuiSuggestedChange(text, targetId); + suggestedChanges.add(suggestedChange); + return suggestedChange; + } + + public DependencyGuiDependency addDependency(String id, String displayName, String versionRequirement, DependencyGuiRequirementKind kind) { + String key = id + "@" + kind.name(); + DependencyGuiDependency dependency = dependencies.get(key); + + if (dependency == null) { + dependency = new DependencyGuiDependency(id, displayName, kind); + dependencies.put(key, dependency); + } + + dependency.addVersionRequirement(versionRequirement); + return dependency; + } + + public DependencyGuiMod addAffectedMod(String id, String displayName, String version) { + DependencyGuiMod mod = affectedMods.get(id); + + if (mod == null) { + mod = new DependencyGuiMod(id, displayName, version); + affectedMods.put(id, mod); + } + + return mod; + } + + public void addIconSource(String id, String iconPath, List paths, byte[] iconBytes) { + if (id == null || id.isEmpty() || iconPath == null || iconPath.isEmpty()) { + return; + } + + boolean hasPaths = paths != null && !paths.isEmpty(); + boolean hasIconBytes = iconBytes != null && iconBytes.length > 0; + + if (!hasPaths && !hasIconBytes) { + return; + } + + if (!iconSources.containsKey(id)) { + iconSources.put(id, new DependencyGuiIconSource(id, iconPath, paths, iconBytes)); + } + } + } + + public static final class DependencyGuiIconSource { + public final String id; + public final String iconPath; + public final List paths = new ArrayList<>(); + public final byte[] iconBytes; + + public DependencyGuiIconSource(String id, String iconPath, List paths, byte[] iconBytes) { + this.id = Objects.requireNonNull(id, "null id"); + this.iconPath = Objects.requireNonNull(iconPath, "null iconPath"); + + if (paths != null) { + this.paths.addAll(paths); + } + + this.iconBytes = iconBytes == null ? new byte[0] : iconBytes.clone(); + } + + DependencyGuiIconSource(DataInputStream is) throws IOException { + id = is.readUTF(); + iconPath = is.readUTF(); + + for (int i = is.readInt(); i > 0; i--) { + paths.add(is.readUTF()); + } + + int iconByteCount = is.readInt(); + iconBytes = new byte[iconByteCount]; + + if (iconByteCount > 0) { + is.readFully(iconBytes); + } + } + + void writeTo(DataOutputStream os) throws IOException { + os.writeUTF(id); + os.writeUTF(iconPath); + os.writeInt(paths.size()); + + for (String path : paths) { + os.writeUTF(path); + } + + os.writeInt(iconBytes.length); + os.write(iconBytes); + } + } + + public static final class DependencyGuiSuggestedChange { + public final String text; + public final String targetId; + public final List details = new ArrayList<>(); + + public DependencyGuiSuggestedChange(String text, String targetId) { + this.text = Objects.requireNonNull(text, "null text"); + this.targetId = targetId == null ? "" : targetId; + } + + DependencyGuiSuggestedChange(DataInputStream is) throws IOException { + text = is.readUTF(); + targetId = is.readUTF(); + + for (int i = is.readInt(); i > 0; i--) { + details.add(is.readUTF()); + } + } + + void writeTo(DataOutputStream os) throws IOException { + os.writeUTF(text); + os.writeUTF(targetId); + os.writeInt(details.size()); + + for (String detail : details) { + os.writeUTF(detail); + } + } + + public DependencyGuiSuggestedChange addDetail(String detail) { + if (detail != null && !detail.isEmpty()) { + details.add(detail); + } + + return this; + } + } + + public static final class DependencyGuiDependency { + public final String id; + public final String displayName; + public final DependencyGuiRequirementKind kind; + public final List versionRequirements = new ArrayList<>(); + + public DependencyGuiDependency(String id, String displayName, DependencyGuiRequirementKind kind) { + this.id = Objects.requireNonNull(id, "null id"); + this.displayName = Objects.requireNonNull(displayName, "null displayName"); + this.kind = Objects.requireNonNull(kind, "null kind"); + } + + DependencyGuiDependency(DataInputStream is) throws IOException { + id = is.readUTF(); + displayName = is.readUTF(); + kind = DependencyGuiRequirementKind.valueOf(is.readUTF()); + + for (int i = is.readInt(); i > 0; i--) { + versionRequirements.add(is.readUTF()); + } + } + + void writeTo(DataOutputStream os) throws IOException { + os.writeUTF(id); + os.writeUTF(displayName); + os.writeUTF(kind.name()); + os.writeInt(versionRequirements.size()); + + for (String versionRequirement : versionRequirements) { + os.writeUTF(versionRequirement); + } + } + + public DependencyGuiDependency addVersionRequirement(String versionRequirement) { + if (versionRequirement != null && !versionRequirement.isEmpty() && !versionRequirements.contains(versionRequirement)) { + versionRequirements.add(versionRequirement); + } + + return this; + } + } + + public static final class DependencyGuiMod { + public final String id; + public final String displayName; + public final String version; + public final List requirements = new ArrayList<>(); + + public DependencyGuiMod(String id, String displayName, String version) { + this.id = Objects.requireNonNull(id, "null id"); + this.displayName = Objects.requireNonNull(displayName, "null displayName"); + this.version = version == null ? "" : version; + } + + DependencyGuiMod(DataInputStream is) throws IOException { + id = is.readUTF(); + displayName = is.readUTF(); + version = is.readUTF(); + + for (int i = is.readInt(); i > 0; i--) { + requirements.add(new DependencyGuiRequirement(is)); + } + } + + void writeTo(DataOutputStream os) throws IOException { + os.writeUTF(id); + os.writeUTF(displayName); + os.writeUTF(version); + os.writeInt(requirements.size()); + + for (DependencyGuiRequirement requirement : requirements) { + requirement.writeTo(os); + } + } + + public DependencyGuiRequirement addRequirement(String dependencyId, String dependencyDisplayName, String versionRequirement, DependencyGuiRequirementKind kind) { + DependencyGuiRequirement requirement = new DependencyGuiRequirement(dependencyId, dependencyDisplayName, versionRequirement, kind); + requirements.add(requirement); + return requirement; + } + } + + public static final class DependencyGuiRequirement { + public final String dependencyId; + public final String dependencyDisplayName; + public final String versionRequirement; + public final DependencyGuiRequirementKind kind; + + public DependencyGuiRequirement(String dependencyId, String dependencyDisplayName, String versionRequirement, DependencyGuiRequirementKind kind) { + this.dependencyId = Objects.requireNonNull(dependencyId, "null dependencyId"); + this.dependencyDisplayName = Objects.requireNonNull(dependencyDisplayName, "null dependencyDisplayName"); + this.versionRequirement = versionRequirement == null ? "" : versionRequirement; + this.kind = Objects.requireNonNull(kind, "null kind"); + } + + DependencyGuiRequirement(DataInputStream is) throws IOException { + dependencyId = is.readUTF(); + dependencyDisplayName = is.readUTF(); + versionRequirement = is.readUTF(); + kind = DependencyGuiRequirementKind.valueOf(is.readUTF()); + } + + void writeTo(DataOutputStream os) throws IOException { + os.writeUTF(dependencyId); + os.writeUTF(dependencyDisplayName); + os.writeUTF(versionRequirement); + os.writeUTF(kind.name()); + } + } + public static final class FabricStatusButton { public final String text; public final FabricBasicButtonType type; @@ -311,7 +641,7 @@ public void setInfo() { private FabricStatusNode addChild(String string) { if (string.startsWith("\t")) { - if (children.size() == 0) { + if (children.isEmpty()) { FabricStatusNode rootChild = new FabricStatusNode(this, ""); children.add(rootChild); } @@ -477,7 +807,7 @@ public void mergeSingleChildFilePath(String folderType) { mergeWithSingleChild("/"); } - children.sort((a, b) -> a.name.compareTo(b.name)); + children.sort(Comparator.comparing(a -> a.name)); mergeChildFilePaths(folderType); } diff --git a/src/main/resources/net/fabricmc/loader/Messages.properties b/src/main/resources/net/fabricmc/loader/Messages.properties index bbf5f9017..56af2aa80 100644 --- a/src/main/resources/net/fabricmc/loader/Messages.properties +++ b/src/main/resources/net/fabricmc/loader/Messages.properties @@ -12,6 +12,66 @@ gui.error.header=Failed to launch! gui.error.missingException=No further details available gui.tab.crash=Crash +gui.dependency.subtitle.incompatible=Some of your mods are incompatible with the game or each other. +gui.dependency.subtitle.generic=Review the information below to resolve the problem. + +gui.dependency.section.suggestedChanges=Suggested changes +gui.dependency.section.suggestedChanges.desc=These changes may resolve the problem: + +gui.dependency.section.whatsMissing=What''s missing? +gui.dependency.section.whatsMissing.desc=The following dependencies are required: + +gui.dependency.section.whoNeedsIt=Who needs it? +gui.dependency.section.whoNeedsIt.desc=The following mods require the missing dependencies: + +gui.dependency.section.affectedMods=Affected mods +gui.dependency.section.affectedMods.desc=Select a mod to see the exact dependency requirements for that specific mod. + +gui.dependency.section.missingOverview=Missing dependencies overview +gui.dependency.section.missingOverview.desc=These dependencies are missing somewhere in the current mod set. + +gui.dependency.section.requiredDependencies=Required dependencies +gui.dependency.section.requiredDependencies.desc=These are the exact dependency requirements for this mod. + +gui.dependency.button.back=\u2190 Back to mod list + +gui.dependency.requires=Requires: +gui.dependency.versionPrefix=Version {0} +gui.dependency.multipleVersionRequirements=Multiple version requirements + +gui.dependency.mod.noDetails=No dependency details available. +gui.dependency.mod.summary.one=Missing 1 dependency: {0} +gui.dependency.mod.summary.two=Missing 2 dependencies: {0}, {1} +gui.dependency.mod.summary.many=Missing {0} dependencies: {1}, {2}, +{3} more + +gui.dependency.mod.detailSubtitle.one=This mod is missing 1 required dependency or version. +gui.dependency.mod.detailSubtitle.many=This mod is missing {0} required dependencies or versions. + +gui.error.openingErrors=Opening Errors +gui.error.noTabs=No tabs provided! (Something is very broken) + +gui.dependency.section.conflictsOverview=Conflicts overview +gui.dependency.section.conflictsOverview.desc=These mods are incompatible with the current mod set. + +gui.dependency.section.conflictingMods=Conflicting mods +gui.dependency.section.conflictingMods.desc=This mod declares the following mods as incompatible. + +gui.dependency.section.dependencyIssues=Dependency issues +gui.dependency.section.dependencyIssues.desc=These are the exact dependency and conflict requirements for this mod. + +gui.dependency.conflictsWith=Conflicts with: +gui.dependency.conflict.version=Conflicts with {0} + +gui.dependency.mod.conflictSummary.one=Conflicts with 1 mod: {0} +gui.dependency.mod.conflictSummary.two=Conflicts with 2 mods: {0}, {1} +gui.dependency.mod.conflictSummary.many=Conflicts with {0} mods: {1}, {2}, +{3} more + +gui.dependency.mod.summary.mixed=Missing {0} dependencies and conflicts with {1} mods + +gui.dependency.mod.conflictDetailSubtitle.one=This mod conflicts with 1 mod or version. +gui.dependency.mod.conflictDetailSubtitle.many=This mod conflicts with {0} mods or versions. +gui.dependency.mod.detailSubtitle.mixed=This mod has {0} dependency issues. + # FormattedException main text exception.incompatible=Incompatible mods found! exception.parsingOverride=Error parsing dependency overrides! diff --git a/src/main/resources/ui/icon/circle_x24.png b/src/main/resources/ui/icon/circle_x24.png new file mode 100644 index 000000000..54fb8b930 Binary files /dev/null and b/src/main/resources/ui/icon/circle_x24.png differ diff --git a/src/main/resources/ui/icon/clipboard_x24.png b/src/main/resources/ui/icon/clipboard_x24.png new file mode 100644 index 000000000..d88946022 Binary files /dev/null and b/src/main/resources/ui/icon/clipboard_x24.png differ diff --git a/src/main/resources/ui/icon/document_x24.png b/src/main/resources/ui/icon/document_x24.png new file mode 100644 index 000000000..f1a9820e9 Binary files /dev/null and b/src/main/resources/ui/icon/document_x24.png differ diff --git a/src/main/resources/ui/icon/error_x24.png b/src/main/resources/ui/icon/error_x24.png new file mode 100644 index 000000000..18d6d34c6 Binary files /dev/null and b/src/main/resources/ui/icon/error_x24.png differ diff --git a/src/main/resources/ui/icon/java_x32.png b/src/main/resources/ui/icon/java_x32.png new file mode 100644 index 000000000..22f0340bd Binary files /dev/null and b/src/main/resources/ui/icon/java_x32.png differ diff --git a/src/main/resources/ui/icon/minecraft_x32.png b/src/main/resources/ui/icon/minecraft_x32.png new file mode 100644 index 000000000..a91c93582 Binary files /dev/null and b/src/main/resources/ui/icon/minecraft_x32.png differ