feat: route switcher on home card, subtler transitions, flat press feedback; add ServerEndpoint tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
layue13andClaude Sonnet 5.5 committed 2026-11-08 14:27:04 +08:00
1 parent 06ef27f0e0
commit 0269e54b95
5 files changed
+85 -16

No files matched your search

@@ -121,7 +121,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// Animates forward navigation with temporary incoming-page caching.
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
@@ -152,7 +152,7 @@ public enum ContainerAnimations implements TransitionPane.AnimationProducer {
/// Animates backward navigation with temporary incoming-page caching.
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode) {
double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50;
double offset = 12.0;
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.cacheProperty(), isCacheAllowed(nextNode), Interpolator.DISCRETE),
@@ -23,10 +23,10 @@ import org.jetbrains.annotations.Nullable;
public class TransitionPane extends StackPane {
/// The default easing curve for page transitions.
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE;
public static final Interpolator DEFAULT_INTERPOLATOR = Motion.EASE_OUT_CUBIC;
/// The default duration for page transitions.
public static final Duration DEFAULT_DURATION = Motion.SHORT4;
public static final Duration DEFAULT_DURATION = Duration.millis(320);
/// Half the default duration for transitions with two phases.
public static final Duration DEFAULT_DURATION_HALF = DEFAULT_DURATION.divide(2);
@@ -12,6 +12,7 @@ import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
@@ -31,6 +32,8 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
import org.jackhuang.hmcl.ui.instances.Instances;
@@ -40,6 +43,8 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -148,6 +153,27 @@ public final class MainPage extends StackPane implements DecoratorPage {
routeFact.setVisible(route != null);
routeFact.setManaged(route != null);
routeValue.setText(route != null ? route.name() : "");
// With more than one route (for example direct and CDN), the route can be switched.
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
boolean switchable = routes.size() > 1;
routeFact.setCursor(switchable ? Cursor.HAND : Cursor.DEFAULT);
if (switchable && route != null) {
routeValue.setText(route.name() + " ▾");
}
routeFact.setOnMouseClicked(switchable ? event -> {
PopupMenu list = new PopupMenu();
JFXPopup popup = new JFXPopup(list);
List<Node> items = new ArrayList<>();
for (ServerEndpoint.Route candidate : routes) {
items.add(new IconedMenuItem(null, candidate.name(), () -> {
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
routeValue.setText(candidate.name() + " ▾");
}, popup));
}
list.getContent().setAll(items);
popup.show(routeFact, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, 0, -routeFact.getHeight());
} : null);
}
});
+12 -12
View File
@@ -460,7 +460,7 @@
}
.launch-pane > .jfx-button > StackPane > .jfx-rippler {
-jfx-rippler-fill: -monet-on-primary-container;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
-fx-padding: 0;
}
@@ -494,11 +494,11 @@
/* TODO: It seems not actually used */
.tab-rippler {
-jfx-rippler-fill: derive(-monet-primary-container, 30%);
-jfx-rippler-fill: transparent;
}
.jfx-tab-pane .jfx-rippler {
-jfx-rippler-fill: white;
-jfx-rippler-fill: transparent;
}
.tab-label {
@@ -746,7 +746,7 @@
}
.jfx-tool-bar-second .jfx-rippler {
-jfx-rippler-fill: -monet-on-surface;
-jfx-rippler-fill: transparent;
}
.jfx-tool-bar-button {
@@ -864,7 +864,7 @@
}
.jfx-button .jfx-rippler {
-jfx-rippler-fill: -monet-on-surface;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
-fx-padding: 0.0;
}
@@ -874,7 +874,7 @@
}
.jfx-button-raised .jfx-rippler {
-jfx-rippler-fill: -monet-on-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
-fx-padding: 0.0;
}
@@ -997,7 +997,7 @@
}
.jfx-list-cell .jfx-rippler {
-jfx-rippler-fill: -monet-primary-container;
-jfx-rippler-fill: transparent;
}
/*.list-cell:odd:selected > .jfx-rippler > StackPane,*/
@@ -1350,7 +1350,7 @@
}
.toggle-icon4 .jfx-rippler {
-jfx-rippler-fill: -monet-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
}
@@ -1379,7 +1379,7 @@
}
.toggle-icon-tiny .jfx-rippler {
-jfx-rippler-fill: -monet-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
}
@@ -1396,7 +1396,7 @@
}
.announcement-close-button .jfx-rippler {
-jfx-rippler-fill: -monet-primary;
-jfx-rippler-fill: transparent;
-jfx-mask-type: CIRCLE;
}
@@ -1607,7 +1607,7 @@
}
/*.combo-box-popup .list-view .jfx-list-cell .jfx-rippler {*/
/* -jfx-rippler-fill: -monet-primary-container;*/
/* -jfx-rippler-fill: transparent;*/
/*}*/
/*******************************************************************************
@@ -1654,7 +1654,7 @@
}
.custom-color-dialog .tab .jfx-rippler {
-jfx-rippler-fill: -monet-on-surface;
-jfx-rippler-fill: transparent;
}
.custom-color-dialog .tab-selected-line {
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/// Tests how [ServerEndpoint] resolves routes without an installed modpack manifest.
public final class ServerEndpointTest {
@AfterEach
void clearProperty() {
System.clearProperty("hmcl.server.address");
}
@Test
void noConfigurationMeansNoRoute(@TempDir Path dir) {
assertTrue(ServerEndpoint.routes(dir.resolve("modpack.json")).isEmpty());
assertNull(ServerEndpoint.selected(dir.resolve("modpack.json")));
}
@Test
void fallsBackToDefaultAddress(@TempDir Path dir) {
System.setProperty("hmcl.server.address", " play.example.net:25565 ");
var routes = ServerEndpoint.routes(dir.resolve("modpack.json"));
assertEquals(1, routes.size());
assertEquals("play.example.net:25565", routes.get(0).address());
assertEquals(routes.get(0), ServerEndpoint.selected(dir.resolve("modpack.json")));
}
@Test
void selectionIsRememberedNextToTheConfiguration(@TempDir Path dir) throws Exception {
System.setProperty("hmcl.server.address", "wss://cdn.example.net/mc");
Path file = dir.resolve("modpack.json");
ServerEndpoint.select(file, new ServerEndpoint.Route("cdn", "wss://cdn.example.net/mc"));
assertEquals("cdn", Files.readString(dir.resolve("server-route.txt")));
}
}