From 26ee08c12bdf3c564126a9c43907dee86d08c456 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 17 Sep 2026 21:35:49 +0800 Subject: [PATCH 1/5] Support native JavaFX 27 window decoration with transparent-window fallback Assisted-by: codex:gpt-6-astra --- .../java/org/jackhuang/hmcl/Launcher.java | 6 - .../org/jackhuang/hmcl/ui/Controllers.java | 75 +++++++++- .../hmcl/ui/decorator/Decorator.java | 86 ++++++++--- .../hmcl/ui/decorator/MainWindowPane.java | 31 +++- .../ui/decorator/NativeWindowDecoration.java | 140 ++++++++++++++++++ .../hmcl/ui/instances/SchematicsPage.java | 4 +- 6 files changed, 310 insertions(+), 32 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 46872d8c4ed..71a49c42f4b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -38,10 +38,8 @@ import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.WindowsNativeUtils; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.upgrade.UpdateHandler; @@ -142,12 +140,8 @@ public void start(Stage primaryStage) { Platform.setImplicitExit(false); Controllers.initialize(primaryStage); - if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) - Themes.applyNativeDarkMode(primaryStage); - UpdateChecker.init(); - WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage); primaryStage.show(); }); } catch (Throwable e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 52411fb21bd..50fa18bdae4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -44,6 +44,7 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.account.AccountListPage; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.Motion; @@ -96,6 +97,9 @@ public final class Controllers { private static Lazy rootPage = new Lazy<>(RootPage::new); /// The coordinator for the main window's scene graph and navigation stack. private static @Nullable Decorator decorator; + /// Whether a transparency change has already queued a main-window style check. + private static boolean windowStyleUpdateQueued; + private static DownloadPage downloadPage; private static Lazy accountListPage = new Lazy<>(() -> { AccountListPage accountListPage = new AccountListPage(); @@ -210,6 +214,63 @@ public static void onApplicationStop() { } } + /// Installs application identity and native lifecycle integration on a new main stage. + /// + /// @param stage the unshown main stage + private static void configureMainStage(Stage stage) { + stage.setOnCloseRequest(event -> Launcher.stopApplication()); + FXUtils.setIcon(stage); + stage.setTitle(Metadata.FULL_TITLE); + if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { + Themes.applyNativeDarkMode(stage); + } + WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(stage); + } + + /// Replaces the main stage when transparency requires a different decoration style. + /// + /// The retained scene, navigation, dialogs, normal bounds, and window state are preserved. A hidden window + /// remains hidden. This method must run on the JavaFX application thread after theme bindings have updated. + private static void updateMainWindowStyle() { + @Nullable Decorator currentDecorator = decorator; + if (currentDecorator == null || !currentDecorator.isStageStyleOutdated()) { + return; + } + @Nullable Stage previousStage = currentDecorator.getStage(); + if (previousStage == null) { + return; + } + + boolean showing = previousStage.isShowing(); + boolean maximized = previousStage.isMaximized(); + boolean fullScreen = previousStage.isFullScreen(); + boolean iconified = previousStage.isIconified(); + @Nullable Node focusOwner = previousStage.getScene().getFocusOwner(); + + Stage replacement = new Stage(); + configureMainStage(replacement); + replacement.setTitle(previousStage.getTitle()); + replacement.getIcons().setAll(previousStage.getIcons()); + replacement.setOnCloseRequest(previousStage.getOnCloseRequest()); + replacement.setResizable(previousStage.isResizable()); + replacement.setAlwaysOnTop(previousStage.isAlwaysOnTop()); + replacement.setFullScreenExitHint(previousStage.getFullScreenExitHint()); + replacement.setFullScreenExitKeyCombination(previousStage.getFullScreenExitKeyCombination()); + + currentDecorator.detachStage(); + previousStage.hide(); + currentDecorator.attachStage(replacement); + replacement.setMaximized(maximized); + replacement.setFullScreen(fullScreen); + replacement.setIconified(iconified); + if (showing) { + replacement.show(); + } + if (focusOwner != null) { + focusOwner.requestFocus(); + } + } + /// Initializes the main application stage, scene graph, and background services. /// /// @param stage the primary application stage, which must not have been shown @@ -230,10 +291,19 @@ public static void initialize(Stage stage) { } } - stage.setOnCloseRequest(e -> Launcher.stopApplication()); + configureMainStage(stage); decorator = new Decorator(getRootPage()); Scene mainScene = decorator.attachStage(stage); + Themes.windowTransparentProperty().addListener((observable, oldValue, newValue) -> { + if (!windowStyleUpdateQueued) { + windowStyleUpdateQueued = true; + Platform.runLater(() -> { + windowStyleUpdateQueued = false; + updateMainWindowStyle(); + }); + } + }); getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty())); getRootPage().getMainPage().showUpdateDialogProperty().bind( decorator.backableProperty().not() @@ -251,9 +321,6 @@ public static void initialize(Stage stage) { StyleSheets.init(mainScene); - FXUtils.setIcon(stage); - stage.setTitle(Metadata.FULL_TITLE); - if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) { Runnable continueAction = () -> { UserState userState = userState(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java index 1cb1bb8505a..36f4c05bf14 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java @@ -61,6 +61,7 @@ import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD; import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogUtils; import org.jackhuang.hmcl.ui.FXUtils; @@ -129,6 +130,12 @@ public final class Decorator { /// The navigation stack rendered in the main window. private final Navigator navigator = new Navigator(); + /// Reflective access to system decoration, or `null` on unsupported runtimes. + private final @Nullable NativeWindowDecoration nativeDecoration = NativeWindowDecoration.create(); + + /// Whether the attached stage delegates window decoration and gestures to the platform. + private boolean nativeDecorationEnabled; + /// The clipped main-window content hosted inside [#shadowContainer]. private final MainWindowPane mainWindowPane; @@ -287,7 +294,7 @@ public Parent getRoot() { /// Returns the insets reserved outside the main-window content. /// - /// @return the root's shadow insets for a normal window, or [Insets#EMPTY] while maximized or full-screen + /// @return the custom shadow insets, or [Insets#EMPTY] with native decoration, maximization, or full-screen public Insets getWindowInsets() { return root.getPadding(); } @@ -318,6 +325,7 @@ private Insets getResizeInsets() { /// /// @param edgeToEdge whether the attached stage is maximized or full-screen private void updateWindowDecoration(boolean edgeToEdge) { + edgeToEdge |= nativeDecorationEnabled; root.setPadding(edgeToEdge ? Insets.EMPTY : SHADOW_INSETS); shadowContainer.setEffect(edgeToEdge ? null : windowShadow); mainWindowPane.setWindowEdgeToEdge(edgeToEdge); @@ -405,7 +413,14 @@ public void startWizard(WizardProvider wizardProvider, @Nullable String category /// /// @param node the drag-enabled node public void capableDraggingWindow(Node node) { - node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> allowMove = true); + if (nativeDecoration != null) { + nativeDecoration.setDraggable(node, true); + } + node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> { + if (!nativeDecorationEnabled) { + allowMove = true; + } + }); node.addEventHandler(MouseEvent.MOUSE_EXITED, event -> { if (!dragging) { allowMove = false; @@ -425,9 +440,14 @@ void registerTitleBar(Node node) { /// /// @param node the node that must consume drag eligibility public void forbidDraggingWindow(Node node) { + if (nativeDecoration != null) { + nativeDecoration.setDraggable(node, false); + } node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> { - allowMove = false; - event.consume(); + if (!nativeDecorationEnabled) { + allowMove = false; + event.consume(); + } }); } @@ -436,7 +456,8 @@ public void forbidDraggingWindow(Node node) { /// @param event the title-bar click event private void onTitleBarDoubleClick(MouseEvent event) { @Nullable Stage currentStage = stage; - if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS + if (nativeDecorationEnabled + || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS || currentStage == null || event.getButton() != MouseButton.PRIMARY || event.getClickCount() != 2) { @@ -452,7 +473,7 @@ private void onTitleBarDoubleClick(MouseEvent event) { /// @param event the title-bar drag event private void onTitleBarDragged(MouseEvent event) { @Nullable Stage currentStage = stage; - if (currentStage == null || dragging || !currentStage.isMaximized()) { + if (nativeDecorationEnabled || currentStage == null || dragging || !currentStage.isMaximized()) { return; } @@ -531,7 +552,7 @@ private void resizeStage(double newWidth, double newHeight) { /// @param event the pointer movement event private void onMouseMoved(MouseEvent event) { @Nullable Stage currentStage = stage; - if (currentStage == null + if (nativeDecorationEnabled || currentStage == null || currentStage.isIconified() || currentStage.isFullScreen() || currentStage.isMaximized() @@ -597,7 +618,7 @@ private void onMouseReleased(MouseEvent event) { /// @param event the primary-button drag event private void onMouseDragged(MouseEvent event) { @Nullable Stage currentStage = stage; - if (currentStage == null + if (nativeDecorationEnabled || currentStage == null || currentStage.isIconified() || currentStage.isFullScreen() || currentStage.isMaximized() @@ -712,27 +733,51 @@ private void initializeStageBounds(Stage targetStage) { contentHeight.set(initialContentHeight); } + /// Returns the preferred style for the current runtime and effective transparency setting. + /// + /// @return the system style when available and opaque, otherwise the custom transparent style + private StageStyle preferredStageStyle() { + return nativeDecoration != null && !Themes.windowTransparentProperty().get() + ? nativeDecoration.style : StageStyle.TRANSPARENT; + } + + /// Returns whether the current stage must be replaced to apply the effective transparency setting. + /// + /// @return `true` if a stage is attached with a different style from the current preference + public boolean isStageStyleOutdated() { + return stage != null && stage.getStyle() != preferredStageStyle(); + } + /// Attaches the retained scene and native window behavior to `newStage`. /// /// If the root has no scene, this method reuses `newStage`'s scene and replaces its root, or creates a /// transparent scene when the stage has none. If the retained scene belongs to another stage, it is detached /// from that stage before being installed on `newStage`. A newly attached stage receives the persisted normal - /// content bounds and minimum size adjusted for the normal decoration insets. The stage and retained scene use - /// transparent styles required by the custom decoration. Any active window animation is cancelled and reset. - /// When window animations are enabled, each subsequent showing starts the opening animation. This method does + /// content bounds and minimum size adjusted for the normal decoration insets. Opaque windows use the system + /// decoration when supported; other windows use the custom transparent decoration. Any active window animation + /// is cancelled and reset. Custom window animations run only with custom decoration. This method does /// not show the stage. /// /// @param newStage the stage to attach, which must be accessed on the JavaFX application thread /// @return the scene installed on `newStage` - /// @throws IllegalStateException if `newStage` has already been shown with a non-transparent style, or if the + /// @throws IllegalStateException if `newStage` has already been shown with a different style, or if the /// retained root or scene cannot be transferred from its current owner public Scene attachStage(Stage newStage) { FXUtils.checkFxUserThread(); stopWindowAnimation(); - if (newStage.getStyle() != StageStyle.TRANSPARENT) { - newStage.initStyle(StageStyle.TRANSPARENT); + StageStyle style = preferredStageStyle(); + if (newStage.getStyle() != style) { + newStage.initStyle(style); } + nativeDecorationEnabled = nativeDecoration != null && style == nativeDecoration.style; + if (nativeDecoration != null) { + nativeDecoration.setContent(null); + if (nativeDecorationEnabled) { + nativeDecoration.configureStage(newStage); + } + } + mainWindowPane.setNativeDecoration(nativeDecorationEnabled ? nativeDecoration : null); @Nullable Scene retainedScene = root.getScene(); Scene scene; @@ -759,7 +804,7 @@ public Scene attachStage(Stage newStage) { } } - scene.setFill(Color.TRANSPARENT); + scene.setFill(nativeDecorationEnabled ? Color.WHITE : Color.TRANSPARENT); if (newStage.getScene() != scene) { newStage.setScene(scene); @@ -880,7 +925,7 @@ void setNavigationDirection(Navigation.NavigationDirection navigationDirection) /// Calling this method replaces any active minimize, restore, close, or earlier opening animation. If window /// animations are disabled, it restores the stable root transform without starting an animation. private void playOpenAnimation() { - if (!AnimationUtils.playWindowAnimation()) { + if (nativeDecorationEnabled || !AnimationUtils.playWindowAnimation()) { stopWindowAnimation(); Controllers.trimHeap(); return; @@ -915,7 +960,7 @@ void minimizeWindow() { return; } - if (AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) { + if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) { Timeline timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(root.opacityProperty(), 1, Motion.EASE), @@ -946,7 +991,7 @@ void minimizeWindow() { /// Closes the application, using the configured window animation when enabled. void closeWindow() { - if (AnimationUtils.playWindowAnimation()) { + if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation()) { Timeline timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(root.opacityProperty(), 1, Motion.EASE), @@ -1074,6 +1119,11 @@ private void setupInputRouting() { /// Restores the root node's transform after an animated minimization. private void playRestoreAnimation() { + if (nativeDecorationEnabled) { + stopWindowAnimation(); + return; + } + Timeline timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(root.opacityProperty(), 0, Motion.EASE), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java index 106d7ec2c5f..1b912e551b8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java @@ -72,6 +72,9 @@ final class MainWindowPane extends StackPane { /// The title bar containing page navigation state. private final BorderPane titleBar; + /// The custom window buttons, whose minimize and close actions are hidden with native decoration. + private final HBox windowButtons; + /// The transition container used when the title-bar state changes. private final TransitionPane navBarPane; @@ -112,14 +115,15 @@ final class MainWindowPane extends StackPane { center.getChildren().setAll(decorator.getNavigator()); frame.setCenter(center); - HBox rightButtonsContainer = createWindowButtons(); + windowButtons = createWindowButtons(); titleBar = new BorderPane(); titleBar.setPickOnBounds(false); titleBar.getStyleClass().add("jfx-tool-bar"); - titleBar.setRight(rightButtonsContainer); + titleBar.setRight(windowButtons); navBarPane = new TransitionPane(); titleBar.setCenter(navBarPane); + decorator.capableDraggingWindow(navBarPane); frame.setTop(titleBar); updateTitleBarBackground(); @@ -136,6 +140,25 @@ final class MainWindowPane extends StackPane { getChildren().setAll(backgroundNode, frame); } + /// Switches the title bar between custom window controls and the system header. + /// + /// @param decoration the native header support, or `null` for custom decoration + void setNativeDecoration(@Nullable NativeWindowDecoration decoration) { + frame.setTop(null); + if (decoration == null) { + frame.setTop(titleBar); + } else { + decoration.setContent(titleBar); + decoration.headerBar.backgroundProperty().bind(titleBar.backgroundProperty()); + frame.setTop(decoration.headerBar); + } + for (int i = 1; i < windowButtons.getChildren().size(); i++) { + Node button = windowButtons.getChildren().get(i); + button.setVisible(decoration == null); + button.setManaged(decoration == null); + } + } + /// Updates the content-corner shape for an edge-to-edge window state. /// /// @param edgeToEdge whether the attached window is maximized or full-screen @@ -171,6 +194,7 @@ private Region createBackgroundNode() { private HBox createWindowButtons() { HBox buttons = new HBox(); buttons.setAlignment(Pos.TOP_RIGHT); + decorator.capableDraggingWindow(buttons); buttons.setMaxSize(Region.USE_PREF_SIZE, 40); JFXButton helpButton = new JFXButton(); @@ -247,6 +271,7 @@ private void updateNavBar() { private Node createNavBar(DecoratorPage.State state) { HBox navBar = new HBox(); navBar.setAlignment(Pos.CENTER_LEFT); + decorator.capableDraggingWindow(navBar); // Left navigation buttons if (state.backable()) { @@ -275,6 +300,7 @@ private Node createNavBar(DecoratorPage.State state) { StackPane titleArea = new StackPane(); titleArea.setAlignment(Pos.CENTER_LEFT); titleArea.setMinWidth(0); + decorator.capableDraggingWindow(titleArea); HBox.setHgrow(titleArea, Priority.ALWAYS); if (state.titleNode() != null) { titleArea.getChildren().setAll(state.titleNode()); @@ -284,6 +310,7 @@ private Node createNavBar(DecoratorPage.State state) { titleLabel.textFillProperty().bind(Themes.titleFillProperty()); titleLabel.getStyleClass().add("jfx-decorator-title"); titleLabel.setMinWidth(0); + titleLabel.setMouseTransparent(true); titleArea.getChildren().setAll(titleLabel); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java new file mode 100644 index 00000000000..597be918b2f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java @@ -0,0 +1,140 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.ui.decorator; + +import javafx.application.ConditionalFeature; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.scene.Node; +import javafx.scene.layout.Region; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import org.jackhuang.hmcl.theme.Themes; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Method; +import java.util.Objects; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes. +@NotNullByDefault +final class NativeWindowDecoration { + /// The platform-supported extended stage style. + final StageStyle style; + + /// The header bar that reserves space for the system window buttons. + final Region headerBar; + + /// Assigns the header bar's center content. + private final Method setCenter; + + /// Assigns a node's native header hit-test behavior. + private final Method setDragType; + + /// Returns the stage's system-button color-scheme property. + private final Method systemColorSchemeProperty; + + /// Marks only the specified node as draggable, preserving child control interaction. + private final Object draggable; + + /// Excludes a node and its descendants from native dragging. + private final Object notDraggable; + + /// The light system-button color scheme. + private final Object light; + + /// The dark system-button color scheme. + private final Object dark; + + /// Resolves all required public APIs and creates a detached header bar. + /// + /// @throws ReflectiveOperationException if the runtime does not expose a required API + private NativeWindowDecoration() throws ReflectiveOperationException { + style = (StageStyle) Objects.requireNonNull(StageStyle.class.getField("EXTENDED").get(null)); + Class headerClass = Class.forName("javafx.scene.layout.HeaderBar"); + Class dragClass = Class.forName("javafx.scene.layout.HeaderDragType"); + Class colorClass = Class.forName("javafx.application.ColorScheme"); + setCenter = headerClass.getMethod("setCenter", Node.class); + setDragType = headerClass.getMethod("setDragType", Node.class, dragClass); + systemColorSchemeProperty = headerClass.getMethod("systemColorSchemeProperty", Stage.class); + draggable = Objects.requireNonNull(dragClass.getField("DRAGGABLE").get(null)); + notDraggable = Objects.requireNonNull(dragClass.getField("NONE").get(null)); + light = Objects.requireNonNull(colorClass.getField("LIGHT").get(null)); + dark = Objects.requireNonNull(colorClass.getField("DARK").get(null)); + headerBar = (Region) headerClass.getConstructor().newInstance(); + } + + /// Creates native decoration support on JavaFX 27 or later when supported by the platform. + /// + /// @return the resolved support, or `null` on older or unsupported runtimes + static @Nullable NativeWindowDecoration create() { + try { + int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]); + if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) { + return null; + } + return new NativeWindowDecoration(); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + LOG.warning("Native window decoration is unavailable; using custom decoration", e); + return null; + } + } + + /// Sets or removes the header content; the caller must first detach it from any other parent. + /// + /// @param content the title-bar content, or `null` to detach it + void setContent(@Nullable Node content) { + try { + setCenter.invoke(headerBar, content); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot update native header content", e); + } + } + + /// Marks a node as draggable or explicitly non-draggable during native header hit testing. + /// + /// Draggability does not extend to descendants, so interactive title content retains its behavior. + /// + /// @param node the node to configure + /// @param enabled whether the node itself may move the window + void setDraggable(Node node, boolean enabled) { + try { + setDragType.invoke(null, node, enabled ? draggable : notDraggable); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot configure native header dragging", e); + } + } + + /// Keeps the platform window buttons synchronized with the launcher color scheme. + /// + /// @param stage the extended stage to configure + @SuppressWarnings("unchecked") + void configureStage(Stage stage) { + try { + ObjectProperty colorScheme = (ObjectProperty) + Objects.requireNonNull(systemColorSchemeProperty.invoke(null, stage)); + colorScheme.bind(Bindings.createObjectBinding( + () -> Themes.darkModeProperty().get() ? dark : light, Themes.darkModeProperty())); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot configure native window buttons", e); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java index a8ec507dc75..2d7231df744 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java @@ -742,10 +742,10 @@ public Cell(JFXListView listView) { { var fo = SVG.FOLDER_OPEN.createIcon(); var f = SVG.FOLDER.createIcon(); - btnReveal.graphicProperty().bind(isDirectoryProperty.map(b -> b ? fo : f)); + btnReveal.graphicProperty().bind(Bindings.when(isDirectoryProperty).then(fo).otherwise(f)); var tooltip = new Tooltip(); - tooltip.textProperty().bind(isDirectoryProperty.map(b -> b ? i18n("button.reveal_dir") : i18n("reveal.in_file_manager"))); + tooltip.textProperty().bind(Bindings.when(isDirectoryProperty).then(i18n("button.reveal_dir")).otherwise(i18n("reveal.in_file_manager"))); FXUtils.installFastTooltip(btnReveal, tooltip); } btnReveal.setOnAction(event -> { From 701be3c8cb99a1a3d837132a753bd2899dc65365 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 17 Sep 2026 21:44:57 +0800 Subject: [PATCH 2/5] Fix native title-bar dragging and restore custom window buttons Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/decorator/MainWindowPane.java | 9 +- .../ui/decorator/NativeWindowDecoration.java | 85 +++++++--- .../decorator/NativeWindowDecorationTest.java | 152 ++++++++++++++++++ 3 files changed, 216 insertions(+), 30 deletions(-) create mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecorationTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java index 1b912e551b8..1ddfddf0427 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java @@ -72,7 +72,7 @@ final class MainWindowPane extends StackPane { /// The title bar containing page navigation state. private final BorderPane titleBar; - /// The custom window buttons, whose minimize and close actions are hidden with native decoration. + /// The custom help, minimize, and close buttons shared by both decoration styles. private final HBox windowButtons; /// The transition container used when the title-bar state changes. @@ -140,7 +140,7 @@ final class MainWindowPane extends StackPane { getChildren().setAll(backgroundNode, frame); } - /// Switches the title bar between custom window controls and the system header. + /// Moves the custom title bar into or out of the native dragging header. /// /// @param decoration the native header support, or `null` for custom decoration void setNativeDecoration(@Nullable NativeWindowDecoration decoration) { @@ -152,11 +152,6 @@ void setNativeDecoration(@Nullable NativeWindowDecoration decoration) { decoration.headerBar.backgroundProperty().bind(titleBar.backgroundProperty()); frame.setTop(decoration.headerBar); } - for (int i = 1; i < windowButtons.getChildren().size(); i++) { - Node button = windowButtons.getChildren().get(i); - button.setVisible(decoration == null); - button.setManaged(decoration == null); - } } /// Updates the content-corner shape for an edge-to-edge window state. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java index 597be918b2f..e80bfad78d1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java @@ -19,13 +19,14 @@ import javafx.application.ConditionalFeature; import javafx.application.Platform; -import javafx.beans.binding.Bindings; -import javafx.beans.property.ObjectProperty; +import javafx.collections.ListChangeListener; import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Control; +import javafx.scene.control.Label; import javafx.scene.layout.Region; import javafx.stage.Stage; import javafx.stage.StageStyle; -import org.jackhuang.hmcl.theme.Themes; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -37,10 +38,13 @@ /// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes. @NotNullByDefault final class NativeWindowDecoration { + /// Marks nodes whose title-content descendants are already observed for native hit testing. + private static final Object DRAG_CONFIGURED = new Object(); + /// The platform-supported extended stage style. final StageStyle style; - /// The header bar that reserves space for the system window buttons. + /// The header bar that supplies native dragging around the custom title content. final Region headerBar; /// Assigns the header bar's center content. @@ -49,8 +53,11 @@ final class NativeWindowDecoration { /// Assigns a node's native header hit-test behavior. private final Method setDragType; - /// Returns the stage's system-button color-scheme property. - private final Method systemColorSchemeProperty; + /// Reads explicit native drag exclusions on title nodes. + private final Method getDragType; + + /// Sets the system button height to zero to hide the platform-provided buttons. + private final Method setSystemButtonHeight; /// Marks only the specified node as draggable, preserving child control interaction. private final Object draggable; @@ -58,12 +65,6 @@ final class NativeWindowDecoration { /// Excludes a node and its descendants from native dragging. private final Object notDraggable; - /// The light system-button color scheme. - private final Object light; - - /// The dark system-button color scheme. - private final Object dark; - /// Resolves all required public APIs and creates a detached header bar. /// /// @throws ReflectiveOperationException if the runtime does not expose a required API @@ -71,14 +72,12 @@ private NativeWindowDecoration() throws ReflectiveOperationException { style = (StageStyle) Objects.requireNonNull(StageStyle.class.getField("EXTENDED").get(null)); Class headerClass = Class.forName("javafx.scene.layout.HeaderBar"); Class dragClass = Class.forName("javafx.scene.layout.HeaderDragType"); - Class colorClass = Class.forName("javafx.application.ColorScheme"); setCenter = headerClass.getMethod("setCenter", Node.class); setDragType = headerClass.getMethod("setDragType", Node.class, dragClass); - systemColorSchemeProperty = headerClass.getMethod("systemColorSchemeProperty", Stage.class); + getDragType = headerClass.getMethod("getDragType", Node.class); + setSystemButtonHeight = headerClass.getMethod("setSystemButtonHeight", Stage.class, double.class); draggable = Objects.requireNonNull(dragClass.getField("DRAGGABLE").get(null)); notDraggable = Objects.requireNonNull(dragClass.getField("NONE").get(null)); - light = Objects.requireNonNull(colorClass.getField("LIGHT").get(null)); - dark = Objects.requireNonNull(colorClass.getField("DARK").get(null)); headerBar = (Region) headerClass.getConstructor().newInstance(); } @@ -100,9 +99,15 @@ private NativeWindowDecoration() throws ReflectiveOperationException { /// Sets or removes the header content; the caller must first detach it from any other parent. /// + /// Configures native dragging on non-interactive descendants and observes later child additions. + /// Controls other than labels and nodes explicitly excluded from dragging retain ordinary input handling. + /// /// @param content the title-bar content, or `null` to detach it void setContent(@Nullable Node content) { try { + if (content != null) { + configureTitleDragging(content); + } setCenter.invoke(headerBar, content); } catch (ReflectiveOperationException e) { throw new IllegalStateException("Cannot update native header content", e); @@ -123,18 +128,52 @@ void setDraggable(Node node, boolean enabled) { } } - /// Keeps the platform window buttons synchronized with the launcher color scheme. + /// Configures native hit testing for title content, including subsequently added descendants. + /// + /// Containers and label graphics may move the window. Other controls and nodes explicitly excluded with + /// [#setDraggable(Node, boolean)] retain ordinary input handling, including all of their descendants. + /// + /// @param node the title content to configure + private void configureTitleDragging(Node node) { + if (node.getProperties().putIfAbsent(DRAG_CONFIGURED, Boolean.TRUE) != null) { + return; + } + try { + if (getDragType.invoke(null, node) == notDraggable) { + return; + } + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot read native header dragging", e); + } + if (node instanceof Control && !(node instanceof Label)) { + setDraggable(node, false); + return; + } + + // Native hit testing starts at the deepest picked node; marking only its parent is insufficient. + setDraggable(node, true); + if (node instanceof Parent parent) { + parent.getChildrenUnmodifiable().addListener((ListChangeListener) change -> { + while (change.next()) { + for (Node child : change.getAddedSubList()) { + configureTitleDragging(child); + } + } + }); + for (Node child : parent.getChildrenUnmodifiable()) { + configureTitleDragging(child); + } + } + } + + /// Hides platform-provided window buttons so the launcher can use its own controls. /// /// @param stage the extended stage to configure - @SuppressWarnings("unchecked") void configureStage(Stage stage) { try { - ObjectProperty colorScheme = (ObjectProperty) - Objects.requireNonNull(systemColorSchemeProperty.invoke(null, stage)); - colorScheme.bind(Bindings.createObjectBinding( - () -> Themes.darkModeProperty().get() ? dark : light, Themes.darkModeProperty())); + setSystemButtonHeight.invoke(null, stage, 0.0); } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Cannot configure native window buttons", e); + throw new IllegalStateException("Cannot hide native window buttons", e); } } } diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecorationTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecorationTest.java new file mode 100644 index 00000000000..de7c314b83e --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecorationTest.java @@ -0,0 +1,152 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.ui.decorator; + +import javafx.application.Platform; +import javafx.geometry.Point2D; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Rectangle; +import javafx.stage.Stage; +import org.jackhuang.hmcl.JavaFXLauncher; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.concurrent.Callable; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/// Exercises JavaFX's native header hit testing without requiring a desktop window manager. +@NotNullByDefault +@EnabledIf("supportsHeaderBar") +final class NativeWindowDecorationTest { + /// Requires a running toolkit and the header API, but not platform support for extended windows. + static boolean supportsHeaderBar() { + try { + Class.forName("javafx.scene.layout.HeaderBar"); + return JavaFXLauncher.isStarted() + && Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]) >= 27; + } catch (ClassNotFoundException e) { + return false; + } + } + + /// Verifies nested title content and label skins are draggable while interactive controls are excluded. + @Test + void picksNestedTitleContentAndControls() throws Exception { + onFxThread(() -> { + NativeWindowDecoration decoration = createDecoration(); + Rectangle icon = new Rectangle(20, 20); + Label title = new Label("Launcher title"); + HBox customTitle = new HBox(icon, title); + Button action = new Button("Action"); + Rectangle buttonGraphic = new Rectangle(12, 12); + action.setGraphic(buttonGraphic); + BorderPane content = new BorderPane(new StackPane(customTitle), null, action, null, null); + decoration.setContent(content); + StackPane root = new StackPane(decoration.headerBar); + Scene scene = new Scene(root, 800, 40); + layout(root); + + assertEquals("DRAGBAR", pick(scene, customTitle)); + assertEquals("DRAGBAR", pick(scene, icon)); + assertEquals("DRAGBAR", pick(scene, title)); + assertNotEquals("DRAGBAR", pick(scene, action)); + assertNotEquals("DRAGBAR", pick(scene, buttonGraphic)); + + // Reattachment and later content changes must preserve the hit-test policy. + decoration.setContent(null); + decoration.setContent(content); + Label addedTitle = new Label("Updated title"); + TextField input = new TextField("Editable title"); + StackPane excluded = new StackPane(new Label("Excluded")); + decoration.setDraggable(excluded, false); + customTitle.getChildren().setAll(addedTitle, input, excluded); + layout(root); + assertEquals("DRAGBAR", pick(scene, addedTitle)); + assertNotEquals("DRAGBAR", pick(scene, input)); + assertNotEquals("DRAGBAR", pick(scene, excluded)); + return null; + }); + } + + /// Verifies each newly attached stage opts out of the system-provided window buttons. + @Test + void hidesSystemButtons() throws Exception { + onFxThread(() -> { + NativeWindowDecoration decoration = createDecoration(); + Stage first = new Stage(); + Stage replacement = new Stage(); + decoration.configureStage(first); + decoration.configureStage(replacement); + Method getter = decoration.headerBar.getClass().getMethod("getSystemButtonHeight", Stage.class); + assertEquals(0.0, getter.invoke(null, first)); + assertEquals(0.0, getter.invoke(null, replacement)); + return null; + }); + } + + /// Resolves the real APIs even when the headless toolkit reports no native window decorations. + private static NativeWindowDecoration createDecoration() throws ReflectiveOperationException { + Constructor constructor = NativeWindowDecoration.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + /// Applies CSS so hit testing includes control skins, then lays out the header at a fixed size. + private static void layout(StackPane root) { + root.resize(800, 40); + root.applyCss(); + root.layout(); + } + + /// Uses the same header-area picker that the platform window toolkit calls. + /// + /// @return the native hit-test category at the node's center, or `null` for an ordinary client-area hit + private static @Nullable String pick(Scene scene, Node node) throws ReflectiveOperationException { + Class listenerClass = Class.forName("javafx.scene.Scene$ScenePeerListener"); + Constructor constructor = listenerClass.getDeclaredConstructor(Scene.class); + constructor.setAccessible(true); + Object listener = constructor.newInstance(scene); + Method pick = listenerClass.getDeclaredMethod("pickHeaderArea", double.class, double.class); + pick.setAccessible(true); + Point2D point = node.localToScene(node.getLayoutBounds().getWidth() / 2, node.getLayoutBounds().getHeight() / 2); + @Nullable Object result = pick.invoke(listener, point.getX(), point.getY()); + return result == null ? null : result.toString(); + } + + /// Runs a checked test action on the JavaFX application thread and propagates failures. + private static void onFxThread(Callable<@Nullable Void> action) throws Exception { + FutureTask<@Nullable Void> task = new FutureTask<>(action); + Platform.runLater(task); + task.get(10, TimeUnit.SECONDS); + } +} From 2e9fb4af106ec34e5707038a7845b62675b04d15 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 17 Sep 2026 21:49:23 +0800 Subject: [PATCH 3/5] Restore rounded content clipping for native window decoration Assisted-by: codex:gpt-6-astra --- .../java/org/jackhuang/hmcl/ui/decorator/Decorator.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java index 36f4c05bf14..9d7765dfd05 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java @@ -325,9 +325,9 @@ private Insets getResizeInsets() { /// /// @param edgeToEdge whether the attached stage is maximized or full-screen private void updateWindowDecoration(boolean edgeToEdge) { - edgeToEdge |= nativeDecorationEnabled; - root.setPadding(edgeToEdge ? Insets.EMPTY : SHADOW_INSETS); - shadowContainer.setEffect(edgeToEdge ? null : windowShadow); + boolean customShadow = !nativeDecorationEnabled && !edgeToEdge; + root.setPadding(customShadow ? SHADOW_INSETS : Insets.EMPTY); + shadowContainer.setEffect(customShadow ? windowShadow : null); mainWindowPane.setWindowEdgeToEdge(edgeToEdge); } From cec33f85e68e663423317953be7682c00244cae9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 17 Sep 2026 21:58:58 +0800 Subject: [PATCH 4/5] Delegate native window corners to the platform Assisted-by: codex:gpt-6-astra --- .../jackhuang/hmcl/ui/WindowsNativeUtils.java | 55 +++++++++++++++++++ .../hmcl/ui/decorator/Decorator.java | 8 +-- .../hmcl/ui/decorator/MainWindowPane.java | 12 ++-- .../ui/decorator/NativeWindowDecoration.java | 4 +- .../util/platform/windows/WinConstants.java | 6 ++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java index 0f691993f6d..39b7d529fac 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.ui; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; import javafx.stage.Stage; import javafx.stage.Window; import javafx.stage.WindowEvent; @@ -24,9 +26,12 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.NativeUtils; +import org.jackhuang.hmcl.util.platform.OSVersion; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.windows.Dwmapi; import org.jackhuang.hmcl.util.platform.windows.IPropertyStore; import org.jackhuang.hmcl.util.platform.windows.Shell32; +import org.jackhuang.hmcl.util.platform.windows.WinConstants; import org.jackhuang.hmcl.util.platform.windows.WinTypes; import org.jetbrains.annotations.Nullable; @@ -42,6 +47,56 @@ /// @author Glavo public final class WindowsNativeUtils { + /// Marks stages that already reapply their rounded-corner preference after each showing. + private static final Object ROUNDED_CORNERS_INSTALLED = new Object(); + + /// Requests system-rounded corners for a non-transparent window on Windows 11 or later. + /// + /// The preference is applied immediately if the stage is showing and after every subsequent showing, + /// including when JavaFX recreates its native window. Repeated installation on the same stage has no effect. + /// Unsupported systems and unavailable native access leave the platform's default outline unchanged. + /// DWM may ignore the request according to window state and system policy; failures are logged. + /// + /// @param stage the stage to configure on the JavaFX application thread + public static void installRoundedWindowCorners(Stage stage) { + if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) || !NativeUtils.USE_JNA) { + return; + } + @Nullable Dwmapi dwmapi = Dwmapi.INSTANCE; + if (dwmapi == null || stage.getProperties().putIfAbsent(ROUNDED_CORNERS_INSTALLED, Boolean.TRUE) != null) { + return; + } + + stage.addEventHandler(WindowEvent.WINDOW_SHOWN, event -> applyRoundedWindowCorners(stage, dwmapi)); + if (stage.isShowing()) { + applyRoundedWindowCorners(stage, dwmapi); + } + } + + /// Applies the rounded-corner preference to the current native window without changing its client area. + /// + /// @param stage the visible stage whose native handle is used + /// @param dwmapi the available DWM library + private static void applyRoundedWindowCorners(Stage stage, Dwmapi dwmapi) { + try { + OptionalLong handle = getWindowHandle(stage); + if (handle.isEmpty() || handle.getAsLong() == 0 || handle.getAsLong() == WinTypes.HANDLE.INVALID_VALUE) { + return; + } + + int result = dwmapi.DwmSetWindowAttribute( + new WinTypes.HANDLE(Pointer.createConstant(handle.getAsLong())), + WinConstants.DWMWA_WINDOW_CORNER_PREFERENCE, + new IntByReference(WinConstants.DWMWCP_ROUND), + Integer.BYTES); + if (result < 0) { + LOG.warning("Failed to request native rounded corners: HRESULT 0x" + Integer.toHexString(result)); + } + } catch (RuntimeException | LinkageError e) { + LOG.warning("Failed to request native rounded corners", e); + } + } + public static OptionalLong getWindowHandle(Stage stage) { try { Class windowStageClass = Class.forName("com.sun.javafx.tk.quantum.WindowStage"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java index 9d7765dfd05..cf4669cf631 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java @@ -325,10 +325,10 @@ private Insets getResizeInsets() { /// /// @param edgeToEdge whether the attached stage is maximized or full-screen private void updateWindowDecoration(boolean edgeToEdge) { - boolean customShadow = !nativeDecorationEnabled && !edgeToEdge; - root.setPadding(customShadow ? SHADOW_INSETS : Insets.EMPTY); - shadowContainer.setEffect(customShadow ? windowShadow : null); - mainWindowPane.setWindowEdgeToEdge(edgeToEdge); + boolean customDecoration = !nativeDecorationEnabled && !edgeToEdge; + root.setPadding(customDecoration ? SHADOW_INSETS : Insets.EMPTY); + shadowContainer.setEffect(customDecoration ? windowShadow : null); + mainWindowPane.setWindowCornersRounded(customDecoration); } /// Returns the pane on which application dialogs are stacked. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java index 1ddfddf0427..5f359896142 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/MainWindowPane.java @@ -63,7 +63,7 @@ final class MainWindowPane extends StackPane { /// 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. + /// The content clip, rounded only for normal windows with custom decoration. private final Rectangle clip = new Rectangle(); /// The frame containing the title bar and current navigation page. @@ -154,11 +154,13 @@ void setNativeDecoration(@Nullable NativeWindowDecoration decoration) { } } - /// Updates the content-corner shape for an edge-to-edge window state. + /// Enables or disables custom rounding of the content corners. /// - /// @param edgeToEdge whether the attached window is maximized or full-screen - void setWindowEdgeToEdge(boolean edgeToEdge) { - double arc = edgeToEdge ? 0.0 : ARC; + /// Native decoration uses square content bounds so the platform can shape the window outline. + /// + /// @param rounded whether to round the content corners + void setWindowCornersRounded(boolean rounded) { + double arc = rounded ? ARC : 0.0; clip.setArcWidth(arc); clip.setArcHeight(arc); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java index e80bfad78d1..bee4baab398 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java @@ -27,6 +27,7 @@ import javafx.scene.layout.Region; import javafx.stage.Stage; import javafx.stage.StageStyle; +import org.jackhuang.hmcl.ui.WindowsNativeUtils; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -166,7 +167,7 @@ private void configureTitleDragging(Node node) { } } - /// Hides platform-provided window buttons so the launcher can use its own controls. + /// Hides platform-provided window buttons and requests native rounded corners on Windows 11. /// /// @param stage the extended stage to configure void configureStage(Stage stage) { @@ -175,5 +176,6 @@ void configureStage(Stage stage) { } catch (ReflectiveOperationException e) { throw new IllegalStateException("Cannot hide native window buttons", e); } + WindowsNativeUtils.installRoundedWindowCorners(stage); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java index 7fa05d9c62a..e4530133052 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java @@ -116,6 +116,12 @@ public interface WinConstants { // https://learn.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + /// Selects the window's DWM rounded-corner preference on Windows 11 and later. + int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + + /// Requests rounded window corners when permitted by DWM policy. + int DWMWCP_ROUND = 2; + // https://learn.microsoft.com/windows/win32/api/winreg/nf-winreg-regcreatekeyexw int REG_OPTION_NON_VOLATILE = 0x0000; } From e2802193ef41780aad0854fdb3d5fa8ee2556264 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 17 Sep 2026 23:38:44 +0800 Subject: [PATCH 5/5] Restrict extended windows to Windows 11 and macOS and remove custom DWM corner settings Assisted-by: codex:gpt-6-astra --- .../jackhuang/hmcl/ui/WindowsNativeUtils.java | 55 ------------------- .../ui/decorator/NativeWindowDecoration.java | 14 +++-- .../util/platform/windows/WinConstants.java | 6 -- 3 files changed, 9 insertions(+), 66 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java index 39b7d529fac..0f691993f6d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java @@ -17,8 +17,6 @@ */ package org.jackhuang.hmcl.ui; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.IntByReference; import javafx.stage.Stage; import javafx.stage.Window; import javafx.stage.WindowEvent; @@ -26,12 +24,9 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.NativeUtils; -import org.jackhuang.hmcl.util.platform.OSVersion; import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.windows.Dwmapi; import org.jackhuang.hmcl.util.platform.windows.IPropertyStore; import org.jackhuang.hmcl.util.platform.windows.Shell32; -import org.jackhuang.hmcl.util.platform.windows.WinConstants; import org.jackhuang.hmcl.util.platform.windows.WinTypes; import org.jetbrains.annotations.Nullable; @@ -47,56 +42,6 @@ /// @author Glavo public final class WindowsNativeUtils { - /// Marks stages that already reapply their rounded-corner preference after each showing. - private static final Object ROUNDED_CORNERS_INSTALLED = new Object(); - - /// Requests system-rounded corners for a non-transparent window on Windows 11 or later. - /// - /// The preference is applied immediately if the stage is showing and after every subsequent showing, - /// including when JavaFX recreates its native window. Repeated installation on the same stage has no effect. - /// Unsupported systems and unavailable native access leave the platform's default outline unchanged. - /// DWM may ignore the request according to window state and system policy; failures are logged. - /// - /// @param stage the stage to configure on the JavaFX application thread - public static void installRoundedWindowCorners(Stage stage) { - if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) || !NativeUtils.USE_JNA) { - return; - } - @Nullable Dwmapi dwmapi = Dwmapi.INSTANCE; - if (dwmapi == null || stage.getProperties().putIfAbsent(ROUNDED_CORNERS_INSTALLED, Boolean.TRUE) != null) { - return; - } - - stage.addEventHandler(WindowEvent.WINDOW_SHOWN, event -> applyRoundedWindowCorners(stage, dwmapi)); - if (stage.isShowing()) { - applyRoundedWindowCorners(stage, dwmapi); - } - } - - /// Applies the rounded-corner preference to the current native window without changing its client area. - /// - /// @param stage the visible stage whose native handle is used - /// @param dwmapi the available DWM library - private static void applyRoundedWindowCorners(Stage stage, Dwmapi dwmapi) { - try { - OptionalLong handle = getWindowHandle(stage); - if (handle.isEmpty() || handle.getAsLong() == 0 || handle.getAsLong() == WinTypes.HANDLE.INVALID_VALUE) { - return; - } - - int result = dwmapi.DwmSetWindowAttribute( - new WinTypes.HANDLE(Pointer.createConstant(handle.getAsLong())), - WinConstants.DWMWA_WINDOW_CORNER_PREFERENCE, - new IntByReference(WinConstants.DWMWCP_ROUND), - Integer.BYTES); - if (result < 0) { - LOG.warning("Failed to request native rounded corners: HRESULT 0x" + Integer.toHexString(result)); - } - } catch (RuntimeException | LinkageError e) { - LOG.warning("Failed to request native rounded corners", e); - } - } - public static OptionalLong getWindowHandle(Stage stage) { try { Class windowStageClass = Class.forName("com.sun.javafx.tk.quantum.WindowStage"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java index bee4baab398..cf742b1f9fb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/NativeWindowDecoration.java @@ -27,7 +27,8 @@ import javafx.scene.layout.Region; import javafx.stage.Stage; import javafx.stage.StageStyle; -import org.jackhuang.hmcl.ui.WindowsNativeUtils; +import org.jackhuang.hmcl.util.platform.OSVersion; +import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -82,10 +83,14 @@ private NativeWindowDecoration() throws ReflectiveOperationException { headerBar = (Region) headerClass.getConstructor().newInstance(); } - /// Creates native decoration support on JavaFX 27 or later when supported by the platform. + /// Creates native decoration support on macOS or Windows 11 and later when JavaFX 27 or later supports it. /// - /// @return the resolved support, or `null` on older or unsupported runtimes + /// @return the resolved support, or `null` on other platforms or unsupported runtimes static @Nullable NativeWindowDecoration create() { + if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS + && !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11)) { + return null; + } try { int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]); if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) { @@ -167,7 +172,7 @@ private void configureTitleDragging(Node node) { } } - /// Hides platform-provided window buttons and requests native rounded corners on Windows 11. + /// Hides platform-provided window buttons. /// /// @param stage the extended stage to configure void configureStage(Stage stage) { @@ -176,6 +181,5 @@ void configureStage(Stage stage) { } catch (ReflectiveOperationException e) { throw new IllegalStateException("Cannot hide native window buttons", e); } - WindowsNativeUtils.installRoundedWindowCorners(stage); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java index e4530133052..7fa05d9c62a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/windows/WinConstants.java @@ -116,12 +116,6 @@ public interface WinConstants { // https://learn.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; - /// Selects the window's DWM rounded-corner preference on Windows 11 and later. - int DWMWA_WINDOW_CORNER_PREFERENCE = 33; - - /// Requests rounded window corners when permitted by DWM policy. - int DWMWCP_ROUND = 2; - // https://learn.microsoft.com/windows/win32/api/winreg/nf-winreg-regcreatekeyexw int REG_OPTION_NON_VOLATILE = 0x0000; }