|
24 | 24 | import javafx.beans.property.BooleanProperty; |
25 | 25 | import javafx.beans.property.ObjectProperty; |
26 | 26 | import javafx.beans.property.SimpleBooleanProperty; |
| 27 | +import javafx.collections.FXCollections; |
27 | 28 | import javafx.collections.ListChangeListener; |
28 | 29 | import javafx.css.PseudoClass; |
29 | 30 | import javafx.geometry.Insets; |
|
36 | 37 | import javafx.scene.input.MouseEvent; |
37 | 38 | import javafx.scene.layout.HBox; |
38 | 39 | import javafx.scene.layout.Priority; |
| 40 | +import javafx.scene.layout.Region; |
39 | 41 | import javafx.scene.layout.StackPane; |
40 | 42 | import javafx.scene.layout.VBox; |
41 | 43 | import javafx.stage.Stage; |
@@ -172,14 +174,54 @@ final class ModListPageSkin extends SkinBase<ModListPage> { |
172 | 174 |
|
173 | 175 | toolbarSelecting.getChildren().setAll( |
174 | 176 | createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> { |
175 | | - Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { |
176 | | - skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()); |
177 | | - }, null); |
| 177 | + var selected = listView.getSelectionModel().getSelectedItems(); |
| 178 | + List<LocalModFile> targets = new ArrayList<>(); |
| 179 | + for (ModInfoObject item : selected) |
| 180 | + if (item != null) targets.add(item.getModInfo()); |
| 181 | + List<ModInfoObject> dependents = findActiveDependents(targets); |
| 182 | + if (dependents.isEmpty()) { |
| 183 | + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), |
| 184 | + () -> skinnable.removeSelected(selected), null); |
| 185 | + } else { |
| 186 | + List<ModInfoObject> selectedSnapshot = new ArrayList<>(selected); |
| 187 | + Controllers.dialog(new DependencyWarningDialog(dependents, i18n("button.remove"), List.of( |
| 188 | + new CascadeOption(i18n("addon.dependencies.warning.cascade.none"), |
| 189 | + () -> skinnable.removeSelected(FXCollections.observableArrayList(selectedSnapshot))), |
| 190 | + new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> { |
| 191 | + for (ModInfoObject dependent : dependents) |
| 192 | + dependent.getModInfo().setActive(false); |
| 193 | + skinnable.removeSelected(FXCollections.observableArrayList(selectedSnapshot)); |
| 194 | + }), |
| 195 | + new CascadeOption(i18n("addon.dependencies.warning.cascade.delete"), () -> { |
| 196 | + List<ModInfoObject> all = new ArrayList<>(selectedSnapshot); |
| 197 | + all.addAll(dependents); |
| 198 | + skinnable.removeSelected(FXCollections.observableArrayList(all)); |
| 199 | + }) |
| 200 | + ))); |
| 201 | + } |
178 | 202 | }), |
179 | 203 | createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () -> |
180 | 204 | skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())), |
181 | | - createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> |
182 | | - skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())), |
| 205 | + createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> { |
| 206 | + var selected = listView.getSelectionModel().getSelectedItems(); |
| 207 | + List<LocalModFile> targets = new ArrayList<>(); |
| 208 | + for (ModInfoObject item : selected) |
| 209 | + if (item != null) targets.add(item.getModInfo()); |
| 210 | + List<ModInfoObject> dependents = findActiveDependents(targets); |
| 211 | + if (dependents.isEmpty()) { |
| 212 | + skinnable.disableSelected(selected); |
| 213 | + } else { |
| 214 | + Controllers.dialog(new DependencyWarningDialog(dependents, i18n("mods.disable"), List.of( |
| 215 | + new CascadeOption(i18n("addon.dependencies.warning.cascade.none"), |
| 216 | + () -> skinnable.disableSelected(selected)), |
| 217 | + new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> { |
| 218 | + skinnable.disableSelected(selected); |
| 219 | + for (ModInfoObject dependent : dependents) |
| 220 | + dependent.getModInfo().setActive(false); |
| 221 | + }) |
| 222 | + ))); |
| 223 | + } |
| 224 | + }), |
183 | 225 | createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () -> |
184 | 226 | skinnable.checkUpdates( |
185 | 227 | listView.getSelectionModel().getSelectedItems().stream() |
@@ -302,6 +344,28 @@ private void search() { |
302 | 344 | } |
303 | 345 | } |
304 | 346 |
|
| 347 | + // Finds enabled mods (other than the targets themselves) that declare a dependency on any of |
| 348 | + // the targets — i.e. mods that may break if the targets are disabled. |
| 349 | + private List<ModInfoObject> findActiveDependents(Collection<LocalModFile> targets) { |
| 350 | + Set<String> targetIds = new HashSet<>(); |
| 351 | + for (LocalModFile target : targets) { |
| 352 | + if (StringUtils.isNotBlank(target.getId())) |
| 353 | + targetIds.add(target.getId()); |
| 354 | + } |
| 355 | + if (targetIds.isEmpty()) |
| 356 | + return List.of(); |
| 357 | + |
| 358 | + List<ModInfoObject> dependents = new ArrayList<>(); |
| 359 | + for (ModInfoObject item : getSkinnable().getItems()) { |
| 360 | + LocalModFile mod = item.getModInfo(); |
| 361 | + if (targets.contains(mod) || !mod.isActive()) |
| 362 | + continue; |
| 363 | + if (mod.getDependencies().stream().anyMatch(targetIds::contains)) |
| 364 | + dependents.add(item); |
| 365 | + } |
| 366 | + return dependents; |
| 367 | + } |
| 368 | + |
305 | 369 | static final class ModInfoObject { |
306 | 370 | private final BooleanProperty active; |
307 | 371 | // Whether the nested (Jar-in-Jar) mods of this entry are expanded in the list. |
@@ -566,6 +630,84 @@ final class ModInfoDialog extends JFXDialogLayout { |
566 | 630 | } |
567 | 631 | } |
568 | 632 |
|
| 633 | + // A selectable "what to do with the dependent mods" choice. action performs the full operation |
| 634 | + // (always acting on the target mods, plus optionally on the dependents). |
| 635 | + private record CascadeOption(String label, Runnable action) { |
| 636 | + @Override |
| 637 | + public String toString() { |
| 638 | + return label; |
| 639 | + } |
| 640 | + } |
| 641 | + |
| 642 | + final class DependencyWarningDialog extends JFXDialogLayout { |
| 643 | + |
| 644 | + DependencyWarningDialog(List<ModInfoObject> dependents, String confirmText, List<CascadeOption> options) { |
| 645 | + setHeading(new Label(i18n("addon.dependencies.warning.title"))); |
| 646 | + |
| 647 | + Label message = new Label(i18n("addon.dependencies.warning")); |
| 648 | + message.setWrapText(true); |
| 649 | + |
| 650 | + ComponentList list = new ComponentList(); |
| 651 | + list.getStyleClass().add("no-padding"); |
| 652 | + for (ModInfoObject dependent : dependents) { |
| 653 | + HBox row = new HBox(8); |
| 654 | + row.setAlignment(Pos.CENTER_LEFT); |
| 655 | + row.setPadding(new Insets(8)); |
| 656 | + row.setMouseTransparent(true); |
| 657 | + |
| 658 | + ImageContainer icon = new ImageContainer(32); |
| 659 | + dependent.loadIcon(icon, null); |
| 660 | + |
| 661 | + TwoLineListItem content = new TwoLineListItem(); |
| 662 | + HBox.setHgrow(content, Priority.ALWAYS); |
| 663 | + content.setTitle(dependent.getModTranslations() != null && I18n.isUseChinese() |
| 664 | + ? dependent.getModTranslations().getDisplayName() |
| 665 | + : dependent.getModInfo().getName()); |
| 666 | + StringJoiner subtitle = new StringJoiner(" | "); |
| 667 | + if (StringUtils.isNotBlank(dependent.getModInfo().getId())) |
| 668 | + subtitle.add(dependent.getModInfo().getId()); |
| 669 | + subtitle.add(FileUtils.getName(dependent.getModInfo().getFile())); |
| 670 | + content.setSubtitle(subtitle.toString()); |
| 671 | + |
| 672 | + row.getChildren().setAll(icon, content); |
| 673 | + list.getContent().add(row); |
| 674 | + } |
| 675 | + |
| 676 | + ScrollPane scrollPane = new ScrollPane(list); |
| 677 | + scrollPane.setFitToWidth(true); |
| 678 | + scrollPane.setMaxHeight(300); |
| 679 | + FXUtils.smoothScrolling(scrollPane); |
| 680 | + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); |
| 681 | + |
| 682 | + setBody(new VBox(10, message, scrollPane)); |
| 683 | + |
| 684 | + JFXComboBox<CascadeOption> cascade = new JFXComboBox<>(); |
| 685 | + cascade.getItems().setAll(options); |
| 686 | + cascade.getSelectionModel().selectFirst(); |
| 687 | + |
| 688 | + HBox cascadeBox = new HBox(8, new Label(i18n("addon.dependencies.warning.cascade.label")), cascade); |
| 689 | + cascadeBox.setAlignment(Pos.CENTER_LEFT); |
| 690 | + |
| 691 | + Region spacer = new Region(); |
| 692 | + HBox.setHgrow(spacer, Priority.ALWAYS); |
| 693 | + |
| 694 | + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); |
| 695 | + cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); |
| 696 | + |
| 697 | + JFXButton confirmButton = new JFXButton(confirmText); |
| 698 | + confirmButton.getStyleClass().add("dialog-accept"); |
| 699 | + confirmButton.setOnAction(e -> { |
| 700 | + fireEvent(new DialogCloseEvent()); |
| 701 | + CascadeOption selected = cascade.getValue(); |
| 702 | + if (selected != null) |
| 703 | + selected.action().run(); |
| 704 | + }); |
| 705 | + |
| 706 | + getActions().setAll(cascadeBox, spacer, cancelButton, confirmButton); |
| 707 | + onEscPressed(this, cancelButton::fire); |
| 708 | + } |
| 709 | + } |
| 710 | + |
569 | 711 | private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new); |
570 | 712 | private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get())); |
571 | 713 |
|
@@ -619,6 +761,28 @@ final class ModInfoListCell extends MDListCell<ModInfoObject> { |
619 | 761 | e.consume(); |
620 | 762 | }); |
621 | 763 |
|
| 764 | + // Warn before disabling a mod that other enabled mods depend on. |
| 765 | + checkBox.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { |
| 766 | + ModInfoObject item = getItem(); |
| 767 | + if (item == null || !checkBox.isSelected()) |
| 768 | + return; // enabling (or empty) — nothing to warn about |
| 769 | + List<LocalModFile> targets = List.of(item.getModInfo()); |
| 770 | + List<ModInfoObject> dependents = findActiveDependents(targets); |
| 771 | + if (!dependents.isEmpty()) { |
| 772 | + e.consume(); // block the immediate toggle; let the user confirm first |
| 773 | + LocalModFile target = item.getModInfo(); |
| 774 | + Controllers.dialog(new DependencyWarningDialog(dependents, i18n("mods.disable"), List.of( |
| 775 | + new CascadeOption(i18n("addon.dependencies.warning.cascade.none"), |
| 776 | + () -> target.setActive(false)), |
| 777 | + new CascadeOption(i18n("addon.dependencies.warning.cascade"), () -> { |
| 778 | + target.setActive(false); |
| 779 | + for (ModInfoObject dependent : dependents) |
| 780 | + dependent.getModInfo().setActive(false); |
| 781 | + }) |
| 782 | + ))); |
| 783 | + } |
| 784 | + }); |
| 785 | + |
622 | 786 | nestedBox.getStyleClass().add("mod-nested-list"); |
623 | 787 | nestedBox.visibleProperty().bind(expanded); |
624 | 788 | nestedBox.managedProperty().bind(nestedBox.visibleProperty()); |
|
0 commit comments