From b97e7fbd55c5b1380cd67eb358196713648339de Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 8 Sep 2026 20:46:21 +0800 Subject: [PATCH] Move modpack file controls into reusable tree cells (#6840) --- .../jfoenix/controls/JFXCheckTreeCell.java | 101 ++++++++++++++++++ .../com/jfoenix/controls/JFXTreeCell.java | 15 ++- .../com/jfoenix/skins/JFXCheckBoxSkin.java | 35 +++--- .../ui/export/ModpackFileSelectionPage.java | 96 +++++++++++------ 4 files changed, 196 insertions(+), 51 deletions(-) create mode 100644 HMCL/src/main/java/com/jfoenix/controls/JFXCheckTreeCell.java diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXCheckTreeCell.java b/HMCL/src/main/java/com/jfoenix/controls/JFXCheckTreeCell.java new file mode 100644 index 0000000000..4824cd9f17 --- /dev/null +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXCheckTreeCell.java @@ -0,0 +1,101 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.jfoenix.controls; + +import com.jfoenix.skins.JFXCheckBoxSkin; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.CheckBoxTreeItem; +import javafx.scene.layout.HBox; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +/// Displays a [CheckBoxTreeItem] with a material-design checkbox. +/// +/// The checkbox's selected and indeterminate states are bidirectionally bound to +/// the current tree item. Reusing or clearing the cell removes the old bindings. +/// Installing a checkbox skin or switching tree items applies the selection state +/// immediately, without playing a selection animation. +/// Ordinary tree items retain the display provided by [JFXTreeCell]. +/// Empty cells and `null` values display neither content nor a checkbox. +/// +/// @param the type of value stored in each tree item +@NotNullByDefault +public class JFXCheckTreeCell extends JFXTreeCell { + + /// The checkbox bound to the currently displayed checkable tree item. + private final JFXCheckBox checkBox = new JFXCheckBox(); + + /// Places the checkbox before the graphic supplied by the superclass. + private final HBox graphic = new HBox(); + + /// The tree item whose selection properties are currently bound, if any. + private @Nullable CheckBoxTreeItem boundTreeItem; + + /// Creates an empty cell whose checkbox toggles the current tree item's selection. + public JFXCheckTreeCell() { + checkBox.setAllowIndeterminate(false); + checkBox.setFocusTraversable(false); + graphic.setAlignment(Pos.CENTER_LEFT); + graphic.setPickOnBounds(false); + checkBox.skinProperty().addListener(observable -> finishSelectionAnimation()); + treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty())); + } + + /// Finishes animations from the previous item and immediately displays the current state. + private void finishSelectionAnimation() { + if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) { + skin.finishSelectionAnimation(); + } + } + + /// Adds the checkbox to the inherited display and updates its selection bindings. + @Override + protected void updateDisplay(@Nullable T item, boolean empty) { + // Release graphics before the superclass reuses or reparents them. + graphic.getChildren().clear(); + super.updateDisplay(item, empty); + + @Nullable CheckBoxTreeItem treeItem = !empty && item != null + && getTreeItem() instanceof CheckBoxTreeItem checkTreeItem ? checkTreeItem : null; + if (boundTreeItem != treeItem) { + if (boundTreeItem != null) { + checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty()); + checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty()); + } + boundTreeItem = treeItem; + if (treeItem != null) { + checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty()); + checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty()); + } else { + checkBox.setSelected(false); + checkBox.setIndeterminate(false); + } + finishSelectionAnimation(); + } + + if (treeItem != null) { + @Nullable Node itemGraphic = getGraphic(); + graphic.getChildren().add(checkBox); + if (itemGraphic != null) { + graphic.getChildren().add(itemGraphic); + } + setGraphic(graphic); + } + } +} diff --git a/HMCL/src/main/java/com/jfoenix/controls/JFXTreeCell.java b/HMCL/src/main/java/com/jfoenix/controls/JFXTreeCell.java index e415743338..29d2703ec0 100644 --- a/HMCL/src/main/java/com/jfoenix/controls/JFXTreeCell.java +++ b/HMCL/src/main/java/com/jfoenix/controls/JFXTreeCell.java @@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; import javafx.scene.layout.*; import javafx.scene.paint.Color; +import org.jetbrains.annotations.Nullable; import java.lang.ref.WeakReference; @@ -83,8 +84,7 @@ public class JFXTreeCell extends TreeCell { treeItemRef = new WeakReference<>(newTreeItem); } }; - final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener); - treeItemProperty().addListener(weakTreeItemListener); + treeItemProperty().addListener(treeItemInvalidationListener); if (getTreeItem() != null) { getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener); } @@ -104,13 +104,20 @@ public class JFXTreeCell extends TreeCell { selectedPane.setVisible(isSelected()); } - private void updateDisplay(T item, boolean empty) { + /// Updates the text and graphic for the current value and tree item. + /// + /// @implSpec Called when the cell value or the current tree item's graphic changes. + /// Subclasses may override this method to decorate the default display. + /// + /// @param item the cell value, or `null` to clear the display + /// @param empty whether the cell has no item + protected void updateDisplay(@Nullable T item, boolean empty) { if (item == null || empty) { hbox = null; setText(null); setGraphic(null); } else { - TreeItem treeItem = getTreeItem(); + @Nullable TreeItem treeItem = getTreeItem(); if (treeItem != null && treeItem.getGraphic() != null) { if (item instanceof Node) { setText(null); diff --git a/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java b/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java index 1f24a50f9f..4a2dde66cc 100644 --- a/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java +++ b/HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java @@ -169,11 +169,8 @@ public class JFXCheckBoxSkin extends CheckBoxSkin { }; } - private void playSelectAnimation(Boolean selection) { - if (selection == null) { - selection = false; - } - + /// Animates a selection change, or applies it immediately when animations are disabled. + private void playSelectAnimation(boolean selection) { JFXCheckBox control = (JFXCheckBox) this.getSkinnable(); this.box.setBorder(new Border(new BorderStroke( @@ -183,17 +180,7 @@ public class JFXCheckBoxSkin extends CheckBoxSkin { new BorderWidths(this.lineThick)))); if (!AnimationUtils.isAnimationEnabled()) { - if (selection) { - this.mark.setVisible(true); - this.mark.setScaleX(1.0); - this.mark.setScaleY(1.0); - JFXNodeUtils.updateBackground(box.getBackground(), box, control.getCheckedColor()); - } else { - this.mark.setVisible(false); - this.mark.setScaleX(0.0); - this.mark.setScaleY(0.0); - JFXNodeUtils.updateBackground(box.getBackground(), box, Color.TRANSPARENT); - } + finishSelectionAnimation(); return; } @@ -203,6 +190,22 @@ public class JFXCheckBoxSkin extends CheckBoxSkin { this.select.play(); } + /// Stops selection animations and immediately displays the checkbox's current selected state. + /// + /// The check mark, fill, and border are updated even if no animation is running. + /// This also suppresses the initial selection animation on the first layout. + /// The skin must not have been disposed. + public void finishSelectionAnimation() { + transition.stop(); + select.stop(); + invalid = false; + boolean selected = getSkinnable().isSelected(); + mark.setVisible(selected); + mark.setScaleX(selected ? 1.0 : 0.0); + mark.setScaleY(selected ? 1.0 : 0.0); + updateColors(); + } + private void createFillTransition() { this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT, (Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java index c7e12fa9a6..a071be08ef 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java @@ -18,17 +18,17 @@ package org.jackhuang.hmcl.ui.export; import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXCheckTreeCell; import com.jfoenix.controls.JFXTreeView; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.CheckBoxTreeItem; import javafx.scene.control.Label; import javafx.scene.control.TreeItem; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.task.Schedulers; @@ -41,6 +41,7 @@ import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.SettingsMap; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -66,13 +67,14 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard private final ModAdviser adviser; private @Nullable ModpackFileTreeItem rootNode; + /// Creates a file-selection page and starts loading the instance's exportable files. public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) { this.controller = controller; this.gameInstance = gameInstance; this.adviser = adviser; - GameInstanceID instanceId = gameInstance.getId(); JFXTreeView treeView = new JFXTreeView<>(); + treeView.setCellFactory(__ -> new ModpackFileTreeCell()); treeView.setSelectionModel(new NoneMultipleSelectionModel<>()); onEscPressed(treeView, () -> controller.onPrev(true)); @@ -196,14 +198,16 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard return node; } - private void getFilesNeeded(ModpackFileTreeItem node, String basePath, List list) { + /// Appends selected paths relative to `minecraft/`, traversing partially selected directories. + /// A `null` node contributes no paths. + private void getFilesNeeded(@Nullable ModpackFileTreeItem node, String basePath, List list) { if (node == null) return; if (node.isSelected() || node.isIndeterminate()) { if (basePath.length() > "minecraft/".length()) list.add(StringUtils.substringAfter(basePath, "minecraft/")); for (TreeItem child : node.getChildren()) { if (child instanceof ModpackFileTreeItem mChild) { - getFilesNeeded(mChild, basePath + "/" + mChild.getFileName(), list); + getFilesNeeded(mChild, basePath + "/" + mChild.getValue(), list); } } } @@ -244,39 +248,69 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard pair("minecraft/scripts", i18n("modpack.files.scripts")) ); + /// Stores a file name, optional explanation, and hierarchical selection state. + @NotNullByDefault private static final class ModpackFileTreeItem extends CheckBoxTreeItem { - private final String fileName; + /// The localized explanation for a recognized path, or `null` if none exists. + private final @Nullable String comment; + /// Creates an item whose value is the file name; only the `minecraft` root starts expanded. public ModpackFileTreeItem(String fileName, String basePath) { - this.fileName = fileName; - - HBox graphic = new HBox(); - JFXCheckBox checkBox = new JFXCheckBox(); - checkBox.selectedProperty().bindBidirectional(this.selectedProperty()); - checkBox.indeterminateProperty().bindBidirectional(this.indeterminateProperty()); - graphic.getChildren().add(checkBox); - - { - Label text = new Label(fileName); - text.setMouseTransparent(true); - graphic.getChildren().add(text); - } - if (TRANSLATION.containsKey(basePath)) { - Label comment = new Label(TRANSLATION.get(basePath)); - comment.setStyle("-fx-text-fill: -monet-on-surface-variant;"); - comment.setMouseTransparent(true); - graphic.getChildren().add(comment); - } - graphic.setPickOnBounds(false); + super(fileName); + this.comment = TRANSLATION.get(basePath); this.setExpanded("minecraft".equals(basePath)); - this.setValue(""); // To disable the default display of text - this.setGraphic(graphic); + } + } + + /// Renders file names and localized explanations alongside the bound checkbox. + @NotNullByDefault + private static final class ModpackFileTreeCell extends JFXCheckTreeCell { + + /// Contains the inherited checkbox graphic followed by the file labels. + private final HBox content = new HBox(); + + /// Displays the current file name. + private final Label fileName = new Label(); + + /// Displays the current path's optional explanation. + private final Label comment = new Label(); + + /// Creates reusable labels whose mouse events pass through to the cell. + private ModpackFileTreeCell() { + fileName.setMouseTransparent(true); + comment.setStyle("-fx-text-fill: -monet-on-surface-variant;"); + comment.setMouseTransparent(true); + content.setAlignment(Pos.CENTER_LEFT); + content.setPickOnBounds(false); } - public String getFileName() { - return fileName; - } + /// Refreshes the file labels and removes content when the cell is cleared. + @Override + protected void updateDisplay(@Nullable String item, boolean empty) { + content.getChildren().clear(); + super.updateDisplay(item, empty); + fileName.setText(null); + comment.setText(null); + if (empty || item == null) { + return; + } + + // Updating the text makes the skin reattach the current graphic to the cell. + // Clear it before moving the checkbox graphic into the content container. + setText(null); + @Nullable Node graphic = getGraphic(); + if (graphic != null) { + content.getChildren().add(graphic); + } + fileName.setText(item); + content.getChildren().add(fileName); + if (getTreeItem() instanceof ModpackFileTreeItem treeItem && treeItem.comment != null) { + comment.setText(treeItem.comment); + content.getChildren().add(comment); + } + setGraphic(content); + } } }