Skip to content
Merged
10 changes: 0 additions & 10 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.geometry.Bounds;
import javafx.geometry.Pos;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Node;
Expand All @@ -51,7 +50,6 @@
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
Expand Down Expand Up @@ -417,14 +415,6 @@ public static double getLimitHeight(Region region) {
return region.getMaxHeight();
}

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public static method limitingSize(Node, double, double) has been removed from FXUtils. This is a breaking change for any external code or plugins that may have been using this utility method. The functionality has been replaced by the new ImageContainer class which provides better encapsulation and rounded corner support. Consider adding a deprecation notice in a previous release for smooth migration, or providing a migration guide.

Suggested change
/**
* Restored for backward compatibility with older HMCL versions.
* <p>
* This method was previously used to limit the size of a {@link Node}.
* The functionality has been superseded by the {@code ImageContainer}
* class, which provides better encapsulation and rounded corner support.
* </p>
*
* @param node the node whose size should be limited
* @param width the maximum width
* @param height the maximum height
* @return never returns normally
* @deprecated Use the new {@code ImageContainer}-based API instead.
*/
@Deprecated
public static Node limitingSize(Node node, double width, double height) {
throw new UnsupportedOperationException(
"FXUtils.limitingSize(Node, double, double) has been removed. " +
"Please migrate to the ImageContainer-based API.");
}

Copilot uses AI. Check for mistakes.
public static Node limitingSize(Node node, double width, double height) {
StackPane pane = new StackPane(node);
pane.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(pane, width);
FXUtils.setLimitHeight(pane, height);
return pane;
}

public static void limitCellWidth(ListView<?> listView, ListCell<?> cell) {
ReadOnlyDoubleProperty widthProperty;

Expand Down
15 changes: 7 additions & 8 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,15 @@
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.ui.construct.ImageContainer;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
Expand Down Expand Up @@ -295,14 +294,14 @@ private static final class InstallerItemSkin extends SkinBase<InstallerItem> {
paneWrapper.pseudoClassStateChanged(CARD, control.style == Style.CARD);

if (control.iconType != null) {
ImageView view = new ImageView(control.iconType.getIcon());
Node node = FXUtils.limitingSize(view, 32, 32);
node.setMouseTransparent(true);
node.getStyleClass().add("installer-item-image");
pane.getChildren().add(node);
var imageContainer = new ImageContainer(32);
imageContainer.setImage(control.iconType.getIcon());
imageContainer.setMouseTransparent(true);
imageContainer.getStyleClass().add("installer-item-image");
pane.getChildren().add(imageContainer);

if (control.style == Style.CARD) {
VBox.setMargin(node, new Insets(8, 0, 16, 0));
VBox.setMargin(imageContainer, new Insets(8, 0, 16, 0));
}
}

Expand Down
163 changes: 163 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImageContainer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleableProperty;
import javafx.css.converter.SizeConverter;
import javafx.geometry.Pos;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Rectangle;
import org.jackhuang.hmcl.ui.FXUtils;

import java.util.ArrayList;
import java.util.List;

/// A custom ImageView with fixed size and corner radius support.
public class ImageContainer extends StackPane {

private static final String DEFAULT_STYLE_CLASS = "image-container";

private final ImageView imageView = new ImageView();
private final Rectangle clip = new Rectangle();

public ImageContainer(double size) {
this(size, size);
}

public ImageContainer(double width, double height) {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);

FXUtils.setLimitWidth(this, width);
FXUtils.setLimitHeight(this, height);

imageView.setPreserveRatio(true);
FXUtils.limitSize(imageView, width, height);
StackPane.setAlignment(imageView, Pos.CENTER);

clip.setWidth(width);
clip.setHeight(height);
updateCornerRadius(getCornerRadius());
this.setClip(clip);

this.getChildren().setAll(imageView);
}

private void updateCornerRadius(double radius) {
clip.setArcWidth(radius);
clip.setArcHeight(radius);
Comment on lines +68 to +69
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The arc width and arc height should be set to twice the radius value for proper rounded corners with the desired radius. In JavaFX, arcWidth and arcHeight define the full width and height of the ellipse used to round the corners, not the radius.

Change the implementation to: clip.setArcWidth(radius * 2); clip.setArcHeight(radius * 2);

Suggested change
clip.setArcWidth(radius);
clip.setArcHeight(radius);
clip.setArcWidth(radius * 2);
clip.setArcHeight(radius * 2);

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +69
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming of the "cornerRadius" property is misleading. In JavaFX's Rectangle, setArcWidth and setArcHeight set the full diameter of the arc used for rounded corners, not the radius. The property should be renamed to "cornerArcSize" or "cornerDiameter", or alternatively, the implementation should multiply the radius value by 2 when setting the arc width/height. Currently, a cornerRadius value of 6.0 creates a corner with an arc diameter of 6 pixels (radius 3 pixels), not a radius of 6 pixels.

Suggested change
clip.setArcWidth(radius);
clip.setArcHeight(radius);
// JavaFX Rectangle#setArcWidth/Height expect the full diameter of the arc.
// Interpret the cornerRadius value as a true radius by doubling it here.
double diameter = radius * 2;
clip.setArcWidth(diameter);
clip.setArcHeight(diameter);

Copilot uses AI. Check for mistakes.
}

private static final double DEFAULT_CORNER_RADIUS = 6.0;

private StyleableDoubleProperty cornerRadius;

public StyleableDoubleProperty cornerRadiusProperty() {
if (this.cornerRadius == null) {
cornerRadius = new StyleableDoubleProperty() {
@Override
public Object getBean() {
return ImageContainer.this;
}

@Override
public String getName() {
return "cornerRadius";
}

@Override
public CssMetaData<? extends Styleable, Number> getCssMetaData() {
return StyleableProperties.CORNER_RADIUS;
}

@Override
protected void invalidated() {
updateCornerRadius(get());
}
};
}

return cornerRadius;
}

public double getCornerRadius() {
return cornerRadius == null ? DEFAULT_CORNER_RADIUS : cornerRadius.get();
}

public void setCornerRadius(double radius) {
cornerRadiusProperty().set(radius);
}

public ObjectProperty<Image> imageProperty() {
return imageView.imageProperty();
}

public Image getImage() {
return imageView.getImage();
}

public void setImage(Image image) {
imageView.setImage(image);
}

public BooleanProperty smoothProperty() {
return imageView.smoothProperty();
}

public boolean isSmooth() {
return imageView.isSmooth();
}

public void setSmooth(boolean smooth) {
imageView.setSmooth(smooth);
}

@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return StyleableProperties.STYLEABLES;
}

private static final class StyleableProperties {
private static final CssMetaData<ImageContainer, Number> CORNER_RADIUS =
new CssMetaData<>("-jfx-corner-radius", SizeConverter.getInstance(), DEFAULT_CORNER_RADIUS) {
@Override
public boolean isSettable(ImageContainer control) {
return control.cornerRadius == null || !control.cornerRadius.isBound();
}

@Override
public StyleableProperty<Number> getStyleableProperty(ImageContainer control) {
return control.cornerRadiusProperty();
}
};

private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;

static {
var styleables = new ArrayList<>(StackPane.getClassCssMetaData());
styleables.add(CORNER_RADIUS);
STYLEABLES = List.copyOf(styleables);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
Expand All @@ -40,17 +39,16 @@
@DefaultProperty("image")
public final class ImagePickerItem extends BorderPane {

private final ImageView imageView;
private final ImageContainer imageContainer;

private final StringProperty title = new SimpleStringProperty(this, "title");
private final ObjectProperty<EventHandler<ActionEvent>> onSelectButtonClicked = new SimpleObjectProperty<>(this, "onSelectButtonClicked");
private final ObjectProperty<EventHandler<ActionEvent>> onDeleteButtonClicked = new SimpleObjectProperty<>(this, "onDeleteButtonClicked");
private final ObjectProperty<Image> image = new SimpleObjectProperty<>(this, "image");

public ImagePickerItem() {
imageView = new ImageView();
imageView.setSmooth(false);
imageView.setPreserveRatio(true);
imageContainer = new ImageContainer(32);
imageContainer.setSmooth(false);

JFXButton selectButton = new JFXButton();
selectButton.setGraphic(SVG.EDIT.createIcon(20));
Expand All @@ -66,7 +64,7 @@ public ImagePickerItem() {
FXUtils.installFastTooltip(deleteButton, i18n("button.reset"));

HBox hBox = new HBox();
hBox.getChildren().setAll(imageView, selectButton, deleteButton);
hBox.getChildren().setAll(imageContainer, selectButton, deleteButton);
hBox.setAlignment(Pos.CENTER_RIGHT);
hBox.setSpacing(8);
setRight(hBox);
Expand All @@ -78,7 +76,7 @@ public ImagePickerItem() {
vBox.setAlignment(Pos.CENTER_LEFT);
setLeft(vBox);

imageView.imageProperty().bind(image);
imageContainer.imageProperty().bind(image);
}

public String getTitle() {
Expand Down Expand Up @@ -128,8 +126,4 @@ public ObjectProperty<Image> imageProperty() {
public void setImage(Image image) {
this.image.set(image);
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public method getImageView() has been removed from ImagePickerItem. This is a breaking change for any external code or plugins that may have been using this method. While this improves encapsulation by not exposing internal implementation details, consider adding a deprecation notice in a previous release for smooth migration. The image functionality can still be accessed via imageProperty(), getImage(), and setImage() methods.

Suggested change
}
}
/**
* Returns the underlying image view/control.
*
* @deprecated This method exposes internal implementation details and will be removed
* in a future release. Use {@link #imageProperty()}, {@link #getImage()},
* or {@link #setImage(Image)} instead.
*/
@Deprecated
public ImageContainer getImageView() {
return imageContainer;
}

Copilot uses AI. Check for mistakes.

public ImageView getImageView() {
return imageView;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SkinBase;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
Expand All @@ -50,10 +49,7 @@
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.MDListCell;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;

Expand Down Expand Up @@ -274,20 +270,20 @@ Image loadIcon() {
}
}

public void loadIcon(ImageView imageView, @Nullable WeakReference<ObjectProperty<DatapackInfoObject>> current) {
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<DatapackInfoObject>> current) {
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
CompletableFuture<Image> imageFuture;
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
Image image = imageFuture.getNow(null);
if (image != null) {
imageView.setImage(image);
imageContainer.setImage(image);
return;
}
} else {
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
this.iconCache = new SoftReference<>(imageFuture);
}
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
imageFuture.thenAcceptAsync(image -> {
if (current != null) {
ObjectProperty<DatapackInfoObject> infoObjectProperty = current.get();
Expand All @@ -296,15 +292,15 @@ public void loadIcon(ImageView imageView, @Nullable WeakReference<ObjectProperty
return;
}
}
imageView.setImage(image);
imageContainer.setImage(image);
}, Schedulers.javafx());

}
}

private final class DatapackInfoListCell extends MDListCell<DatapackInfoObject> {
final JFXCheckBox checkBox = new JFXCheckBox();
ImageView imageView = new ImageView();
ImageContainer imageContainer = new ImageContainer(32);
final TwoLineListItem content = new TwoLineListItem();
BooleanProperty booleanProperty;

Expand All @@ -320,13 +316,10 @@ private final class DatapackInfoListCell extends MDListCell<DatapackInfoObject>

checkBox.disableProperty().bind(isReadOnlyProperty);

imageView.setFitWidth(32);
imageView.setFitHeight(32);
imageView.setPreserveRatio(true);
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));
imageContainer.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"));

StackPane.setMargin(container, new Insets(8));
container.getChildren().setAll(checkBox, imageView, content);
container.getChildren().setAll(checkBox, imageContainer, content);
getContainer().getChildren().setAll(container);

getContainer().getParent().addEventHandler(MouseEvent.MOUSE_PRESSED, mouseEvent -> handleSelect(this, mouseEvent));
Expand All @@ -341,7 +334,7 @@ protected void updateControl(DatapackInfoObject dataItem, boolean empty) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty);
dataItem.loadIcon(imageView, new WeakReference<>(this.itemProperty()));
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
}
}

Expand Down
Loading