Move modpack file controls into reusable tree cells (#6840)

This commit is contained in:
Glavo
2026-09-08 20:46:21 +08:00
committed by GitHub
parent 7f578be08e
commit b97e7fbd55
4 changed files with 196 additions and 51 deletions
@@ -0,0 +1,101 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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 <T> the type of value stored in each tree item
@NotNullByDefault
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
/// 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);
}
}
}
@@ -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<T> extends TreeCell<T> {
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<T> extends TreeCell<T> {
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<T> treeItem = getTreeItem();
@Nullable TreeItem<T> treeItem = getTreeItem();
if (treeItem != null && treeItem.getGraphic() != null) {
if (item instanceof Node) {
setText(null);
@@ -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());
@@ -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<String> 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<String> 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<String> list) {
if (node == null) return;
if (node.isSelected() || node.isIndeterminate()) {
if (basePath.length() > "minecraft/".length())
list.add(StringUtils.substringAfter(basePath, "minecraft/"));
for (TreeItem<String> 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<String> {
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);
super(fileName);
this.comment = TRANSLATION.get(basePath);
this.setExpanded("minecraft".equals(basePath));
}
if (TRANSLATION.containsKey(basePath)) {
Label comment = new Label(TRANSLATION.get(basePath));
}
/// Renders file names and localized explanations alongside the bound checkbox.
@NotNullByDefault
private static final class ModpackFileTreeCell extends JFXCheckTreeCell<String> {
/// 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);
graphic.getChildren().add(comment);
}
graphic.setPickOnBounds(false);
this.setExpanded("minecraft".equals(basePath));
this.setValue(""); // To disable the default display of text
this.setGraphic(graphic);
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);
}
}
}