diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java index b622a6114..e5c4396fc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java @@ -17,13 +17,8 @@ */ package org.jackhuang.hmcl.ui.construct; -import javafx.beans.DefaultProperty; +import javafx.beans.InvalidationListener; 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.ObservableList; import javafx.css.PseudoClass; @@ -33,88 +28,21 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Label; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; +import javafx.scene.layout.*; import org.jackhuang.hmcl.util.javafx.MappedObservableList; -import java.util.List; -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 content = FXCollections.observableArrayList(); - private Supplier> lazyInitializer; +public class ComponentList extends Control implements NoPaddingComponent { public ComponentList() { getStyleClass().add("options-list"); } - public ComponentList(Supplier> lazyInitializer) { - 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; - } + private final ObservableList content = FXCollections.observableArrayList(); public ObservableList getContent() { return content; } - void doLazyInit() { - if (lazyInitializer != null) { - this.getContent().setAll(lazyInitializer.get()); - setNeedsLayout(true); - lazyInitializer = null; - } - } - @Override public Orientation getContentBias() { return Orientation.HORIZONTAL; @@ -130,49 +58,72 @@ public class ComponentList extends Control { private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last"); private final ObservableList list; - private final ObjectBinding firstItem; - private final ObjectBinding lastItem; Skin(ComponentList control) { super(control); list = MappedObservableList.create(control.getContent(), node -> { - ComponentListCell cell = new ComponentListCell(node); - cell.getStyleClass().add("options-list-item"); - if (node.getProperties().containsKey("ComponentList.vgrow")) { - VBox.setVgrow(cell, (Priority) node.getProperties().get("ComponentList.vgrow")); + Pane wrapper; + if (node instanceof ComponentSublist sublist) { + sublist.getStyleClass().remove("options-list"); + 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); - firstItem.addListener((observable, oldValue, newValue) -> { - 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); + updateStyle(); + list.addListener((InvalidationListener) o -> updateStyle()); VBox vbox = new VBox(); vbox.setFillWidth(true); Bindings.bindContent(vbox.getChildren(), list); 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) { @@ -189,4 +140,8 @@ public class ComponentList extends Control { public static void setVgrow(Node node, Priority priority) { node.getProperties().put("ComponentList.vgrow", priority); } + + public static void setNoPadding(Node node) { + node.getProperties().put("ComponentList.noPadding", true); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java deleted file mode 100644 index 20503523c..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 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 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); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java index 3516399aa..d5692c024 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java @@ -17,57 +17,100 @@ */ package org.jackhuang.hmcl.ui.construct; -import javafx.beans.DefaultProperty; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.*; import javafx.scene.Node; -@DefaultProperty("content") +import java.util.List; +import java.util.function.Supplier; + public class ComponentSublist extends ComponentList { - private final ObjectProperty headerLeft = new SimpleObjectProperty<>(this, "headerLeft"); - private final ObjectProperty headerRight = new SimpleObjectProperty<>(this, "headerRight"); - private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true); + Supplier> lazyInitializer; public ComponentSublist() { super(); } - public Node getHeaderLeft() { - return headerLeft.get(); + public ComponentSublist(Supplier> lazyInitializer) { + this.lazyInitializer = lazyInitializer; } - public ObjectProperty 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; } public void setHeaderLeft(Node headerLeft) { - this.headerLeft.set(headerLeft); + this.headerLeft = headerLeft; } + private Node headerRight; + public Node getHeaderRight() { - return headerRight.get(); - } - - public ObjectProperty headerRightProperty() { return headerRight; } public void setHeaderRight(Node headerRight) { - this.headerRight.set(headerRight); + this.headerRight = headerRight; } + private boolean componentPadding = true; + public boolean hasComponentPadding() { - return componentPadding.get(); - } - - public BooleanProperty componentPaddingProperty() { return componentPadding; } public void setComponentPadding(boolean componentPadding) { - this.componentPadding.set(componentPadding); + this.componentPadding = componentPadding; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java new file mode 100644 index 000000000..9b2faa775 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java @@ -0,0 +1,146 @@ +/* + * 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 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); + } + }); + }); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java index 4cf4f1307..b437e4a1c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java @@ -29,7 +29,7 @@ import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; /// @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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LinePane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LinePane.java index 111832bff..101da46eb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LinePane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LinePane.java @@ -28,7 +28,7 @@ import javafx.scene.layout.Region; import javafx.scene.layout.VBox; /// @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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiColorItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiColorItem.java deleted file mode 100644 index 8e5fb183a..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiColorItem.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 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 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 toggleSelectedListener; - private Consumer 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 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 consumer) { - toggleSelectedListener = consumer; - } - - public void setOnColorPickerChanged(Consumer consumer) { - colorConsumer = consumer; - } - - public void setColor(Color color) { - colorPicker.setValue(color); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoPaddingComponent.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoPaddingComponent.java new file mode 100644 index 000000000..5f6a67689 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoPaddingComponent.java @@ -0,0 +1,24 @@ +/* + * 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 org.jackhuang.hmcl.ui.construct; + +/// Marker interface for no padding in [ComponentList]. +/// +/// @author Glavo +interface NoPaddingComponent { +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsList.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsList.java new file mode 100644 index 000000000..842485a37 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsList.java @@ -0,0 +1,236 @@ +/* + * 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 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 elements = FXCollections.observableArrayList(); + + public ObservableList 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 contentPadding = new StyleableObjectProperty<>() { + @Override + public Object getBean() { + return OptionsList.this; + } + + @Override + public String getName() { + return "contentPadding"; + } + + @Override + public javafx.css.CssMetaData getCssMetaData() { + return StyleableProperties.CONTENT_PADDING; + } + }; + + public StyleableObjectProperty 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 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 getStyleableProperty(OptionsList styleable) { + return styleable.contentPaddingProperty(); + } + }; + + private static final List> STYLEABLES; + + static { + List> styleables = new ArrayList<>(Control.getClassCssMetaData()); + Collections.addAll(styleables, CONTENT_PADDING); + STYLEABLES = List.copyOf(styleables); + } + } + + public static List> getClassCssMetaData() { + return StyleableProperties.STYLEABLES; + } + + @Override + public List> 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); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsListSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsListSkin.java new file mode 100644 index 000000000..6e0ead568 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsListSkin.java @@ -0,0 +1,152 @@ +/* + * 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 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 { + + private final JFXListView listView; + private final ObjectBinding 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 { + 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 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)); + } + } + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java index 0a1bc7422..bc12984fa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java @@ -197,8 +197,7 @@ public class PersonalizationPage extends StackPane { } { - ComponentList logPane = new ComponentSublist(); - logPane.setTitle(i18n("settings.launcher.log")); + ComponentList logPane = new ComponentList(); { VBox fontPane = new VBox(); @@ -254,8 +253,7 @@ public class PersonalizationPage extends StackPane { } { - ComponentSublist fontPane = new ComponentSublist(); - fontPane.setTitle(i18n("settings.launcher.font")); + ComponentList fontPane = new ComponentList(); { VBox vbox = new VBox(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java index 2070664e0..881b93b91 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java @@ -79,7 +79,6 @@ public final class ProfilePage extends BorderPane implements DecoratorPage { rootPane.setStyle("-fx-padding: 20;"); { ComponentList componentList = new ComponentList(); - componentList.setDepth(1); { BorderPane profileNamePane = new BorderPane(); { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java index ce24167ed..36390a2ce 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java @@ -455,7 +455,7 @@ public class TerracottaControllerPage extends StackPane { exportLogInner.setTitle(i18n("terracotta.export_log")); exportLogInner.setSubtitle(i18n("terracotta.export_log.desc")); exportLog.setContent(exportLogInner); - exportLog.getProperties().put("ComponentList.noPadding", true); + ComponentList.setNoPadding(exportLog); // FIXME: SpinnerPane loses its content width in loading state. exportLog.minHeightProperty().bind(back.heightProperty()); @@ -559,7 +559,7 @@ public class TerracottaControllerPage extends StackPane { getChildren().setAll(scrollPane); } - private ComponentList getThirdPartyDownloadNodes() { + private ComponentSublist getThirdPartyDownloadNodes() { ComponentSublist locals = new ComponentSublist(); locals.setComponentPadding(false); @@ -587,7 +587,7 @@ public class TerracottaControllerPage extends StackPane { i18n("message.info"), MessageDialogPane.MessageType.INFO, () -> FXUtils.openLink(url) )); - container.getProperties().put("ComponentList.noPadding", true); + ComponentList.setNoPadding(container); locals.getContent().add(container); } return locals; @@ -666,7 +666,7 @@ public class TerracottaControllerPage extends StackPane { container.getChildren().setAll(nodes); }, button.title, button.subTitle, button.left, button.right)); - button.getProperties().put("ComponentList.noPadding", true); + ComponentList.setNoPadding(button); return button; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index 14daccfba..cb0513b64 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -300,13 +300,13 @@ public class DownloadPage extends Control implements DecoratorPage { for (String gameVersion : control.versions.keys().stream() .sorted(Collections.reverseOrder(GameVersionNumber::compare)) - .collect(Collectors.toList())) { + .toList()) { List versions = control.versions.get(gameVersion); if (versions == null || versions.isEmpty()) { continue; } - ComponentList sublist = new ComponentList(() -> { + var sublist = new ComponentSublist(() -> { ArrayList items = new ArrayList<>(versions.size()); for (RemoteMod.Version v : versions) { items.add(new ModItem(control.addon, v, control)); @@ -465,13 +465,14 @@ public class DownloadPage extends Control implements DecoratorPage { box.getChildren().setAll(modItem); SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); - ComponentList dependenciesList = new ComponentList(Lang::immutableListOf); + ComponentList dependenciesList = new ComponentList(); loadDependencies(version, selfPage, spinnerPane, dependenciesList); spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList)); scrollPane.setContent(dependenciesList); scrollPane.setFitToWidth(true); scrollPane.setFitToHeight(true); + FXUtils.smoothScrolling(scrollPane); spinnerPane.setContent(scrollPane); box.getChildren().add(spinnerPane); VBox.setVgrow(spinnerPane, Priority.SOMETIMES); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java index 50dc26ce2..ee634fdb2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java @@ -173,7 +173,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag { componentList = new ComponentList(); - componentList.setDepth(1); if (!globalSetting) { BorderPane copyGlobalPane = new BorderPane();