优化 ComponentList (#5385)

This commit is contained in:
Glavo
2026-02-02 20:45:08 +08:00
committed by GitHub
parent 73b30974bf
commit c0f4143618
15 changed files with 695 additions and 473 deletions

View File

@@ -17,13 +17,8 @@
*/ */
package org.jackhuang.hmcl.ui.construct; package org.jackhuang.hmcl.ui.construct;
import javafx.beans.DefaultProperty; import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings; import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.css.PseudoClass; import javafx.css.PseudoClass;
@@ -33,88 +28,21 @@ import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Control; import javafx.scene.control.Control;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.HBox; import javafx.scene.layout.*;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.util.javafx.MappedObservableList; import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.util.List; public class ComponentList extends Control implements NoPaddingComponent {
import java.util.function.Supplier;
@DefaultProperty("content")
public class ComponentList extends Control {
private final StringProperty title = new SimpleStringProperty(this, "title", "Group");
private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle", "");
private final IntegerProperty depth = new SimpleIntegerProperty(this, "depth", 0);
private boolean hasSubtitle = false;
public final ObservableList<Node> content = FXCollections.observableArrayList();
private Supplier<List<? extends Node>> lazyInitializer;
public ComponentList() { public ComponentList() {
getStyleClass().add("options-list"); getStyleClass().add("options-list");
} }
public ComponentList(Supplier<List<? extends Node>> lazyInitializer) { private final ObservableList<Node> content = FXCollections.observableArrayList();
this();
this.lazyInitializer = lazyInitializer;
}
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
public String getSubtitle() {
return subtitle.get();
}
public StringProperty subtitleProperty() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle.set(subtitle);
}
public int getDepth() {
return depth.get();
}
public IntegerProperty depthProperty() {
return depth;
}
public void setDepth(int depth) {
this.depth.set(depth);
}
public boolean isHasSubtitle() {
return hasSubtitle;
}
public void setHasSubtitle(boolean hasSubtitle) {
this.hasSubtitle = hasSubtitle;
}
public ObservableList<Node> getContent() { public ObservableList<Node> getContent() {
return content; return content;
} }
void doLazyInit() {
if (lazyInitializer != null) {
this.getContent().setAll(lazyInitializer.get());
setNeedsLayout(true);
lazyInitializer = null;
}
}
@Override @Override
public Orientation getContentBias() { public Orientation getContentBias() {
return Orientation.HORIZONTAL; return Orientation.HORIZONTAL;
@@ -130,49 +58,72 @@ public class ComponentList extends Control {
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last"); private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
private final ObservableList<Node> list; private final ObservableList<Node> list;
private final ObjectBinding<Node> firstItem;
private final ObjectBinding<Node> lastItem;
Skin(ComponentList control) { Skin(ComponentList control) {
super(control); super(control);
list = MappedObservableList.create(control.getContent(), node -> { list = MappedObservableList.create(control.getContent(), node -> {
ComponentListCell cell = new ComponentListCell(node); Pane wrapper;
cell.getStyleClass().add("options-list-item"); if (node instanceof ComponentSublist sublist) {
if (node.getProperties().containsKey("ComponentList.vgrow")) { sublist.getStyleClass().remove("options-list");
VBox.setVgrow(cell, (Priority) node.getProperties().get("ComponentList.vgrow")); sublist.getStyleClass().add("options-sublist");
wrapper = new ComponentSublistWrapper(sublist);
} else {
wrapper = new StackPane(node);
} }
if (node instanceof LineButtonBase || node instanceof LinePane || node.getProperties().containsKey("ComponentList.noPadding")) {
cell.getStyleClass().add("no-padding"); wrapper.getStyleClass().add("options-list-item");
if (node.getProperties().get("ComponentList.vgrow") instanceof Priority priority) {
VBox.setVgrow(wrapper, priority);
} }
return cell;
if (node instanceof NoPaddingComponent || node.getProperties().containsKey("ComponentList.noPadding")) {
wrapper.getStyleClass().add("no-padding");
}
return wrapper;
}); });
firstItem = Bindings.valueAt(list, 0); updateStyle();
firstItem.addListener((observable, oldValue, newValue) -> { list.addListener((InvalidationListener) o -> updateStyle());
if (newValue != null)
newValue.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true);
if (oldValue != null)
oldValue.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, false);
});
if (!list.isEmpty())
list.get(0).pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true);
lastItem = Bindings.valueAt(list, Bindings.subtract(Bindings.size(list), 1));
lastItem.addListener((observable, oldValue, newValue) -> {
if (newValue != null)
newValue.pseudoClassStateChanged(PSEUDO_CLASS_LAST, true);
if (oldValue != null)
oldValue.pseudoClassStateChanged(PSEUDO_CLASS_LAST, false);
});
if (!list.isEmpty())
list.get(list.size() - 1).pseudoClassStateChanged(PSEUDO_CLASS_LAST, true);
VBox vbox = new VBox(); VBox vbox = new VBox();
vbox.setFillWidth(true); vbox.setFillWidth(true);
Bindings.bindContent(vbox.getChildren(), list); Bindings.bindContent(vbox.getChildren(), list);
node = vbox; node = vbox;
} }
private Node prevFirstItem;
private Node prevLastItem;
private void updateStyle() {
Node firstItem;
Node lastItem;
if (list.isEmpty()) {
firstItem = null;
lastItem = null;
} else {
firstItem = list.get(0);
lastItem = list.get(list.size() - 1);
}
if (firstItem != prevFirstItem) {
if (prevFirstItem != null)
prevFirstItem.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, false);
if (firstItem != null)
firstItem.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true);
prevFirstItem = firstItem;
}
if (lastItem != prevLastItem) {
if (prevLastItem != null)
prevLastItem.pseudoClassStateChanged(PSEUDO_CLASS_LAST, false);
if (lastItem != null)
lastItem.pseudoClassStateChanged(PSEUDO_CLASS_LAST, true);
prevLastItem = lastItem;
}
}
} }
public static Node createComponentListTitle(String title) { public static Node createComponentListTitle(String title) {
@@ -189,4 +140,8 @@ public class ComponentList extends Control {
public static void setVgrow(Node node, Priority priority) { public static void setVgrow(Node node, Priority priority) {
node.getProperties().put("ComponentList.vgrow", priority); node.getProperties().put("ComponentList.vgrow", priority);
} }
public static void setNoPadding(Node node) {
node.getProperties().put("ComponentList.noPadding", true);
}
} }

View File

@@ -1,167 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 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 org.jackhuang.hmcl.ui.construct;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/**
* @author huangyuhui
*/
final class ComponentListCell extends StackPane {
private final Node content;
private Animation expandAnimation;
private boolean expanded = false;
ComponentListCell(Node content) {
this.content = content;
updateLayout();
}
private void updateLayout() {
if (content instanceof ComponentList list) {
content.getStyleClass().remove("options-list");
content.getStyleClass().add("options-sublist");
getStyleClass().add("no-padding");
VBox groupNode = new VBox();
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20);
expandIcon.setMouseTransparent(true);
HBox.setMargin(expandIcon, new Insets(0, 8, 0, 8));
VBox labelVBox = new VBox();
labelVBox.setMouseTransparent(true);
labelVBox.setAlignment(Pos.CENTER_LEFT);
boolean overrideHeaderLeft = false;
if (list instanceof ComponentSublist) {
Node leftNode = ((ComponentSublist) list).getHeaderLeft();
if (leftNode != null) {
labelVBox.getChildren().setAll(leftNode);
overrideHeaderLeft = true;
}
}
if (!overrideHeaderLeft) {
Label label = new Label();
label.textProperty().bind(list.titleProperty());
label.getStyleClass().add("title-label");
labelVBox.getChildren().add(label);
if (list.isHasSubtitle()) {
Label subtitleLabel = new Label();
subtitleLabel.textProperty().bind(list.subtitleProperty());
subtitleLabel.getStyleClass().add("subtitle-label");
subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant());
labelVBox.getChildren().add(subtitleLabel);
}
}
HBox header = new HBox();
header.setSpacing(16);
header.getChildren().add(labelVBox);
header.setPadding(new Insets(10, 16, 10, 16));
header.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(labelVBox, Priority.ALWAYS);
if (list instanceof ComponentSublist) {
Node rightNode = ((ComponentSublist) list).getHeaderRight();
if (rightNode != null)
header.getChildren().add(rightNode);
}
header.getChildren().add(expandIcon);
RipplerContainer headerRippler = new RipplerContainer(header);
groupNode.getChildren().add(headerRippler);
VBox container = new VBox();
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
if (hasPadding) {
container.setPadding(new Insets(8, 16, 10, 16));
}
FXUtils.setLimitHeight(container, 0);
FXUtils.setOverflowHidden(container);
container.getChildren().setAll(content);
groupNode.getChildren().add(container);
headerRippler.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() != MouseButton.PRIMARY)
return;
event.consume();
if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) {
expandAnimation.stop();
}
boolean expanded = !this.expanded;
this.expanded = expanded;
if (expanded) {
list.doLazyInit();
list.layout();
}
Platform.runLater(() -> {
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
double contentHeight = expanded ? newAnimatedHeight : 0;
double targetRotate = expanded ? -180 : 0;
if (AnimationUtils.isAnimationEnabled()) {
double currentRotate = expandIcon.getRotate();
Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0);
Interpolator interpolator = Motion.EASE_IN_OUT_CUBIC_EMPHASIZED;
expandAnimation = new Timeline(
new KeyFrame(duration,
new KeyValue(container.minHeightProperty(), contentHeight, interpolator),
new KeyValue(container.maxHeightProperty(), contentHeight, interpolator),
new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator))
);
expandAnimation.play();
} else {
container.setMinHeight(contentHeight);
container.setMaxHeight(contentHeight);
expandIcon.setRotate(targetRotate);
}
});
});
getChildren().setAll(groupNode);
} else {
getStyleClass().remove("no-padding");
getChildren().setAll(content);
}
}
}

View File

@@ -17,57 +17,100 @@
*/ */
package org.jackhuang.hmcl.ui.construct; package org.jackhuang.hmcl.ui.construct;
import javafx.beans.DefaultProperty; import javafx.beans.property.*;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node; import javafx.scene.Node;
@DefaultProperty("content") import java.util.List;
import java.util.function.Supplier;
public class ComponentSublist extends ComponentList { public class ComponentSublist extends ComponentList {
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft"); Supplier<List<? extends Node>> lazyInitializer;
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
public ComponentSublist() { public ComponentSublist() {
super(); super();
} }
public Node getHeaderLeft() { public ComponentSublist(Supplier<List<? extends Node>> lazyInitializer) {
return headerLeft.get(); this.lazyInitializer = lazyInitializer;
} }
public ObjectProperty<Node> headerLeftProperty() { void doLazyInit() {
if (lazyInitializer != null) {
this.getContent().setAll(lazyInitializer.get());
setNeedsLayout(true);
lazyInitializer = null;
}
}
private final StringProperty title = new SimpleStringProperty(this, "title", "Group");
public StringProperty titleProperty() {
return title;
}
public String getTitle() {
return titleProperty().get();
}
public void setTitle(String title) {
titleProperty().set(title);
}
private StringProperty subtitle;
public StringProperty subtitleProperty() {
if (subtitle == null)
subtitle = new SimpleStringProperty(this, "subtitle", "");
return subtitle;
}
public String getSubtitle() {
return subtitleProperty().get();
}
public void setSubtitle(String subtitle) {
subtitleProperty().set(subtitle);
}
private boolean hasSubtitle = false;
public boolean isHasSubtitle() {
return hasSubtitle;
}
public void setHasSubtitle(boolean hasSubtitle) {
this.hasSubtitle = hasSubtitle;
}
private Node headerLeft;
public Node getHeaderLeft() {
return headerLeft; return headerLeft;
} }
public void setHeaderLeft(Node headerLeft) { public void setHeaderLeft(Node headerLeft) {
this.headerLeft.set(headerLeft); this.headerLeft = headerLeft;
} }
private Node headerRight;
public Node getHeaderRight() { public Node getHeaderRight() {
return headerRight.get();
}
public ObjectProperty<Node> headerRightProperty() {
return headerRight; return headerRight;
} }
public void setHeaderRight(Node headerRight) { public void setHeaderRight(Node headerRight) {
this.headerRight.set(headerRight); this.headerRight = headerRight;
} }
private boolean componentPadding = true;
public boolean hasComponentPadding() { public boolean hasComponentPadding() {
return componentPadding.get();
}
public BooleanProperty componentPaddingProperty() {
return componentPadding; return componentPadding;
} }
public void setComponentPadding(boolean componentPadding) { public void setComponentPadding(boolean componentPadding) {
this.componentPadding.set(componentPadding); this.componentPadding = componentPadding;
} }
} }

View File

@@ -0,0 +1,146 @@
/*
* 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 org.jackhuang.hmcl.ui.construct;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
/// @author Glavo
final class ComponentSublistWrapper extends VBox implements NoPaddingComponent {
private VBox container;
private Animation expandAnimation;
private boolean expanded = false;
ComponentSublistWrapper(ComponentSublist sublist) {
boolean noPadding = !sublist.hasComponentPadding();
Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20);
expandIcon.setMouseTransparent(true);
HBox.setMargin(expandIcon, new Insets(0, 8, 0, 8));
VBox labelVBox = new VBox();
labelVBox.setMouseTransparent(true);
labelVBox.setAlignment(Pos.CENTER_LEFT);
Node leftNode = sublist.getHeaderLeft();
if (leftNode == null) {
Label label = new Label();
label.textProperty().bind(sublist.titleProperty());
label.getStyleClass().add("title-label");
labelVBox.getChildren().add(label);
if (sublist.isHasSubtitle()) {
Label subtitleLabel = new Label();
subtitleLabel.textProperty().bind(sublist.subtitleProperty());
subtitleLabel.getStyleClass().add("subtitle-label");
subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant());
labelVBox.getChildren().add(subtitleLabel);
}
} else {
labelVBox.getChildren().setAll(leftNode);
}
HBox header = new HBox();
header.setSpacing(16);
header.getChildren().add(labelVBox);
header.setPadding(new Insets(10, 16, 10, 16));
header.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(labelVBox, Priority.ALWAYS);
Node rightNode = sublist.getHeaderRight();
if (rightNode != null)
header.getChildren().add(rightNode);
header.getChildren().add(expandIcon);
RipplerContainer headerRippler = new RipplerContainer(header);
this.getChildren().add(headerRippler);
headerRippler.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() != MouseButton.PRIMARY)
return;
event.consume();
if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) {
expandAnimation.stop();
}
boolean expanded = !this.expanded;
this.expanded = expanded;
if (expanded) {
sublist.doLazyInit();
if (container == null) {
this.container = new VBox();
if (!noPadding) {
container.setPadding(new Insets(8, 16, 10, 16));
}
FXUtils.setLimitHeight(container, 0);
FXUtils.setOverflowHidden(container);
container.getChildren().setAll(sublist);
ComponentSublistWrapper.this.getChildren().add(container);
this.applyCss();
}
this.layout();
}
Platform.runLater(() -> {
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
double contentHeight = expanded ? (sublist.prefHeight(sublist.getWidth()) + (noPadding ? 4 : 8 + 10)) : 0;
double targetRotate = expanded ? -180 : 0;
if (AnimationUtils.isAnimationEnabled()) {
double currentRotate = expandIcon.getRotate();
Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0);
Interpolator interpolator = Motion.EASE_IN_OUT_CUBIC_EMPHASIZED;
expandAnimation = new Timeline(
new KeyFrame(duration,
new KeyValue(container.minHeightProperty(), contentHeight, interpolator),
new KeyValue(container.maxHeightProperty(), contentHeight, interpolator),
new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator))
);
expandAnimation.play();
} else {
container.setMinHeight(contentHeight);
container.setMaxHeight(contentHeight);
expandIcon.setRotate(targetRotate);
}
});
});
}
}

View File

@@ -29,7 +29,7 @@ import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
/// @author Glavo /// @author Glavo
public abstract class LineButtonBase extends StackPane { public abstract class LineButtonBase extends StackPane implements NoPaddingComponent {
private static final Insets PADDING = new Insets(8, 8, 8, 16); private static final Insets PADDING = new Insets(8, 8, 8, 16);

View File

@@ -28,7 +28,7 @@ import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
/// @author Glavo /// @author Glavo
public class LinePane extends BorderPane { public class LinePane extends BorderPane implements NoPaddingComponent {
private static final Insets PADDING = new Insets(8, 8, 8, 16); private static final Insets PADDING = new Insets(8, 8, 8, 16);

View File

@@ -1,164 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 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 org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXColorPicker;
import com.jfoenix.controls.JFXRadioButton;
import javafx.beans.NamedArg;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.Collection;
import java.util.Optional;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiColorItem extends ComponentList {
private final StringProperty customTitle = new SimpleStringProperty(this, "customTitle", i18n("selector.custom"));
private final StringProperty chooserTitle = new SimpleStringProperty(this, "chooserTitle", i18n("selector.choose_file"));
private final ToggleGroup group = new ToggleGroup();
private final JFXColorPicker colorPicker = new JFXColorPicker();
private final JFXRadioButton radioCustom = new JFXRadioButton();
private final BorderPane custom = new BorderPane();
private final VBox pane = new VBox();
private final boolean hasCustom;
private Consumer<Toggle> toggleSelectedListener;
private Consumer<Color> colorConsumer;
public MultiColorItem(@NamedArg(value = "hasCustom", defaultValue = "true") boolean hasCustom) {
this.hasCustom = hasCustom;
radioCustom.textProperty().bind(customTitleProperty());
radioCustom.setToggleGroup(group);
colorPicker.disableProperty().bind(radioCustom.selectedProperty().not());
colorPicker.setOnAction(e -> Optional.ofNullable(colorConsumer).ifPresent(c -> c.accept(colorPicker.getValue())));
custom.setLeft(radioCustom);
custom.setStyle("-fx-padding: 3;");
HBox right = new HBox();
right.setSpacing(3);
right.getChildren().addAll(colorPicker);
custom.setRight(right);
FXUtils.setLimitHeight(custom, 40);
pane.setStyle("-fx-padding: 0 0 10 0;");
pane.setSpacing(8);
if (hasCustom)
pane.getChildren().add(custom);
getContent().add(pane);
group.selectedToggleProperty().addListener((a, b, newValue) -> {
if (toggleSelectedListener != null)
toggleSelectedListener.accept(newValue);
});
}
public Node createChildren(String title) {
return createChildren(title, null);
}
public Node createChildren(String title, Object userData) {
return createChildren(title, "", userData);
}
public Node createChildren(String title, String subtitle, Object userData) {
BorderPane pane = new BorderPane();
pane.setStyle("-fx-padding: 3;");
JFXRadioButton left = new JFXRadioButton(title);
left.setToggleGroup(group);
left.setUserData(userData);
pane.setLeft(left);
Label right = new Label(subtitle);
right.getStyleClass().add("subtitle-label");
right.setStyle("-fx-font-size: 10;");
pane.setRight(right);
return pane;
}
public void loadChildren(Collection<Node> list) {
pane.getChildren().setAll(list);
if (hasCustom)
pane.getChildren().add(custom);
}
public ToggleGroup getGroup() {
return group;
}
public String getCustomTitle() {
return customTitle.get();
}
public StringProperty customTitleProperty() {
return customTitle;
}
public void setCustomTitle(String customTitle) {
this.customTitle.set(customTitle);
}
public String getChooserTitle() {
return chooserTitle.get();
}
public StringProperty chooserTitleProperty() {
return chooserTitle;
}
public void setChooserTitle(String chooserTitle) {
this.chooserTitle.set(chooserTitle);
}
public void setCustomUserData(Object userData) {
radioCustom.setUserData(userData);
}
public boolean isCustomToggle(Toggle toggle) {
return radioCustom == toggle;
}
public void setToggleSelectedListener(Consumer<Toggle> consumer) {
toggleSelectedListener = consumer;
}
public void setOnColorPickerChanged(Consumer<Color> consumer) {
colorConsumer = consumer;
}
public void setColor(Color color) {
colorPicker.setValue(color);
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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 org.jackhuang.hmcl.ui.construct;
/// Marker interface for no padding in [ComponentList].
///
/// @author Glavo
interface NoPaddingComponent {
}

View File

@@ -0,0 +1,236 @@
/*
* 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 org.jackhuang.hmcl.ui.construct;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableObjectProperty;
import javafx.css.StyleableProperty;
import javafx.css.converter.InsetsConverter;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
// TODO: We plan to replace ComponentList with this class, but we need to address some issues first
/// @author Glavo
public final class OptionsList extends Control {
public OptionsList() {
this.getStyleClass().add("options-list");
}
@Override
protected Skin<?> createDefaultSkin() {
return new OptionsListSkin(this);
}
private final ObservableList<Element> elements = FXCollections.observableArrayList();
public ObservableList<Element> getElements() {
return elements;
}
public void addTitle(String title) {
elements.add(new Title(title));
}
public void addNode(Node node) {
elements.add(new NodeElement(node));
}
public void addListElement(@NotNull Node node) {
elements.add(new ListElement(node));
}
public void addListElements(@NotNull Node... nodes) {
for (Node node : nodes) {
elements.add(new ListElement(node));
}
}
private final StyleableObjectProperty<Insets> contentPadding = new StyleableObjectProperty<>() {
@Override
public Object getBean() {
return OptionsList.this;
}
@Override
public String getName() {
return "contentPadding";
}
@Override
public javafx.css.CssMetaData<OptionsList, Insets> getCssMetaData() {
return StyleableProperties.CONTENT_PADDING;
}
};
public StyleableObjectProperty<Insets> contentPaddingProperty() {
return contentPadding;
}
public Insets getContentPadding() {
return contentPaddingProperty().get();
}
public void setContentPadding(Insets padding) {
contentPaddingProperty().set(padding);
}
private static final class StyleableProperties {
private static final CssMetaData<OptionsList, Insets> CONTENT_PADDING = new CssMetaData<>("-jfx-content-padding", InsetsConverter.getInstance()) {
@Override
public boolean isSettable(OptionsList styleable) {
return styleable.contentPadding == null || !styleable.contentPadding.isBound();
}
@Override
public StyleableProperty<Insets> getStyleableProperty(OptionsList styleable) {
return styleable.contentPaddingProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
Collections.addAll(styleables, CONTENT_PADDING);
STYLEABLES = List.copyOf(styleables);
}
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.STYLEABLES;
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static abstract class Element {
protected Node node;
Node getNode() {
if (node == null)
node = createNode();
return node;
}
protected abstract Node createNode();
}
public static final class Title extends Element {
private final @NotNull String title;
public Title(@NotNull String title) {
this.title = title;
}
@Override
protected Node createNode() {
Label label = new Label(title);
label.setPadding(new Insets(8, 0, 8, 0));
return label;
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof Title that && Objects.equals(this.title, that.title);
}
@Override
public int hashCode() {
return title.hashCode();
}
@Override
public String toString() {
return "Title[%s]".formatted(title);
}
}
public static final class NodeElement extends Element {
public NodeElement(@NotNull Node node) {
this.node = node;
}
@Override
protected Node createNode() {
return node;
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof NodeElement that && this.node.equals(that.node);
}
@Override
public int hashCode() {
return node.hashCode();
}
@Override
public String toString() {
return "NodeElement[node=%s]".formatted(node);
}
}
public static final class ListElement extends Element {
private final Node original;
public ListElement(@NotNull Node node) {
this.original = node;
}
@Override
protected Node createNode() {
if (original instanceof ComponentSublist sublist) {
return new ComponentSublistWrapper(sublist);
} else {
return original;
}
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof ListElement that && this.original.equals(that.original);
}
@Override
public int hashCode() {
return original.hashCode();
}
@Override
public String toString() {
return "ListElement[node=%s]".formatted(original);
}
}
}

View File

@@ -0,0 +1,152 @@
/*
* 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 org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXListView;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.FXUtils;
/// @author Glavo
public final class OptionsListSkin extends SkinBase<OptionsList> {
private final JFXListView<OptionsList.Element> listView;
private final ObjectBinding<ContentPaddings> contentPaddings;
OptionsListSkin(OptionsList control) {
super(control);
this.listView = new JFXListView<>();
listView.setItems(control.getElements());
listView.setCellFactory(listView1 -> new Cell());
this.contentPaddings = Bindings.createObjectBinding(() -> {
Insets padding = control.getContentPadding();
return padding == null ? ContentPaddings.EMPTY : new ContentPaddings(
new Insets(padding.getTop(), padding.getRight(), 0, padding.getLeft()),
new Insets(0, padding.getRight(), padding.getBottom(), padding.getLeft()),
new Insets(0, padding.getRight(), 0, padding.getLeft())
);
}, control.contentPaddingProperty());
this.getChildren().setAll(listView);
}
private record ContentPaddings(Insets first, Insets last, Insets middle) {
static final ContentPaddings EMPTY = new ContentPaddings(Insets.EMPTY, Insets.EMPTY, Insets.EMPTY);
}
private final class Cell extends ListCell<OptionsList.Element> {
private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first");
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener updateStyleListener = o -> updateStyle();
private StackPane wrapper;
public Cell() {
FXUtils.limitCellWidth(listView, this);
WeakInvalidationListener weakListener = new WeakInvalidationListener(updateStyleListener);
listView.itemsProperty().addListener((o, oldValue, newValue) -> {
if (oldValue != null)
oldValue.removeListener(weakListener);
if (newValue != null)
newValue.addListener(weakListener);
weakListener.invalidated(o);
});
itemProperty().addListener(weakListener);
contentPaddings.addListener(weakListener);
}
@Override
protected void updateItem(OptionsList.Element item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
} else if (item instanceof OptionsList.ListElement element) {
if (wrapper == null)
wrapper = createWrapper();
else
wrapper.getStyleClass().remove("no-padding");
Node node = element.getNode();
if (node instanceof NoPaddingComponent || node.getProperties().containsKey("ComponentList.noPadding"))
wrapper.getStyleClass().add("no-padding");
wrapper.getChildren().setAll(node);
setGraphic(wrapper);
} else {
setGraphic(item.getNode());
}
updateStyle();
}
private StackPane createWrapper() {
var wrapper = new StackPane();
wrapper.setAlignment(Pos.CENTER_LEFT);
wrapper.getStyleClass().add("options-list-item");
updateStyle();
return wrapper;
}
private void updateStyle() {
OptionsList.Element item = getItem();
int index = getIndex();
ObservableList<OptionsList.Element> items = getListView().getItems();
if (item == null || index < 0 || index >= items.size()) {
this.setPadding(Insets.EMPTY);
return;
}
boolean isFirst = index == 0;
boolean isLast = index == items.size() - 1;
ContentPaddings paddings = contentPaddings.get();
if (isFirst) {
this.setPadding(paddings.first);
} else if (isLast) {
this.setPadding(paddings.last);
} else {
this.setPadding(paddings.middle);
}
if (item instanceof OptionsList.ListElement && wrapper != null) {
wrapper.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, isFirst || !(items.get(index - 1) instanceof OptionsList.ListElement));
wrapper.pseudoClassStateChanged(PSEUDO_CLASS_LAST, isLast || !(items.get(index + 1) instanceof OptionsList.ListElement));
}
}
}
}

View File

@@ -197,8 +197,7 @@ public class PersonalizationPage extends StackPane {
} }
{ {
ComponentList logPane = new ComponentSublist(); ComponentList logPane = new ComponentList();
logPane.setTitle(i18n("settings.launcher.log"));
{ {
VBox fontPane = new VBox(); VBox fontPane = new VBox();
@@ -254,8 +253,7 @@ public class PersonalizationPage extends StackPane {
} }
{ {
ComponentSublist fontPane = new ComponentSublist(); ComponentList fontPane = new ComponentList();
fontPane.setTitle(i18n("settings.launcher.font"));
{ {
VBox vbox = new VBox(); VBox vbox = new VBox();

View File

@@ -79,7 +79,6 @@ public final class ProfilePage extends BorderPane implements DecoratorPage {
rootPane.setStyle("-fx-padding: 20;"); rootPane.setStyle("-fx-padding: 20;");
{ {
ComponentList componentList = new ComponentList(); ComponentList componentList = new ComponentList();
componentList.setDepth(1);
{ {
BorderPane profileNamePane = new BorderPane(); BorderPane profileNamePane = new BorderPane();
{ {

View File

@@ -455,7 +455,7 @@ public class TerracottaControllerPage extends StackPane {
exportLogInner.setTitle(i18n("terracotta.export_log")); exportLogInner.setTitle(i18n("terracotta.export_log"));
exportLogInner.setSubtitle(i18n("terracotta.export_log.desc")); exportLogInner.setSubtitle(i18n("terracotta.export_log.desc"));
exportLog.setContent(exportLogInner); exportLog.setContent(exportLogInner);
exportLog.getProperties().put("ComponentList.noPadding", true); ComponentList.setNoPadding(exportLog);
// FIXME: SpinnerPane loses its content width in loading state. // FIXME: SpinnerPane loses its content width in loading state.
exportLog.minHeightProperty().bind(back.heightProperty()); exportLog.minHeightProperty().bind(back.heightProperty());
@@ -559,7 +559,7 @@ public class TerracottaControllerPage extends StackPane {
getChildren().setAll(scrollPane); getChildren().setAll(scrollPane);
} }
private ComponentList getThirdPartyDownloadNodes() { private ComponentSublist getThirdPartyDownloadNodes() {
ComponentSublist locals = new ComponentSublist(); ComponentSublist locals = new ComponentSublist();
locals.setComponentPadding(false); locals.setComponentPadding(false);
@@ -587,7 +587,7 @@ public class TerracottaControllerPage extends StackPane {
i18n("message.info"), MessageDialogPane.MessageType.INFO, i18n("message.info"), MessageDialogPane.MessageType.INFO,
() -> FXUtils.openLink(url) () -> FXUtils.openLink(url)
)); ));
container.getProperties().put("ComponentList.noPadding", true); ComponentList.setNoPadding(container);
locals.getContent().add(container); locals.getContent().add(container);
} }
return locals; return locals;
@@ -666,7 +666,7 @@ public class TerracottaControllerPage extends StackPane {
container.getChildren().setAll(nodes); container.getChildren().setAll(nodes);
}, button.title, button.subTitle, button.left, button.right)); }, button.title, button.subTitle, button.left, button.right));
button.getProperties().put("ComponentList.noPadding", true); ComponentList.setNoPadding(button);
return button; return button;
} }

View File

@@ -300,13 +300,13 @@ public class DownloadPage extends Control implements DecoratorPage {
for (String gameVersion : control.versions.keys().stream() for (String gameVersion : control.versions.keys().stream()
.sorted(Collections.reverseOrder(GameVersionNumber::compare)) .sorted(Collections.reverseOrder(GameVersionNumber::compare))
.collect(Collectors.toList())) { .toList()) {
List<RemoteMod.Version> versions = control.versions.get(gameVersion); List<RemoteMod.Version> versions = control.versions.get(gameVersion);
if (versions == null || versions.isEmpty()) { if (versions == null || versions.isEmpty()) {
continue; continue;
} }
ComponentList sublist = new ComponentList(() -> { var sublist = new ComponentSublist(() -> {
ArrayList<ModItem> items = new ArrayList<>(versions.size()); ArrayList<ModItem> items = new ArrayList<>(versions.size());
for (RemoteMod.Version v : versions) { for (RemoteMod.Version v : versions) {
items.add(new ModItem(control.addon, v, control)); items.add(new ModItem(control.addon, v, control));
@@ -465,13 +465,14 @@ public class DownloadPage extends Control implements DecoratorPage {
box.getChildren().setAll(modItem); box.getChildren().setAll(modItem);
SpinnerPane spinnerPane = new SpinnerPane(); SpinnerPane spinnerPane = new SpinnerPane();
ScrollPane scrollPane = new ScrollPane(); ScrollPane scrollPane = new ScrollPane();
ComponentList dependenciesList = new ComponentList(Lang::immutableListOf); ComponentList dependenciesList = new ComponentList();
loadDependencies(version, selfPage, spinnerPane, dependenciesList); loadDependencies(version, selfPage, spinnerPane, dependenciesList);
spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList)); spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList));
scrollPane.setContent(dependenciesList); scrollPane.setContent(dependenciesList);
scrollPane.setFitToWidth(true); scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true); scrollPane.setFitToHeight(true);
FXUtils.smoothScrolling(scrollPane);
spinnerPane.setContent(scrollPane); spinnerPane.setContent(scrollPane);
box.getChildren().add(spinnerPane); box.getChildren().add(spinnerPane);
VBox.setVgrow(spinnerPane, Priority.SOMETIMES); VBox.setVgrow(spinnerPane, Priority.SOMETIMES);

View File

@@ -173,7 +173,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
{ {
componentList = new ComponentList(); componentList = new ComponentList();
componentList.setDepth(1);
if (!globalSetting) { if (!globalSetting) {
BorderPane copyGlobalPane = new BorderPane(); BorderPane copyGlobalPane = new BorderPane();