|
| 1 | +/* |
| 2 | + * Copyright (c) Forge Development LLC |
| 3 | + * SPDX-License-Identifier: LGPL-2.1-only |
| 4 | + */ |
| 5 | +package net.minecraftforge.installer; |
| 6 | + |
| 7 | +import java.awt.Color; |
| 8 | +import java.awt.Component; |
| 9 | +import java.awt.Container; |
| 10 | +import java.awt.Dialog; |
| 11 | +import java.awt.event.WindowAdapter; |
| 12 | +import java.awt.event.WindowEvent; |
| 13 | +import java.lang.foreign.Arena; |
| 14 | +import java.lang.foreign.FunctionDescriptor; |
| 15 | +import java.lang.foreign.Linker; |
| 16 | +import java.lang.foreign.MemorySegment; |
| 17 | +import java.lang.foreign.SymbolLookup; |
| 18 | +import java.lang.foreign.ValueLayout; |
| 19 | +import java.lang.invoke.MethodHandle; |
| 20 | +import java.util.Locale; |
| 21 | +import java.util.concurrent.Callable; |
| 22 | + |
| 23 | +import javax.swing.JComponent; |
| 24 | +import javax.swing.JDialog; |
| 25 | +import javax.swing.JOptionPane; |
| 26 | +import javax.swing.JPanel; |
| 27 | +import javax.swing.JRadioButton; |
| 28 | + |
| 29 | +final class Win11MicaEffect { |
| 30 | + private static final Color TRANSPARENT = new Color(220, 220, 220, 0); |
| 31 | + private static final Color WINDOW_BACKGROUND = new Color(220, 220, 220, 1); |
| 32 | + |
| 33 | + private Win11MicaEffect() {} |
| 34 | + |
| 35 | + /// Swing paints an opaque background by default for all components, which hides the Mica backdrop effect. |
| 36 | + /// This method recursively undoes that so that DWM is responsible for painting the window background. |
| 37 | + static void prepare(Component component) { |
| 38 | + if (isUnsupportedPlatform()) |
| 39 | + return; |
| 40 | + |
| 41 | + if (component instanceof JPanel || component instanceof JOptionPane || component instanceof JRadioButton) { |
| 42 | + JComponent jComponent = (JComponent) component; |
| 43 | + jComponent.setOpaque(false); |
| 44 | + jComponent.setBackground(TRANSPARENT); |
| 45 | + } |
| 46 | + |
| 47 | + if (component instanceof Container container) { |
| 48 | + for (Component child : container.getComponents()) { |
| 49 | + prepare(child); |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + static void install(JDialog dialog) throws Exception { |
| 55 | + if (isUnsupportedPlatform()) |
| 56 | + return; |
| 57 | + |
| 58 | + dialog.getRootPane().setOpaque(false); |
| 59 | + dialog.getLayeredPane().setOpaque(false); |
| 60 | + if (dialog.getContentPane() instanceof JComponent contentPane) { |
| 61 | + contentPane.setOpaque(false); |
| 62 | + contentPane.setBackground(TRANSPARENT); |
| 63 | + } |
| 64 | + prepare(dialog.getRootPane()); |
| 65 | + |
| 66 | + dialog.setBackground(WINDOW_BACKGROUND); |
| 67 | + |
| 68 | + // In order for the Mica effect to apply correctly on a Swing window, we need to ensure a repaint after applying |
| 69 | + // for timing reasons. If the window is already showing, we can repaint immediately, otherwise wait for the |
| 70 | + // window to be opened first. |
| 71 | + Callable<Void> apply = () -> { |
| 72 | + try { |
| 73 | + applyTo(dialog); |
| 74 | + } catch (Throwable t) { |
| 75 | + if (t instanceof Exception e) throw e; |
| 76 | + else throw new RuntimeException(t); |
| 77 | + } |
| 78 | + dialog.invalidate(); |
| 79 | + dialog.validate(); |
| 80 | + dialog.repaint(); |
| 81 | + dialog.getRootPane().repaint(); |
| 82 | + return null; |
| 83 | + }; |
| 84 | + |
| 85 | + if (dialog.isShowing()) { |
| 86 | + apply.call(); |
| 87 | + return; |
| 88 | + } |
| 89 | + |
| 90 | + dialog.addWindowListener(new WindowAdapter() { |
| 91 | + @Override |
| 92 | + public void windowOpened(WindowEvent e) { |
| 93 | + dialog.removeWindowListener(this); |
| 94 | + try { |
| 95 | + apply.call(); |
| 96 | + } catch (Exception ex) { |
| 97 | + throw new RuntimeException(ex); |
| 98 | + } |
| 99 | + } |
| 100 | + }); |
| 101 | + } |
| 102 | + |
| 103 | + private static void applyTo(Dialog dialog) throws Throwable { |
| 104 | + if (!dialog.isDisplayable()) |
| 105 | + return; |
| 106 | + |
| 107 | + var linker = Linker.nativeLinker(); |
| 108 | + try (Arena arena = Arena.ofConfined()) { |
| 109 | + // Lookup the necessary Windows APIs |
| 110 | + SymbolLookup user32 = SymbolLookup.libraryLookup("user32", arena); |
| 111 | + SymbolLookup dwmapi = SymbolLookup.libraryLookup("dwmapi", arena); |
| 112 | + |
| 113 | + MemorySegment hwnd = findDialogWindow(dialog, arena, user32); |
| 114 | + if (MemorySegment.NULL.equals(hwnd)) |
| 115 | + return; |
| 116 | + |
| 117 | + MethodHandle setWindowPos = linker.downcallHandle( |
| 118 | + user32.find("SetWindowPos").orElseThrow(), |
| 119 | + FunctionDescriptor.of( |
| 120 | + ValueLayout.JAVA_INT, |
| 121 | + ValueLayout.ADDRESS, |
| 122 | + ValueLayout.ADDRESS, |
| 123 | + ValueLayout.JAVA_INT, |
| 124 | + ValueLayout.JAVA_INT, |
| 125 | + ValueLayout.JAVA_INT, |
| 126 | + ValueLayout.JAVA_INT, |
| 127 | + ValueLayout.JAVA_INT |
| 128 | + ) |
| 129 | + ); |
| 130 | + MethodHandle dwmSetWindowAttribute = linker.downcallHandle( |
| 131 | + dwmapi.find("DwmSetWindowAttribute").orElseThrow(), |
| 132 | + FunctionDescriptor.of( |
| 133 | + ValueLayout.JAVA_INT, |
| 134 | + ValueLayout.ADDRESS, |
| 135 | + ValueLayout.JAVA_INT, |
| 136 | + ValueLayout.ADDRESS, |
| 137 | + ValueLayout.JAVA_INT |
| 138 | + ) |
| 139 | + ); |
| 140 | + MethodHandle dwmExtendFrameIntoClientArea = linker.downcallHandle( |
| 141 | + dwmapi.find("DwmExtendFrameIntoClientArea").orElseThrow(), |
| 142 | + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS) |
| 143 | + ); |
| 144 | + |
| 145 | + promoteToTaskbarWindow(hwnd, user32, setWindowPos); |
| 146 | + |
| 147 | + // Win11 defaults to a rounded corner preference for decorated windows, but for backwards-compatibility it |
| 148 | + // defaults to sharp corners for undecorated windows. This tells Win11 that we want rounded corners for our |
| 149 | + // undecorated dialog window |
| 150 | + MemorySegment cornerPreference = arena.allocate(ValueLayout.JAVA_INT); |
| 151 | + cornerPreference.set(ValueLayout.JAVA_INT, 0, 2); // DWMWCP_ROUND |
| 152 | + int DWMWA_WINDOW_CORNER_PREFERENCE = 33; |
| 153 | + int _ = (int) dwmSetWindowAttribute.invokeExact(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, cornerPreference, Integer.BYTES); |
| 154 | + |
| 155 | + // Tell DWM that we'd like to opt-into the main window backdrop type rather than the backwards-compatible |
| 156 | + // default of no backdrop effect. This is the main bit that actually enables the Mica backdrop effect - the |
| 157 | + // rest of the code is mostly workarounds for Swing limitations |
| 158 | + MemorySegment backdropType = arena.allocate(ValueLayout.JAVA_INT); |
| 159 | + // 2 = Mica (DWMSBT_MAINWINDOW), 3 = Acrylic (DWMSBT_TRANSIENTWINDOW), 4 = Mica Alt (DWMSBT_TABBEDWINDOW) |
| 160 | + int backdropTypePreference = Integer.getInteger("forgeinstaller.backdroptype", 2); |
| 161 | + backdropType.set(ValueLayout.JAVA_INT, 0, backdropTypePreference); |
| 162 | + int DWMWA_SYSTEMBACKDROP_TYPE = 38; |
| 163 | + int hresult = (int) dwmSetWindowAttribute.invokeExact(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, backdropType, Integer.BYTES); |
| 164 | + if (hresult != 0) |
| 165 | + throw new RuntimeException("DwmSetWindowAttribute failed with HRESULT 0x" + Integer.toHexString(hresult)); |
| 166 | + |
| 167 | + // Tell DWM to paint the entire window background with the Mica brush rather than the default of only the |
| 168 | + // title bar. |
| 169 | + MemorySegment margins = arena.allocate(4L * Integer.BYTES, Integer.BYTES); |
| 170 | + margins.set(ValueLayout.JAVA_INT, 0L, -1); |
| 171 | + margins.set(ValueLayout.JAVA_INT, Integer.BYTES, -1); |
| 172 | + margins.set(ValueLayout.JAVA_INT, 2L * Integer.BYTES, -1); |
| 173 | + margins.set(ValueLayout.JAVA_INT, 3L * Integer.BYTES, -1); |
| 174 | + int _ = (int) dwmExtendFrameIntoClientArea.invokeExact(hwnd, margins); |
| 175 | + |
| 176 | + // Comply with the remarks in Windows' API docs to ensure that the style changes apply |
| 177 | + refreshWindowFrame(hwnd, setWindowPos); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + /// Attempt to find the window handle (HWND) of the dialog window by querying for a window with its title first, |
| 182 | + /// falling back to the foreground window if no window with this dialog's window title is found. |
| 183 | + private static MemorySegment findDialogWindow(Dialog dialog, Arena arena, SymbolLookup user32) throws Throwable { |
| 184 | + var linker = Linker.nativeLinker(); |
| 185 | + MethodHandle findWindowW = linker.downcallHandle( |
| 186 | + user32.find("FindWindowW").orElseThrow(), |
| 187 | + FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS, ValueLayout.ADDRESS) |
| 188 | + ); |
| 189 | + |
| 190 | + String title = dialog.getTitle(); |
| 191 | + if (title != null && !title.isBlank()) { |
| 192 | + MemorySegment windowTitle = arena.allocateFrom(ValueLayout.JAVA_CHAR, (title + '\0').toCharArray()); |
| 193 | + MemorySegment hwnd = (MemorySegment) findWindowW.invokeExact(MemorySegment.NULL, windowTitle); |
| 194 | + if (!MemorySegment.NULL.equals(hwnd)) |
| 195 | + return hwnd; |
| 196 | + } |
| 197 | + |
| 198 | + MethodHandle getForegroundWindow = linker.downcallHandle( |
| 199 | + user32.find("GetForegroundWindow").orElseThrow(), |
| 200 | + FunctionDescriptor.of(ValueLayout.ADDRESS) |
| 201 | + ); |
| 202 | + |
| 203 | + return (MemorySegment) getForegroundWindow.invokeExact(); |
| 204 | + } |
| 205 | + |
| 206 | + /// Ensures that the dialog box is visible in the taskbar by removing the tool window style and adding the app |
| 207 | + /// window style. This is a workaround for Swing hiding the dialog box from the taskbar when undecorated and |
| 208 | + /// non-opaque due to it expecting that the window is invisible, however in practice it is visible as DWM will draw |
| 209 | + /// the window background for us which is what we want for the Mica effect to be visible. |
| 210 | + /// @see [#prepare(Component) |
| 211 | + private static void promoteToTaskbarWindow(MemorySegment hwnd, SymbolLookup user32, MethodHandle setWindowPos) throws Throwable { |
| 212 | + var linker = Linker.nativeLinker(); |
| 213 | + MethodHandle getWindowLongW = linker.downcallHandle( |
| 214 | + user32.find("GetWindowLongW").orElseThrow(), |
| 215 | + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT) |
| 216 | + ); |
| 217 | + MethodHandle setWindowLongW = linker.downcallHandle( |
| 218 | + user32.find("SetWindowLongW").orElseThrow(), |
| 219 | + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT) |
| 220 | + ); |
| 221 | + |
| 222 | + int WS_EX_TOOLWINDOW = 0x00000080; |
| 223 | + int WS_EX_APPWINDOW = 0x00040000; |
| 224 | + int GWL_EXSTYLE = -20; |
| 225 | + int exStyle = (int) getWindowLongW.invokeExact(hwnd, GWL_EXSTYLE); |
| 226 | + int newExStyle = (exStyle | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW; |
| 227 | + if (newExStyle == exStyle) |
| 228 | + return; |
| 229 | + |
| 230 | + int _ = (int) setWindowLongW.invokeExact(hwnd, GWL_EXSTYLE, newExStyle); |
| 231 | + refreshWindowFrame(hwnd, setWindowPos); |
| 232 | + } |
| 233 | + |
| 234 | + /// The [docs](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowpos#remarks) mention |
| 235 | + /// that we should call SetWindowPos with the SWP_FRAMECHANGED flag after calling SetWindowLong to ensure that |
| 236 | + /// style updates apply on Windows Vista onwards. This is done after the DWM calls just in case those count too. |
| 237 | + /// @see [#promoteToTaskbarWindow(MemorySegment, SymbolLookup, MethodHandle) |
| 238 | + private static void refreshWindowFrame(MemorySegment hwnd, MethodHandle setWindowPos) throws Throwable { |
| 239 | + // SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED; |
| 240 | + int flags = 0x0001 | 0x0002 | 0x0004 | 0x0010 | 0x0020; |
| 241 | + int _ = (int) setWindowPos.invokeExact(hwnd, MemorySegment.NULL, 0, 0, 0, 0, flags); |
| 242 | + } |
| 243 | + |
| 244 | + /// @return true if the current platform is Windows 11 |
| 245 | + static boolean isSupported() { |
| 246 | + return !isUnsupportedPlatform(); |
| 247 | + } |
| 248 | + |
| 249 | + private static boolean isUnsupportedPlatform() { |
| 250 | + final class LazyInit { |
| 251 | + private LazyInit() {} |
| 252 | + private static final boolean IS_UNSUPPORTED; |
| 253 | + static { |
| 254 | + var useMica = true; |
| 255 | + boolean forceDisableMica = false; |
| 256 | + var isWin11 = System.getProperty("os.name", "").toLowerCase(Locale.ENGLISH).startsWith("windows 11"); |
| 257 | + if (isWin11) { |
| 258 | + // Disable Mica if explicitly requested |
| 259 | + forceDisableMica = !Boolean.parseBoolean(System.getProperty("forgeinstaller.usewin11mica", "true")); |
| 260 | + if (forceDisableMica) |
| 261 | + useMica = false; |
| 262 | + } else { |
| 263 | + // Mica is only supported on Windows 11 |
| 264 | + useMica = false; |
| 265 | + } |
| 266 | + |
| 267 | +// System.out.println("OS: " + System.getProperty("os.name", "") + ", isWin11: " + isWin11 + ", forceDisableMica: " + forceDisableMica); |
| 268 | + IS_UNSUPPORTED = !useMica; |
| 269 | + } |
| 270 | + } |
| 271 | + return LazyInit.IS_UNSUPPORTED; |
| 272 | + } |
| 273 | +} |
0 commit comments