重构 Decorator (#6557)

This commit is contained in:
Glavo authored and GitHub committed 2026-08-02 00:10:40 +08:00
1 parent 612998a98f
commit 5a14449b44
12 files changed
+1489 -1247

No files matched your search

@@ -24,18 +24,11 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Label;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
@@ -47,12 +40,11 @@ import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.account.AccountListPage;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.DecoratorController;
import org.jackhuang.hmcl.ui.decorator.Decorator;
import org.jackhuang.hmcl.ui.download.DownloadPage;
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
import org.jackhuang.hmcl.ui.main.RootPage;
@@ -74,6 +66,7 @@ import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
@@ -89,26 +82,11 @@ public final class Controllers {
public static final String SOFTWARE_RENDERING = "softwareRendering";
public static final String APRIL_FOOLS = "aprilFools";
private static final int CUSTOM_DECORATION_SHADOW_SIZE = 8;
private static final int CUSTOM_DECORATION_SHADOW_EXTENT = CUSTOM_DECORATION_SHADOW_SIZE * 2;
public static final int MIN_CONTENT_WIDTH = 800 + 2; // bg width + border width*2
public static final int MIN_CONTENT_HEIGHT = 450 + 2 + 40; // bg height + border width*2 + toolbar height
public static final int MIN_WIDTH = MIN_CONTENT_WIDTH + CUSTOM_DECORATION_SHADOW_EXTENT;
public static final int MIN_HEIGHT = MIN_CONTENT_HEIGHT + CUSTOM_DECORATION_SHADOW_EXTENT;
public static final Rectangle2D PRIMARY_SCREEN_BOUNDS = Screen.getPrimary().getBounds();
private static InvalidationListener stageSizeChangeListener;
private static final DoubleProperty contentX = new SimpleDoubleProperty();
private static final DoubleProperty contentY = new SimpleDoubleProperty();
private static final DoubleProperty contentWidth = new SimpleDoubleProperty();
private static final DoubleProperty contentHeight = new SimpleDoubleProperty();
private static Scene scene;
private static Stage stage;
private static GameInstancePage gameInstancePage;
private static Lazy<GameListPage> gameListPage = new Lazy<>(GameListPage::new);
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
private static DecoratorController decorator;
/// The coordinator for the main window's scene graph and navigation stack.
private static @Nullable Decorator decorator;
private static DownloadPage downloadPage;
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
AccountListPage accountListPage = new AccountListPage();
@@ -132,16 +110,12 @@ public final class Controllers {
void run() throws Exception;
}
/// Returns the stage currently attached to the main-window decorator.
///
/// @return the current stage, or `null` before initialization or after detachment
public static @Nullable Stage getStage() {
return stage;
}
public static ReadOnlyDoubleProperty windowWidthProperty() {
return contentWidth;
}
public static ReadOnlyDoubleProperty windowHeightProperty() {
return contentHeight;
@Nullable Decorator currentDecorator = decorator;
return currentDecorator == null ? null : currentDecorator.getStage();
}
@FXThread
@@ -212,15 +186,24 @@ public final class Controllers {
return terracottaPage.get();
}
/// Returns the initialized main-window decorator.
///
/// @return the application-wide main-window decorator
@FXThread
public static DecoratorController getDecorator() {
return decorator;
public static Decorator getDecorator() {
return Objects.requireNonNull(decorator, "Main window is not initialized");
}
/// Releases stage-specific listeners and retained window ownership before application shutdown.
public static void onApplicationStop() {
stageSizeChangeListener = null;
if (decorator != null) {
decorator.detachStage();
}
}
/// Initializes the main application stage, scene graph, and background services.
///
/// @param stage the primary application stage, which must not have been shown
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
@@ -238,99 +221,10 @@ public final class Controllers {
}
}
Controllers.stage = stage;
double initContentWidth = Math.max(MIN_CONTENT_WIDTH, state().getWidth());
double initContentHeight = Math.max(MIN_CONTENT_HEIGHT, state().getHeight());
double initWidth = initContentWidth + CUSTOM_DECORATION_SHADOW_EXTENT;
double initHeight = initContentHeight + CUSTOM_DECORATION_SHADOW_EXTENT;
{
double initContentX = state().getX() * PRIMARY_SCREEN_BOUNDS.getWidth();
double initContentY = state().getY() * PRIMARY_SCREEN_BOUNDS.getHeight();
boolean invalid = true;
double border = 20D;
for (Screen screen : Screen.getScreens()) {
Rectangle2D bound = screen.getBounds();
if (bound.getMinX() + border <= initContentX + initContentWidth
&& initContentX <= bound.getMaxX() - border
&& bound.getMinY() + border <= initContentY
&& initContentY <= bound.getMaxY() - border) {
invalid = false;
break;
}
}
if (invalid) {
initContentX = (0.5D - initContentWidth / PRIMARY_SCREEN_BOUNDS.getWidth() / 2)
* PRIMARY_SCREEN_BOUNDS.getWidth();
initContentY = (0.5D - initContentHeight / PRIMARY_SCREEN_BOUNDS.getHeight() / 2)
* PRIMARY_SCREEN_BOUNDS.getHeight();
}
double initX = initContentX - CUSTOM_DECORATION_SHADOW_SIZE;
double initY = initContentY - CUSTOM_DECORATION_SHADOW_SIZE;
stage.setX(initX);
stage.setY(initY);
contentX.set(initContentX);
contentY.set(initContentY);
}
stage.setHeight(initHeight);
stage.setWidth(initWidth);
contentHeight.set(initContentHeight);
contentWidth.set(initContentWidth);
stageSizeChangeListener = o -> {
ReadOnlyDoubleProperty property = (ReadOnlyDoubleProperty) o;
Stage currentStage = property.getBean() instanceof Stage s ? s : null;
if (currentStage == null)
return;
boolean saveState = !currentStage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|| !currentStage.isFullScreen() && !currentStage.isMaximized());
switch (property.getName()) {
case "x" -> {
double value = property.get() + CUSTOM_DECORATION_SHADOW_SIZE;
contentX.set(value);
if (saveState)
state().setX(value / PRIMARY_SCREEN_BOUNDS.getWidth());
}
case "y" -> {
double value = property.get() + CUSTOM_DECORATION_SHADOW_SIZE;
contentY.set(value);
if (saveState)
state().setY(value / PRIMARY_SCREEN_BOUNDS.getHeight());
}
case "width" -> {
double value = Math.max(MIN_CONTENT_WIDTH, property.get() - CUSTOM_DECORATION_SHADOW_EXTENT);
contentWidth.set(value);
if (saveState)
state().setWidth(value);
}
case "height" -> {
double value = Math.max(MIN_CONTENT_HEIGHT, property.get() - CUSTOM_DECORATION_SHADOW_EXTENT);
contentHeight.set(value);
if (saveState)
state().setHeight(value);
}
}
};
WeakInvalidationListener weakListener = new WeakInvalidationListener(stageSizeChangeListener);
stage.xProperty().addListener(weakListener);
stage.yProperty().addListener(weakListener);
stage.heightProperty().addListener(weakListener);
stage.widthProperty().addListener(weakListener);
stage.setOnCloseRequest(e -> Launcher.stopApplication());
decorator = new DecoratorController(stage, getRootPage());
decorator = new Decorator(getRootPage());
Scene mainScene = decorator.attachStage(stage);
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
getRootPage().getMainPage().showUpdateDialogProperty().bind(
decorator.backableProperty().not()
@@ -346,36 +240,10 @@ public final class Controllers {
Lang.thread(JavaManager::initialize, "Search Java", true);
scene = new Scene(decorator.getDecorator());
scene.setFill(Color.TRANSPARENT);
stage.setMinWidth(MIN_WIDTH);
stage.setMinHeight(MIN_HEIGHT);
decorator.getDecorator().prefWidthProperty().bind(scene.widthProperty());
decorator.getDecorator().prefHeightProperty().bind(scene.heightProperty());
StyleSheets.init(scene);
StyleSheets.init(mainScene);
FXUtils.setIcon(stage);
stage.setTitle(Metadata.FULL_TITLE);
stage.initStyle(StageStyle.TRANSPARENT);
stage.setScene(scene);
if (AnimationUtils.playWindowAnimation()) {
Timeline timeline = new Timeline(
new KeyFrame(Duration.millis(0),
new KeyValue(decorator.getDecorator().opacityProperty(), 0, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleXProperty(), 0.8, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleYProperty(), 0.8, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleZProperty(), 0.8, Motion.EASE)
),
new KeyFrame(Duration.millis(600),
new KeyValue(decorator.getDecorator().opacityProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleXProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleYProperty(), 1, Motion.EASE),
new KeyValue(decorator.getDecorator().scaleZProperty(), 1, Motion.EASE)
)
);
timeline.play();
}
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
Runnable continueAction = () -> {
@@ -644,20 +512,36 @@ public final class Controllers {
decorator.showToast(content);
}
/// Shows `directoryChooser` with the current main window as its owner.
///
/// @param directoryChooser the chooser to show
/// @return the selected directory, or `null` if the chooser is cancelled
public static @Nullable Path showDialog(DirectoryChooser directoryChooser) {
return FileUtils.toPath(directoryChooser.showDialog(stage));
return FileUtils.toPath(directoryChooser.showDialog(getStage()));
}
/// Shows `fileChooser` for opening one file with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected file, or `null` if the chooser is cancelled
public static @Nullable Path showOpenDialog(FileChooser fileChooser) {
return FileUtils.toPath(fileChooser.showOpenDialog(stage));
return FileUtils.toPath(fileChooser.showOpenDialog(getStage()));
}
/// Shows `fileChooser` for saving one file with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected file, or `null` if the chooser is cancelled
public static @Nullable Path showSaveDialog(FileChooser fileChooser) {
return FileUtils.toPath(fileChooser.showSaveDialog(stage));
return FileUtils.toPath(fileChooser.showSaveDialog(getStage()));
}
/// Shows `fileChooser` for opening multiple files with the current main window as its owner.
///
/// @param fileChooser the chooser to show
/// @return the selected files, or `null` if the chooser is cancelled
public static @Nullable List<Path> showOpenMultipleDialog(FileChooser fileChooser) {
return FileUtils.toPaths(fileChooser.showOpenMultipleDialog(stage));
return FileUtils.toPaths(fileChooser.showOpenMultipleDialog(getStage()));
}
public static void onHyperlinkAction(String href) {
@@ -681,7 +565,9 @@ public final class Controllers {
return decorator == null;
}
/// Releases controller-owned pages, window state, and JavaFX helper resources.
public static void shutdown() {
onApplicationStop();
rootPage = null;
gameInstancePage = null;
gameListPage = null;
@@ -690,9 +576,6 @@ public final class Controllers {
settingsPage = null;
terracottaPage = null;
decorator = null;
stage = null;
scene = null;
onApplicationStop();
FXUtils.shutdown();
}
@@ -49,17 +49,31 @@ public final class DialogUtils {
public static final String PROPERTY_DIALOG_SHOW_LATER = DialogUtils.class.getName() + ".dialog.showLater";
/// Shows content in the main-window dialog stack owned by `decorator`.
///
/// @param decorator the main-window decorator
/// @param content the dialog content
public static void show(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> show(decorator, content));
StackPane dialogContainer = decorator.getDialogContainer();
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showInDecorator(decorator, dialogContainer, content));
return;
}
show(decorator.getDrawerWrapper(), content, (dialog) -> {
showInDecorator(decorator, dialogContainer, content);
}
/// Shows content in a decorator's resolved dialog container.
///
/// @param decorator the main-window decorator
/// @param dialogContainer the container resolved before any deferred execution
/// @param content the dialog content
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
show(dialogContainer, content, dialog -> {
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
decorator.capableDraggingWindow(dialog);
decorator.forbidDraggingWindow(pane);
dialog.setDialogContainer(decorator.getDrawerWrapper());
dialog.setDialogContainer(dialogContainer);
});
}
@@ -125,12 +139,18 @@ public final class DialogUtils {
}
}
/// Queues content in the main-window dialog stack owned by `decorator`.
///
/// @param decorator the main-window decorator
/// @param content the dialog content
public static void showLater(Decorator decorator, Node content) {
if (decorator.getDrawerWrapper() == null) {
Platform.runLater(() -> showLater(decorator, content));
StackPane dialogContainer = decorator.getDialogContainer();
Runnable showDialogAction = () -> showInDecorator(decorator, dialogContainer, content);
if (decorator.getRoot().getScene() == null) {
Platform.runLater(() -> showLater(dialogContainer, showDialogAction));
return;
}
showLater(decorator.getDrawerWrapper(), () -> show(decorator, content));
showLater(dialogContainer, showDialogAction);
}
@SuppressWarnings("unchecked")
@@ -44,8 +44,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class UpgradeDialog extends JFXDialogLayout {
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
maxWidthProperty().bind(Controllers.windowWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
setHeading(new Label(i18n("update.changelog")));
setBody(new JFXSpinner());
File diff suppressed because it is too large. Load diff
@@ -1,253 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <[email protected]> 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.decorator;
import com.jfoenix.controls.JFXSnackbar;
import com.jfoenix.controls.JFXSnackbarLayout;
import javafx.animation.Interpolator;
import javafx.beans.property.BooleanProperty;
import javafx.scene.Node;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.DialogUtils;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane.AnimationProducer;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
public class DecoratorController {
private final Decorator decorator;
private final Navigator navigator;
public DecoratorController(Stage stage, Node mainPage) {
decorator = new Decorator(stage);
decorator.titleTransparentProperty().bind(Themes.titleBarTransparentProperty());
navigator = new Navigator();
navigator.setOnNavigated(this::onNavigated);
navigator.init(mainPage);
decorator.getContent().setAll(navigator);
decorator.onCloseNavButtonActionProperty().set(e -> close());
decorator.onBackNavButtonActionProperty().set(e -> back());
decorator.onRefreshNavButtonActionProperty().set(e -> refresh());
setupAuthlibInjectorDnD();
decorator.contentBackgroundProperty().bind(Themes.backgroundProperty());
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
if (!(e.getTarget() instanceof Node t)) {
return;
}
Node newTarget;
JFXDialogPane currentDialogPane = null;
if (decorator.getDrawerWrapper() != null) {
currentDialogPane = (JFXDialogPane) decorator.getDrawerWrapper().getProperties().get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
}
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
newTarget = currentDialogPane.peek().get();
} else {
newTarget = navigator.getCurrentPage();
}
boolean needsRedirect = true;
while (t != null) {
if (t == newTarget) {
// current event target is in newTarget
needsRedirect = false;
break;
}
t = t.getParent();
}
if (!needsRedirect) {
return;
}
e.consume();
newTarget.fireEvent(e.copyFor(e.getSource(), newTarget));
});
// press ESC to go back
onEscPressed(navigator, this::back);
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
e.consume();
}
});
}
navigator.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.BACK) {
back();
e.consume();
}
});
}
public Decorator getDecorator() {
return decorator;
}
// ==== Navigation ====
public void navigate(Node node, AnimationProducer animationProducer, Duration duration, Interpolator interpolator) {
navigator.navigate(node, animationProducer, duration, interpolator);
}
public BooleanProperty backableProperty() {
return navigator.backableProperty();
}
private void close() {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
if (page.isPageCloseable()) {
page.closePage();
return;
}
}
navigator.clear();
}
private void back() {
if (navigator.getCurrentPage() instanceof DecoratorPage) {
DecoratorPage page = (DecoratorPage) navigator.getCurrentPage();
if (page.back()) {
if (navigator.canGoBack()) {
navigator.close();
}
}
} else {
if (navigator.canGoBack()) {
navigator.close();
}
}
}
private void refresh() {
if (navigator.getCurrentPage() instanceof Refreshable) {
Refreshable refreshable = (Refreshable) navigator.getCurrentPage();
if (refreshable.refreshableProperty().get())
refreshable.refresh();
}
}
private void onNavigated(Navigator.NavigationEvent event) {
if (event.getSource() != this.navigator) return;
Node to = event.getNode();
if (to instanceof Refreshable) {
decorator.canRefreshProperty().bind(((Refreshable) to).refreshableProperty());
} else {
decorator.canRefreshProperty().unbind();
decorator.canRefreshProperty().set(false);
}
decorator.canCloseProperty().set(navigator.size() > 2);
if (to instanceof DecoratorPage) {
decorator.showCloseAsHomeProperty().set(!((DecoratorPage) to).isPageCloseable());
} else {
decorator.showCloseAsHomeProperty().set(true);
}
decorator.setNavigationDirection(event.getDirection());
// state property should be updated at last.
if (to instanceof DecoratorPage) {
decorator.stateProperty().bind(((DecoratorPage) to).stateProperty());
} else {
decorator.stateProperty().unbind();
decorator.stateProperty().set(new DecoratorPage.State("", null, navigator.canGoBack(), false, true));
}
if (to instanceof Region) {
Region region = (Region) to;
// Let root pane fix window size.
StackPane parent = (StackPane) region.getParent();
region.prefWidthProperty().bind(parent.widthProperty());
region.prefHeightProperty().bind(parent.heightProperty());
}
}
// ==== Dialog ====
public void showDialog(Node node) {
DialogUtils.show(decorator, node);
}
private void closeDialog(Node node) {
DialogUtils.close(node);
}
public void showDialogLater(Node node) {
DialogUtils.showLater(decorator, node);
}
// ==== Toast ====
public void showToast(String content) {
decorator.getSnackbar().fireEvent(new JFXSnackbar.SnackbarEvent(new JFXSnackbarLayout(content)));
}
// ==== Wizard ====
public void startWizard(WizardProvider wizardProvider) {
startWizard(wizardProvider, null);
}
public void startWizard(WizardProvider wizardProvider, String category) {
FXUtils.checkFxUserThread();
navigator.navigate(new DecoratorWizardDisplayer(wizardProvider, category),
ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE);
}
// ==== Authlib Injector DnD ====
private void setupAuthlibInjectorDnD() {
decorator.addEventFilter(DragEvent.DRAG_OVER, AuthlibInjectorDnD.dragOverHandler());
decorator.addEventFilter(DragEvent.DRAG_DROPPED, AuthlibInjectorDnD.dragDroppedHandler(
url -> Controllers.dialog(new AddAuthlibInjectorServerPane(url))));
}
}
@@ -1,602 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <[email protected]> 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.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
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.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
public class DecoratorSkin extends SkinBase<Decorator> {
private final StackPane root, parent;
private final StackPane titleContainer;
private final Stage primaryStage;
private final TransitionPane navBarPane;
@SuppressWarnings("FieldCanBeLocal")
private final InvalidationListener onWindowsStatusChange;
private final EventHandler<MouseEvent> onTitleBarDoubleClick;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
public DecoratorSkin(Decorator control) {
super(control);
primaryStage = control.getPrimaryStage();
Decorator skinnable = getSkinnable();
root = new StackPane();
root.getStyleClass().add("window");
StackPane shadowContainer = new StackPane();
shadowContainer.getStyleClass().add("body");
shadowContainer.setEffect(new DropShadow(BlurType.ONE_PASS_BOX, Color.rgb(0, 0, 0, 0.4), 10, 0.3, 0.0, 0.0));
parent = new StackPane();
Rectangle clip = new Rectangle();
clip.widthProperty().bind(parent.widthProperty());
clip.heightProperty().bind(parent.heightProperty());
clip.setArcWidth(8);
clip.setArcHeight(8);
parent.setClip(clip);
skinnable.getSnackbar().registerSnackbarContainer(parent);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
onTitleBarDoubleClick = event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
} else {
onWindowsStatusChange = null;
onTitleBarDoubleClick = null;
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
StackPane wrapper = new StackPane();
wrapper.backgroundProperty().bind(Bindings.createObjectBinding(
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
Themes.colorSchemeProperty()));
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
backgroundNode.backgroundProperty().bind(Bindings.createObjectBinding(
() -> skinnable.getContentBackground() == null
? null
: skinnable.getContentBackground().background(),
skinnable.contentBackgroundProperty()));
backgroundNode.opacityProperty().bind(Bindings.createDoubleBinding(
() -> skinnable.getContentBackground() == null
? 1.0
: skinnable.getContentBackground().opacity(),
skinnable.contentBackgroundProperty()));
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
BorderPane frame = new BorderPane();
frame.getStyleClass().addAll("jfx-decorator");
wrapper.getChildren().setAll(backgroundNode, frame);
skinnable.setDrawerWrapper(wrapper);
parent.getChildren().add(wrapper);
// center node with an animation layer at bottom, a container layer at middle and a "welcome" layer at top.
StackPane container = new StackPane();
FXUtils.setOverflowHidden(container);
// content layer at middle
{
StackPane contentPlaceHolder = new StackPane();
contentPlaceHolder.getStyleClass().add("jfx-decorator-content-container");
Bindings.bindContent(contentPlaceHolder.getChildren(), skinnable.contentProperty());
container.getChildren().add(contentPlaceHolder);
}
// welcome and hint layer at top
{
StackPane floatLayer = new StackPane();
Bindings.bindContent(floatLayer.getChildren(), skinnable.containerProperty());
ListChangeListener<Node> listener = c -> {
if (skinnable.getContainer().isEmpty()) {
floatLayer.setMouseTransparent(true);
floatLayer.setVisible(false);
} else {
floatLayer.setMouseTransparent(false);
floatLayer.setVisible(true);
}
};
skinnable.containerProperty().addListener(listener);
listener.onChanged(null);
container.getChildren().add(floatLayer);
}
frame.setCenter(container);
titleContainer = new StackPane();
titleContainer.setPickOnBounds(false);
titleContainer.getStyleClass().addAll("jfx-tool-bar");
backgroundNode.maxHeightProperty().bind(Bindings.createDoubleBinding(
() -> Math.max(0.0, wrapper.getHeight()
- (skinnable.isTitleTransparent() ? 0.0 : titleContainer.getHeight())),
wrapper.heightProperty(),
skinnable.titleTransparentProperty(),
titleContainer.heightProperty()));
// Maybe, we can automatically identify whether the top part of the picture is light-coloured or dark when the title is transparent,
// and decide whether the whole top bar should be rendered in white or black. TODO
FXUtils.onChangeAndOperate(skinnable.titleTransparentProperty(), titleTransparent -> {
if (titleTransparent) {
titleContainer.getStyleClass().remove("background");
titleContainer.getStyleClass().add("gray-background");
} else {
titleContainer.getStyleClass().add("background");
titleContainer.getStyleClass().remove("gray-background");
}
});
control.capableDraggingWindow(titleContainer);
BorderPane titleBar = new BorderPane();
titleContainer.getChildren().add(titleBar);
Rectangle buttonsContainerPlaceHolder = new Rectangle();
{
navBarPane = new TransitionPane();
navBarPane.setId("decoratorTitleTransitionPane");
FXUtils.onChangeAndOperate(skinnable.stateProperty(), s -> {
if (s == null) return;
Node node = createNavBar(skinnable, s.leftPaneWidth(), s.backable(), skinnable.canCloseProperty().get(), skinnable.showCloseAsHomeProperty().get(), s.refreshable(), s.title(), s.titleNode());
if (s.animate()) {
TransitionPane.AnimationProducer animation = switch (skinnable.getNavigationDirection()) {
case NEXT -> NavBarAnimations.NEXT;
case PREVIOUS -> NavBarAnimations.PREVIOUS;
default -> ContainerAnimations.FADE;
};
skinnable.setNavigationDirection(Navigation.NavigationDirection.START);
navBarPane.setContent(node, animation, Motion.SHORT4);
} else {
navBarPane.getChildren().setAll(node);
}
});
titleBar.setCenter(navBarPane);
titleBar.setRight(buttonsContainerPlaceHolder);
}
frame.setTop(titleContainer);
{
HBox buttonsContainer = new HBox();
buttonsContainer.setAlignment(Pos.TOP_RIGHT);
buttonsContainer.setMaxHeight(40);
{
JFXButton btnHelp = new JFXButton();
btnHelp.setFocusTraversable(false);
btnHelp.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
btnHelp.getStyleClass().add("jfx-decorator-button");
btnHelp.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL));
JFXButton btnMin = new JFXButton();
btnMin.setFocusTraversable(false);
btnMin.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
btnMin.getStyleClass().add("jfx-decorator-button");
btnMin.setOnAction(e -> skinnable.minimize());
JFXButton btnClose = new JFXButton();
btnClose.setFocusTraversable(false);
btnClose.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
btnClose.getStyleClass().add("jfx-decorator-button");
btnClose.setOnAction(e -> skinnable.close());
buttonsContainer.getChildren().setAll(btnHelp, btnMin, btnClose);
}
AnchorPane layer = new AnchorPane();
layer.setPickOnBounds(false);
layer.getChildren().add(buttonsContainer);
AnchorPane.setTopAnchor(buttonsContainer, 0.0);
AnchorPane.setRightAnchor(buttonsContainer, 0.0);
buttonsContainerPlaceHolder.widthProperty().bind(buttonsContainer.widthProperty());
parent.getChildren().add(layer);
}
getChildren().add(root);
}
private Node createNavBar(Decorator skinnable, double leftPaneWidth, boolean canBack, boolean canClose, boolean showCloseAsHome, boolean canRefresh, String title, Node titleNode) {
BorderPane navBar = new BorderPane();
navBar.getStyleClass().add("navigation-bar");
{
HBox navLeft = new HBox();
navLeft.setAlignment(Pos.CENTER_LEFT);
navLeft.setPadding(new Insets(0, 5, 0, 5));
if (canBack) {
JFXButton backNavButton = new JFXButton();
skinnable.forbidDraggingWindow(backNavButton);
backNavButton.setFocusTraversable(false);
backNavButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backNavButton.getStyleClass().add("jfx-decorator-button");
backNavButton.onActionProperty().bind(skinnable.onBackNavButtonActionProperty());
backNavButton.visibleProperty().set(canBack);
navLeft.getChildren().add(backNavButton);
}
if (canClose) {
JFXButton closeNavButton = new JFXButton();
skinnable.forbidDraggingWindow(closeNavButton);
closeNavButton.setFocusTraversable(false);
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeNavButton.getStyleClass().add("jfx-decorator-button");
closeNavButton.onActionProperty().bind(skinnable.onCloseNavButtonActionProperty());
if (showCloseAsHome)
closeNavButton.setGraphic(SVG.HOME.createIcon(Themes.titleFillProperty()));
else
closeNavButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
navLeft.getChildren().add(closeNavButton);
}
if (canBack || canClose) {
navBar.setLeft(navLeft);
}
BorderPane center = new BorderPane();
if (title != null) {
Label titleLabel = new Label();
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
titleLabel.getStyleClass().add("jfx-decorator-title");
if (titleNode == null) {
titleLabel.maxWidthProperty().bind(Bindings.createDoubleBinding(
() -> skinnable.getWidth() - 150 - navLeft.getWidth(),
skinnable.widthProperty(), navLeft.widthProperty()));
} else {
titleLabel.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
// 8 (margin-left)
return leftPaneWidth - 8 - navLeft.getWidth();
}, navLeft.widthProperty()));
}
titleLabel.setText(title);
center.setLeft(titleLabel);
BorderPane.setAlignment(titleLabel, Pos.CENTER_LEFT);
}
if (titleNode != null) {
center.setCenter(titleNode);
BorderPane.setAlignment(titleNode, Pos.CENTER_LEFT);
BorderPane.setMargin(titleNode, new Insets(0, 0, 0, 8));
}
if (onTitleBarDoubleClick != null)
center.setOnMouseClicked(onTitleBarDoubleClick);
center.setOnMouseDragged(mouseEvent -> {
if (!getSkinnable().isDragging() && primaryStage.isMaximized()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
primaryStage.setMaximized(false);
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
primaryStage.setY(stageInitY = 0);
primaryStage.setX(stageInitX = mouseInitX - stageInitWidth / 2);
}
});
navBar.setCenter(center);
if (canRefresh) {
HBox navRight = new HBox();
navRight.setAlignment(Pos.CENTER_RIGHT);
JFXButton refreshNavButton = new JFXButton();
refreshNavButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshNavButton.getStyleClass().add("jfx-decorator-button");
refreshNavButton.onActionProperty().bind(skinnable.onRefreshNavButtonActionProperty());
skinnable.forbidDraggingWindow(refreshNavButton);
navRight.getChildren().setAll(refreshNavButton);
navBar.setRight(navRight);
}
}
return navBar;
}
private boolean isRightEdge(double x, double y, Bounds boundsInParent) {
return x < root.getWidth() && x >= root.getWidth() - root.snappedLeftInset();
}
private boolean isTopEdge(double x, double y, Bounds boundsInParent) {
return y >= 0 && y <= root.snappedTopInset();
}
private boolean isBottomEdge(double x, double y, Bounds boundsInParent) {
return y < root.getHeight() && y >= root.getHeight() - root.snappedLeftInset();
}
private boolean isLeftEdge(double x, double y, Bounds boundsInParent) {
return x >= 0 && x <= root.snappedLeftInset();
}
private void resizeStage(double newWidth, double newHeight) {
if (newWidth < 0)
newWidth = primaryStage.getWidth();
if (newWidth < primaryStage.getMinWidth())
newWidth = primaryStage.getMinWidth();
if (newWidth < titleContainer.getMinWidth())
newWidth = titleContainer.getMinWidth();
if (newHeight < 0)
newHeight = primaryStage.getHeight();
if (newHeight < primaryStage.getMinHeight())
newHeight = primaryStage.getMinHeight();
if (newHeight < titleContainer.getMinHeight())
newHeight = titleContainer.getMinHeight();
// Width and height must be set simultaneously to avoid JDK-8344372 (https://github.com/openjdk/jfx/pull/1654)
primaryStage.setWidth(newWidth);
primaryStage.setHeight(newHeight);
}
private void onMouseMoved(MouseEvent mouseEvent) {
if (!primaryStage.isFullScreen() && primaryStage.isResizable()) {
double x = mouseEvent.getX(), y = mouseEvent.getY();
Bounds boundsInParent = root.getBoundsInParent();
double diagonalSize = root.snappedLeftInset() + 10;
if (this.isRightEdge(x, y, boundsInParent)) {
if (y < diagonalSize) {
root.setCursor(Cursor.NE_RESIZE);
} else if (y > root.getHeight() - diagonalSize) {
root.setCursor(Cursor.SE_RESIZE);
} else {
root.setCursor(Cursor.E_RESIZE);
}
} else if (this.isLeftEdge(x, y, boundsInParent)) {
if (y < diagonalSize) {
root.setCursor(Cursor.NW_RESIZE);
} else if (y > root.getHeight() - diagonalSize) {
root.setCursor(Cursor.SW_RESIZE);
} else {
root.setCursor(Cursor.W_RESIZE);
}
} else if (this.isTopEdge(x, y, boundsInParent)) {
if (x < diagonalSize) {
root.setCursor(Cursor.NW_RESIZE);
} else if (x > root.getWidth() - diagonalSize) {
root.setCursor(Cursor.NE_RESIZE);
} else {
root.setCursor(Cursor.N_RESIZE);
}
} else if (this.isBottomEdge(x, y, boundsInParent)) {
if (x < diagonalSize) {
root.setCursor(Cursor.SW_RESIZE);
} else if (x > root.getWidth() - diagonalSize) {
root.setCursor(Cursor.SE_RESIZE);
} else {
root.setCursor(Cursor.S_RESIZE);
}
} else {
root.setCursor(Cursor.DEFAULT);
}
} else {
root.setCursor(Cursor.DEFAULT);
}
}
private void onMouseReleased(MouseEvent mouseEvent) {
getSkinnable().setDragging(false);
}
private void onMouseDragged(MouseEvent mouseEvent) {
if (!getSkinnable().isDragging()) {
getSkinnable().setDragging(true);
mouseInitX = mouseEvent.getScreenX();
mouseInitY = mouseEvent.getScreenY();
stageInitX = primaryStage.getX();
stageInitY = primaryStage.getY();
stageInitWidth = primaryStage.getWidth();
stageInitHeight = primaryStage.getHeight();
}
if (primaryStage.isFullScreen() || !mouseEvent.isPrimaryButtonDown() || mouseEvent.isStillSincePress())
return;
double dx = mouseEvent.getScreenX() - mouseInitX;
double dy = mouseEvent.getScreenY() - mouseInitY;
Cursor cursor = root.getCursor();
if (getSkinnable().isAllowMove()) {
if (cursor == Cursor.DEFAULT) {
primaryStage.setX(stageInitX + dx);
primaryStage.setY(stageInitY + dy);
mouseEvent.consume();
}
}
if (getSkinnable().isResizable()) {
if (cursor == Cursor.E_RESIZE) {
resizeStage(stageInitWidth + dx, -1);
mouseEvent.consume();
} else if (cursor == Cursor.S_RESIZE) {
resizeStage(-1, stageInitHeight + dy);
mouseEvent.consume();
} else if (cursor == Cursor.W_RESIZE) {
resizeStage(stageInitWidth - dx, -1);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
mouseEvent.consume();
} else if (cursor == Cursor.N_RESIZE) {
resizeStage(-1, stageInitHeight - dy);
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
} else if (cursor == Cursor.SE_RESIZE) {
resizeStage(stageInitWidth + dx, stageInitHeight + dy);
mouseEvent.consume();
} else if (cursor == Cursor.SW_RESIZE) {
resizeStage(stageInitWidth - dx, stageInitHeight + dy);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
mouseEvent.consume();
} else if (cursor == Cursor.NW_RESIZE) {
resizeStage(stageInitWidth - dx, stageInitHeight - dy);
primaryStage.setX(stageInitX + stageInitWidth - primaryStage.getWidth());
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
} else if (cursor == Cursor.NE_RESIZE) {
resizeStage(stageInitWidth + dx, stageInitHeight - dy);
primaryStage.setY(stageInitY + stageInitHeight - primaryStage.getHeight());
mouseEvent.consume();
}
}
}
enum NavBarAnimations implements TransitionPane.AnimationProducer {
NEXT {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
@Override
public Timeline animate(
Pane container, Node previousNode, Node nextNode,
Duration duration, Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), 50, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), -50, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator))
);
}
@Override
public TransitionPane.AnimationProducer opposite() {
return NEXT;
}
},
PREVIOUS {
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
@Override
public Timeline animate(Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), -50, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), 50, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator))
);
}
@Override
public TransitionPane.AnimationProducer opposite() {
return PREVIOUS;
}
};
}
}
@@ -0,0 +1,400 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <[email protected]> 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.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import org.glavo.monetfx.ColorRole;
import org.glavo.monetfx.ColorScheme;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Renders the clipped background, title bar, and navigation content owned by a [Decorator].
@NotNullByDefault
final class MainWindowPane extends StackPane {
/// The diameter, in pixels, of the rounded window corners.
private static final double ARC = 8.0;
/// The decorator whose state and actions are represented by this pane.
private final Decorator decorator;
/// The clip that rounds normal window corners and becomes square while the window fills the screen.
private final Rectangle clip = new Rectangle();
/// The frame containing the title bar and current navigation page.
private final BorderPane frame;
/// The title bar containing page navigation state.
private final BorderPane titleBar;
/// The transition container used when the title-bar state changes.
private final TransitionPane navBarPane;
/// Retains listener delegates that are registered through weak listener wrappers.
@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();
/// Creates and wires the main-window content for `decorator`.
///
/// @param decorator the owning window decorator
MainWindowPane(Decorator decorator) {
this.decorator = decorator;
clip.widthProperty().bind(widthProperty());
clip.heightProperty().bind(heightProperty());
clip.setArcWidth(ARC);
clip.setArcHeight(ARC);
setClip(clip);
backgroundProperty().bind(Bindings.createObjectBinding(
() -> Themes.windowTransparentProperty().get()
? null
: new Background(new BackgroundFill(
Themes.getColorScheme().getColor(ColorRole.SURFACE_CONTAINER),
CornerRadii.EMPTY,
Insets.EMPTY)),
Themes.windowTransparentProperty(),
Themes.colorSchemeProperty()));
Region backgroundNode = createBackgroundNode();
frame = new BorderPane();
frame.getStyleClass().add("jfx-decorator");
StackPane center = new StackPane();
center.getStyleClass().add("jfx-decorator-content-container");
FXUtils.setOverflowHidden(center);
center.getChildren().setAll(decorator.getNavigator());
frame.setCenter(center);
HBox rightButtonsContainer = createWindowButtons();
titleBar = new BorderPane();
titleBar.setPickOnBounds(false);
titleBar.getStyleClass().add("jfx-tool-bar");
titleBar.setRight(rightButtonsContainer);
navBarPane = new TransitionPane();
titleBar.setCenter(navBarPane);
frame.setTop(titleBar);
updateTitleBarBackground();
InvalidationListener titleBarBackgroundListener = observable -> updateTitleBarBackground();
Themes.colorSchemeProperty().addListener(holder.weak(titleBarBackgroundListener));
Themes.titleBarTransparentProperty().addListener(holder.weak(titleBarBackgroundListener));
InvalidationListener navBarListener = observable -> updateNavBar();
decorator.stateProperty().addListener(holder.weak(navBarListener));
updateNavBar();
decorator.capableDraggingWindow(titleBar);
getChildren().setAll(backgroundNode, frame);
}
/// Updates the content-corner shape for an edge-to-edge window state.
///
/// @param edgeToEdge whether the attached window is maximized or full-screen
void setWindowEdgeToEdge(boolean edgeToEdge) {
double arc = edgeToEdge ? 0.0 : ARC;
clip.setArcWidth(arc);
clip.setArcHeight(arc);
}
/// Creates the launcher-background layer and keeps it synchronized with the active theme.
///
/// @return the mouse-transparent background node
private Region createBackgroundNode() {
Region backgroundNode = new Region();
backgroundNode.setMouseTransparent(true);
StackPane.setAlignment(backgroundNode, Pos.BOTTOM_CENTER);
holder.onWeakChangeAndOperate(Themes.backgroundProperty(), background -> {
double opacity = background.opacity();
if (opacity >= 0.001) {
backgroundNode.setBackground(background.background());
backgroundNode.setOpacity(opacity);
} else {
backgroundNode.setBackground(null);
backgroundNode.setOpacity(0);
}
});
return backgroundNode;
}
/// Creates the help, minimize, and application-close buttons.
///
/// @return a size-limited top-right button row
private HBox createWindowButtons() {
HBox buttons = new HBox();
buttons.setAlignment(Pos.TOP_RIGHT);
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
JFXButton helpButton = new JFXButton();
helpButton.setFocusTraversable(false);
helpButton.setGraphic(SVG.HELP.createIcon(Themes.titleFillProperty()));
helpButton.getStyleClass().add("jfx-decorator-button");
helpButton.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL));
decorator.forbidDraggingWindow(helpButton);
JFXButton minimizeButton = new JFXButton();
minimizeButton.setFocusTraversable(false);
minimizeButton.setGraphic(SVG.MINIMIZE_CENTER.createIcon(Themes.titleFillProperty()));
minimizeButton.getStyleClass().add("jfx-decorator-button");
minimizeButton.setOnAction(event -> decorator.minimizeWindow());
decorator.forbidDraggingWindow(minimizeButton);
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic(SVG.CLOSE.createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.setOnAction(event -> decorator.closeWindow());
decorator.forbidDraggingWindow(closeButton);
buttons.getChildren().setAll(helpButton, minimizeButton, closeButton);
return buttons;
}
/// Recomputes the title-bar background for the current theme and transparency preference.
private void updateTitleBarBackground() {
@Nullable ColorScheme colorScheme = Themes.getColorScheme();
if (colorScheme == null) {
titleBar.setBackground(null);
} else if (Themes.titleBarTransparentProperty().get()) {
Color surface = colorScheme.getSurface();
titleBar.setBackground(new Background(new BackgroundFill(
new Color(surface.getRed(), surface.getGreen(), surface.getBlue(), 0.5),
CornerRadii.EMPTY,
Insets.EMPTY)));
} else {
titleBar.setBackground(new Background(new BackgroundFill(
colorScheme.getPrimaryContainer(),
CornerRadii.EMPTY,
Insets.EMPTY)));
}
}
/// Rebuilds the navigation bar from the current page state.
private void updateNavBar() {
DecoratorPage.@Nullable State state = decorator.getState();
if (state == null) {
navBarPane.getChildren().clear();
return;
}
Node navBar = createNavBar(state);
if (state.animate()) {
TransitionPane.AnimationProducer animation = switch (decorator.getNavigationDirection()) {
case NEXT -> NavBarAnimations.NEXT;
case PREVIOUS -> NavBarAnimations.PREVIOUS;
default -> ContainerAnimations.FADE;
};
decorator.setNavigationDirection(Navigation.NavigationDirection.START);
navBarPane.setContent(navBar, animation, Motion.SHORT4);
} else {
navBarPane.getChildren().setAll(navBar);
}
}
/// Creates the page-specific navigation controls and title.
///
/// @param state the current page state
/// @return a newly constructed navigation bar
private Node createNavBar(DecoratorPage.State state) {
HBox navBar = new HBox();
navBar.setAlignment(Pos.CENTER_LEFT);
// Left navigation buttons
if (state.backable()) {
JFXButton backButton = new JFXButton();
backButton.setFocusTraversable(false);
backButton.setGraphic(SVG.ARROW_BACK.createIcon(Themes.titleFillProperty()));
backButton.getStyleClass().add("jfx-decorator-button");
backButton.setOnAction(event -> decorator.back());
decorator.forbidDraggingWindow(backButton);
navBar.getChildren().add(backButton);
}
if (decorator.canCloseProperty().get()) {
boolean showCloseAsHome = decorator.showCloseAsHomeProperty().get();
JFXButton closeButton = new JFXButton();
closeButton.setFocusTraversable(false);
closeButton.setGraphic((showCloseAsHome ? SVG.HOME : SVG.CLOSE).createIcon(Themes.titleFillProperty()));
closeButton.getStyleClass().add("jfx-decorator-button");
closeButton.setOnAction(event -> decorator.closeCurrentPage());
decorator.forbidDraggingWindow(closeButton);
navBar.getChildren().add(closeButton);
}
// Center title area
StackPane titleArea = new StackPane();
titleArea.setAlignment(Pos.CENTER_LEFT);
titleArea.setMinWidth(0);
HBox.setHgrow(titleArea, Priority.ALWAYS);
if (state.titleNode() != null) {
titleArea.getChildren().setAll(state.titleNode());
StackPane.setMargin(state.titleNode(), new Insets(0, 0, 0, 8));
} else if (state.title() != null) {
Label titleLabel = new Label(state.title());
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
titleLabel.getStyleClass().add("jfx-decorator-title");
titleLabel.setMinWidth(0);
titleArea.getChildren().setAll(titleLabel);
}
decorator.registerTitleBar(titleArea);
if (!navBar.getChildren().isEmpty()) {
Insets padding = new Insets(0, 0, 0, 5);
navBar.setPadding(padding);
titleArea.setPadding(padding);
}
navBar.getChildren().add(titleArea);
// Right refresh button
if (state.refreshable()) {
JFXButton refreshButton = new JFXButton();
refreshButton.setFocusTraversable(false);
refreshButton.setGraphic(SVG.REFRESH.createIcon(Themes.titleFillProperty()));
refreshButton.getStyleClass().add("jfx-decorator-button");
refreshButton.setOnAction(event -> decorator.refresh());
decorator.forbidDraggingWindow(refreshButton);
navBar.getChildren().add(refreshButton);
}
return navBar;
}
/// Produces directional transitions for page-title changes.
private enum NavBarAnimations implements TransitionPane.AnimationProducer {
/// Moves the next title in from the right.
NEXT {
/// {@inheritDoc}
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(container.getWidth());
}
/// {@inheritDoc}
@Override
public Timeline animate(
Pane container,
Node previousNode,
Node nextNode,
Duration duration,
Interpolator interpolator) {
return createTimeline(previousNode, nextNode, 50, -50, duration, interpolator);
}
/// {@inheritDoc}
@Override
public TransitionPane.AnimationProducer opposite() {
return PREVIOUS;
}
},
/// Moves the previous title in from the left.
PREVIOUS {
/// {@inheritDoc}
@Override
public void init(TransitionPane container, Node previousNode, Node nextNode) {
super.init(container, previousNode, nextNode);
nextNode.setTranslateX(-container.getWidth());
}
/// {@inheritDoc}
@Override
public Timeline animate(
Pane container,
Node previousNode,
Node nextNode,
Duration duration,
Interpolator interpolator) {
return createTimeline(previousNode, nextNode, -50, 50, duration, interpolator);
}
/// {@inheritDoc}
@Override
public TransitionPane.AnimationProducer opposite() {
return NEXT;
}
};
/// Creates a title transition with the supplied horizontal offsets.
///
/// @param previousNode the title being removed
/// @param nextNode the title being shown
/// @param nextOffset the initial offset of the next title
/// @param previousOffset the final offset of the previous title
/// @param duration the transition duration
/// @param interpolator the transition interpolator
/// @return the unstarted transition timeline
private static Timeline createTimeline(
Node previousNode,
Node nextNode,
double nextOffset,
double previousOffset,
Duration duration,
Interpolator interpolator) {
return new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(nextNode.translateXProperty(), nextOffset, interpolator),
new KeyValue(previousNode.translateXProperty(), 0, interpolator),
new KeyValue(nextNode.opacityProperty(), 0, interpolator),
new KeyValue(previousNode.opacityProperty(), 1, interpolator)),
new KeyFrame(duration,
new KeyValue(nextNode.translateXProperty(), 0, interpolator),
new KeyValue(previousNode.translateXProperty(), previousOffset, interpolator),
new KeyValue(nextNode.opacityProperty(), 1, interpolator),
new KeyValue(previousNode.opacityProperty(), 0, interpolator)));
}
}
}
@@ -575,8 +575,8 @@ public class DownloadPage extends Control implements DecoratorPage {
this.setActions(downloadButton, saveAsButton, cancelButton);
}
this.prefWidthProperty().bind(Controllers.windowWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.7));
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
onEscPressed(this, cancelButton::fire);
}
@@ -402,7 +402,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
titleContainer.setSpacing(8);
titleContainer.setPadding(new Insets(0, 0, 12, 0));
DoubleBinding widthBinding = Controllers.windowWidthProperty().multiply(0.7);
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
prefWidthProperty().bind(widthBinding);
maxWidthProperty().bind(widthBinding);
@@ -442,7 +442,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
description.heightProperty().addListener((obs, oldVal, newVal) -> {
double maxHeight = Controllers.windowHeightProperty().get() * 0.5;
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
@@ -594,7 +594,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
maxWidthProperty().bind(Controllers.windowWidthProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
imageContainer.setImage(packInfoObject.getIcon());
@@ -620,7 +620,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
FXUtils.onChange(description.heightProperty(), newVal -> {
double maxHeight = Controllers.windowHeightProperty().get() * 0.5;
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
@@ -418,7 +418,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
/// @param themePack the installed theme pack to display
private ThemePackInfoDialog(ThemePackManagementPage page, ThemePackManager.InstalledThemePack themePack) {
ThemePackManifest manifest = themePack.manifest();
maxWidthProperty().bind(Controllers.windowWidthProperty().multiply(0.7));
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
HBox heading = new HBox(8);
heading.setAlignment(Pos.CENTER_LEFT);
@@ -483,7 +483,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
scrollPane.setPrefViewportWidth(520);
scrollPane.setPrefViewportHeight(Math.min(360, manifest.themes().size() * 86));
scrollPane.maxHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.55));
scrollPane.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.55));
StackPane body = new StackPane(scrollPane);
body.setPadding(new Insets(10, 0, 0, 0));
@@ -1762,11 +1762,6 @@
-fx-font-size: 14;
}
.window {
-fx-background-color: transparent;
-fx-padding: 8;
}
.debug-border {
-fx-border-color: red;
-fx-border-width: 1;