mirror of
https://github.com/HMCL-dev/HMCL.git
synced 2026-10-16 21:21:33 +08:00
@@ -24,17 +24,21 @@ import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.OverrunStyle;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/// Displays a title with optional tags and a subtitle.
|
||||
public class TwoLineListItem extends VBox {
|
||||
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
|
||||
|
||||
@@ -44,6 +48,7 @@ public class TwoLineListItem extends VBox {
|
||||
private final Label lblTitle;
|
||||
private Label lblSubtitle;
|
||||
|
||||
/// Creates an empty item whose children do not receive mouse events.
|
||||
public TwoLineListItem() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
setMouseTransparent(true);
|
||||
@@ -52,7 +57,19 @@ public class TwoLineListItem extends VBox {
|
||||
lblTitle.getStyleClass().add("title");
|
||||
lblTitle.setTextOverrun(OverrunStyle.ELLIPSIS);
|
||||
|
||||
this.firstLine = new HBox(lblTitle);
|
||||
this.firstLine = new HBox(lblTitle) {
|
||||
/// Includes the tags' natural width when requesting space from the parent.
|
||||
@Override
|
||||
protected double computePrefWidth(double height) {
|
||||
double width = super.computePrefWidth(height);
|
||||
for (Node child : getManagedChildren()) {
|
||||
if (child instanceof TagsBox tagsBox) {
|
||||
width += snapSizeX(tagsBox.computeNaturalPrefWidth(height));
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
};
|
||||
firstLine.getStyleClass().add("first-line");
|
||||
firstLine.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
@@ -173,19 +190,18 @@ public class TwoLineListItem extends VBox {
|
||||
return tagLabel;
|
||||
}
|
||||
|
||||
private ObservableList<Label> tags;
|
||||
/// Stores the mutable tag list once tag support has been initialized.
|
||||
private @Nullable ObservableList<Label> tags;
|
||||
|
||||
/// Returns the mutable list of tags displayed after the title.
|
||||
public ObservableList<Label> getTags() {
|
||||
if (tags == null) {
|
||||
tags = FXCollections.observableArrayList();
|
||||
|
||||
var tagsBox = new HBox(8);
|
||||
var tagsBox = new TagsBox();
|
||||
tagsBox.getStyleClass().add("tags");
|
||||
tagsBox.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
// grow lazily after title has been placed
|
||||
tagsBox.setPrefWidth(0);
|
||||
tagsBox.setMinWidth(0);
|
||||
HBox.setHgrow(tagsBox, Priority.SOMETIMES);
|
||||
|
||||
Bindings.bindContent(tagsBox.getChildren(), tags);
|
||||
@@ -202,6 +218,27 @@ public class TwoLineListItem extends VBox {
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// Reports its natural width separately from the width used to allocate space within the title row.
|
||||
@NotNullByDefault
|
||||
private static final class TagsBox extends HBox {
|
||||
|
||||
/// Creates a tag container with zero minimum and preferred allocation widths.
|
||||
private TagsBox() {
|
||||
super(8);
|
||||
// Start at zero during HBox allocation so tags do not compete with the title for space.
|
||||
setPrefWidth(0);
|
||||
setMinWidth(0);
|
||||
}
|
||||
|
||||
/// Returns the computed preferred width without applying the zero preferred-width override.
|
||||
///
|
||||
/// @param height the available height, or -1 if unspecified
|
||||
/// @return the natural preferred width of the tags and their container
|
||||
private double computeNaturalPrefWidth(double height) {
|
||||
return super.computePrefWidth(height);
|
||||
}
|
||||
}
|
||||
|
||||
public void addTag(String tag, PseudoClass pseudoClass) {
|
||||
getTags().add(createTag(tag, pseudoClass));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.application.Platform;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.condition.EnabledIf;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/// Verifies tag sizing in parents that allocate only the item's preferred width.
|
||||
@NotNullByDefault
|
||||
@EnabledIf("org.jackhuang.hmcl.JavaFXLauncher#isStarted")
|
||||
public final class TwoLineListItemTest {
|
||||
|
||||
/// Verifies tags remain visible as they are added, resized, removed, and added again.
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
public void testPreferredWidthIncludesTags(boolean wrapText) {
|
||||
onFxThread(() -> {
|
||||
TwoLineListItem item = new TwoLineListItem("Example Mod", "a.jar");
|
||||
item.getTitleLabel().setWrapText(wrapText);
|
||||
HBox parent = createParent(item);
|
||||
layout(parent, 800);
|
||||
double originalWidth = item.getWidth();
|
||||
|
||||
item.addTag("Fabric");
|
||||
layout(parent, 800);
|
||||
assertTagsVisible(item);
|
||||
assertTrue(item.getWidth() > originalWidth);
|
||||
|
||||
item.addTag("NeoForge");
|
||||
item.addTag("Quilt");
|
||||
layout(parent, 800);
|
||||
assertTagsVisible(item);
|
||||
double taggedWidth = item.getWidth();
|
||||
|
||||
item.getTags().get(0).setText("A longer loader tag");
|
||||
layout(parent, 800);
|
||||
assertTagsVisible(item);
|
||||
assertTrue(item.getWidth() > taggedWidth);
|
||||
|
||||
item.getTags().clear();
|
||||
layout(parent, 800);
|
||||
assertEquals(originalWidth, item.getWidth(), 0.001);
|
||||
|
||||
item.addTag("Fabric");
|
||||
item.setSubtitle("A subtitle that is wider than the title and its loader tag");
|
||||
layout(parent, 800);
|
||||
assertTagsVisible(item);
|
||||
assertTrue(item.getSubtitleLabel().getWidth() >= item.getSubtitleLabel().prefWidth(-1));
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies constrained rows give the title priority while allowing truncation or wrapping.
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
public void testTitleRetainsPriority(boolean wrapText) {
|
||||
onFxThread(() -> {
|
||||
TwoLineListItem item = new TwoLineListItem("A very long mod title ".repeat(20), "a.jar");
|
||||
item.getTitleLabel().setWrapText(wrapText);
|
||||
item.addTag("Fabric");
|
||||
item.addTag("NeoForge");
|
||||
HBox parent = createParent(item);
|
||||
layout(parent, 300);
|
||||
|
||||
assertEquals(252, item.getWidth(), 0.001);
|
||||
assertEquals(item.getWidth(), item.getTitleLabel().getWidth(), 0.001);
|
||||
assertTrue(item.getBoundsInParent().getMaxX() <= parent.getWidth());
|
||||
if (wrapText) {
|
||||
assertTrue(item.getTitleLabel().getHeight() > item.getTitleLabel().prefHeight(-1));
|
||||
}
|
||||
|
||||
item.setTitle("Mod");
|
||||
layout(parent, 300);
|
||||
assertTagsVisible(item);
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a scene with an icon and an item without a horizontal growth constraint.
|
||||
private static HBox createParent(TwoLineListItem item) {
|
||||
Region icon = new Region();
|
||||
icon.setMinSize(40, 40);
|
||||
icon.setPrefSize(40, 40);
|
||||
icon.setMaxSize(40, 40);
|
||||
HBox parent = new HBox(8, icon, item);
|
||||
new Scene(parent);
|
||||
return parent;
|
||||
}
|
||||
|
||||
/// Applies CSS and lays out the parent at the given width and its preferred height.
|
||||
private static void layout(HBox parent, double width) {
|
||||
parent.applyCss();
|
||||
parent.resize(width, parent.prefHeight(width));
|
||||
parent.layout();
|
||||
}
|
||||
|
||||
/// Verifies every tag fits within the region that clips its contents.
|
||||
private static void assertTagsVisible(TwoLineListItem item) {
|
||||
for (Label tag : item.getTags()) {
|
||||
Region container = (Region) tag.getParent();
|
||||
assertTrue(tag.getBoundsInParent().getMaxX() <= container.getWidth(), "Tag is clipped");
|
||||
assertTrue(tag.getWidth() >= tag.prefWidth(-1), "Tag text is truncated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs assertions on the JavaFX thread and waits up to 30 seconds for completion.
|
||||
private static void onFxThread(Runnable action) {
|
||||
FutureTask<Boolean> task = new FutureTask<>(action, true);
|
||||
Platform.runLater(task);
|
||||
try {
|
||||
task.get(30, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(e);
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user