diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java index 439be301f2..705a24809b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java @@ -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), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java index 02973b83b6..1cb0ef9eb2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java @@ -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); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 76dc61ed4d..dc2cef9374 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -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 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 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); } }); diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index e4ce382f2c..39e81db0f9 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -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 { diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/game/ServerEndpointTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/game/ServerEndpointTest.java new file mode 100644 index 0000000000..0dc1a21d50 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/game/ServerEndpointTest.java @@ -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"))); + } +}