Skip to content

Commit 41db83b

Browse files
authored
Merge pull request #184 from kdroidFilter/fix/windows-minmax-size-dpi
fix(decorated-window-jni): apply DPI scaling to window min/max size on Windows
2 parents 7650c1e + edff91e commit 41db83b

6 files changed

Lines changed: 162 additions & 0 deletions

File tree

decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.Windows.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ private fun DecoratedWindowScope.NativeWindowsTitleBar(
9999
}
100100
}
101101

102+
// Fix DPI scaling for min/max size on non-JBR JVMs (issue #102)
103+
SyncMinMaxSizeToNative(window)
104+
102105
val useNewFullscreenControls = modifier.hasNewFullscreenControls()
103106

104107
// ── Fullscreen with newFullscreenControls: sliding overlay ──
@@ -238,3 +241,40 @@ private fun DecoratedWindowScope.FallbackWindowsTitleBar(
238241
content(currentState)
239242
}
240243
}
244+
245+
/**
246+
* Syncs [java.awt.Window.minimumSize] and [java.awt.Window.maximumSize] to the native
247+
* WM_GETMINMAXINFO handler so that DPI scaling is applied correctly on non-JBR JVMs.
248+
*/
249+
@Suppress("FunctionNaming")
250+
@Composable
251+
private fun SyncMinMaxSizeToNative(window: java.awt.Window) {
252+
DisposableEffect(window) {
253+
val hwnd = JniWindowsWindowUtil.getHwnd(window)
254+
if (hwnd != 0L) {
255+
val syncSizes = {
256+
val min = window.minimumSize
257+
JniWindowsDecorationBridge.nativeSetMinimumSize(hwnd, min.width, min.height)
258+
val max = window.maximumSize
259+
val maxW = if (max.width < Short.MAX_VALUE) max.width else 0
260+
val maxH = if (max.height < Short.MAX_VALUE) max.height else 0
261+
JniWindowsDecorationBridge.nativeSetMaximumSize(hwnd, maxW, maxH)
262+
}
263+
syncSizes()
264+
val propertyListener =
265+
java.beans.PropertyChangeListener { evt ->
266+
if (evt.propertyName == "minimumSize" || evt.propertyName == "maximumSize") {
267+
syncSizes()
268+
}
269+
}
270+
window.addPropertyChangeListener(propertyListener)
271+
onDispose {
272+
window.removePropertyChangeListener(propertyListener)
273+
JniWindowsDecorationBridge.nativeSetMinimumSize(hwnd, 0, 0)
274+
JniWindowsDecorationBridge.nativeSetMaximumSize(hwnd, 0, 0)
275+
}
276+
} else {
277+
onDispose { }
278+
}
279+
}
280+
}

decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/utils/windows/JniWindowsDecorationBridge.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@ internal object JniWindowsDecorationBridge {
7373
argb: Int,
7474
)
7575

76+
// Sets the minimum window size in logical pixels. The native
77+
// WM_GETMINMAXINFO handler applies DPI scaling automatically.
78+
// Pass (0, 0) to disable the override and fall back to AWT default.
79+
@JvmStatic
80+
external fun nativeSetMinimumSize(
81+
hwnd: Long,
82+
widthPx: Int,
83+
heightPx: Int,
84+
)
85+
86+
// Sets the maximum window size in logical pixels. The native
87+
// WM_GETMINMAXINFO handler applies DPI scaling automatically.
88+
// Pass (0, 0) to disable the override and fall back to AWT default.
89+
@JvmStatic
90+
external fun nativeSetMaximumSize(
91+
hwnd: Long,
92+
widthPx: Int,
93+
heightPx: Int,
94+
)
95+
7696
// Returns debug counters as a string (temporary).
7797
@JvmStatic
7898
external fun nativeGetDebugInfo(hwnd: Long): String

decorated-window-jni/src/main/native/windows/nucleus_windows_decoration.c

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ typedef struct {
9393
LONG savedStyle;
9494
LONG savedExStyle;
9595
WINDOWPLACEMENT savedPlacement;
96+
/* Min/max size override (logical pixels, 0 = not set) */
97+
POINT minSizePx;
98+
POINT maxSizePx;
9699
/* Debug counters */
97100
int hitTestCount;
98101
int hitTestCaption;
@@ -418,6 +421,31 @@ static LRESULT CALLBACK decorationWndProc(
418421
break; /* also let original handle it */
419422
}
420423

424+
/* -------------------------------------------------------------- */
425+
/* WM_GETMINMAXINFO: fix DPI scaling for min/max size. */
426+
/* Standard OpenJDK stores logical pixels in ptMinTrackSize but */
427+
/* Windows expects physical pixels. JBR applies ScaleUpX/Y; */
428+
/* we replicate that fix here so it works on all JVMs. */
429+
/* -------------------------------------------------------------- */
430+
case WM_GETMINMAXINFO: {
431+
/* Let AWT process first (sets unscaled values) */
432+
LRESULT result = CallWindowProcW(state->originalWndProc,
433+
hwnd, msg, wParam, lParam);
434+
LPMINMAXINFO lpmmi = (LPMINMAXINFO)lParam;
435+
UINT dpi = getDpi(hwnd);
436+
437+
/* Override with DPI-scaled values if set (per-axis) */
438+
if (state->minSizePx.x > 0)
439+
lpmmi->ptMinTrackSize.x = MulDiv(state->minSizePx.x, dpi, 96);
440+
if (state->minSizePx.y > 0)
441+
lpmmi->ptMinTrackSize.y = MulDiv(state->minSizePx.y, dpi, 96);
442+
if (state->maxSizePx.x > 0)
443+
lpmmi->ptMaxTrackSize.x = MulDiv(state->maxSizePx.x, dpi, 96);
444+
if (state->maxSizePx.y > 0)
445+
lpmmi->ptMaxTrackSize.y = MulDiv(state->maxSizePx.y, dpi, 96);
446+
return result;
447+
}
448+
421449
/* -------------------------------------------------------------- */
422450
/* WM_SYSCOMMAND: block state-changing commands while fullscreen */
423451
/* to prevent native/Kotlin state desync. The application must */
@@ -1032,3 +1060,43 @@ Java_io_github_kdroidfilter_nucleus_window_utils_windows_JniWindowsDecorationBri
10321060
}
10331061
return (*env)->NewStringUTF(env, buf);
10341062
}
1063+
1064+
/* -------------------------------------------------------------- */
1065+
/* nativeSetMinimumSize(long hwnd, int widthPx, int heightPx) */
1066+
/* Stores the minimum window size in logical pixels. The */
1067+
/* WM_GETMINMAXINFO handler applies DPI scaling automatically. */
1068+
/* Pass (0, 0) to disable the override and fall back to AWT. */
1069+
/* -------------------------------------------------------------- */
1070+
JNIEXPORT void JNICALL
1071+
Java_io_github_kdroidfilter_nucleus_window_utils_windows_JniWindowsDecorationBridge_nativeSetMinimumSize(
1072+
JNIEnv *env, jclass clazz, jlong hwndLong, jint widthPx, jint heightPx)
1073+
{
1074+
HWND hwnd = (HWND)(uintptr_t)hwndLong;
1075+
if (!hwnd) return;
1076+
1077+
DecoState *state = getState(hwnd);
1078+
if (!state) return;
1079+
1080+
state->minSizePx.x = (LONG)widthPx;
1081+
state->minSizePx.y = (LONG)heightPx;
1082+
}
1083+
1084+
/* -------------------------------------------------------------- */
1085+
/* nativeSetMaximumSize(long hwnd, int widthPx, int heightPx) */
1086+
/* Stores the maximum window size in logical pixels. The */
1087+
/* WM_GETMINMAXINFO handler applies DPI scaling automatically. */
1088+
/* Pass (0, 0) to disable the override and fall back to AWT. */
1089+
/* -------------------------------------------------------------- */
1090+
JNIEXPORT void JNICALL
1091+
Java_io_github_kdroidfilter_nucleus_window_utils_windows_JniWindowsDecorationBridge_nativeSetMaximumSize(
1092+
JNIEnv *env, jclass clazz, jlong hwndLong, jint widthPx, jint heightPx)
1093+
{
1094+
HWND hwnd = (HWND)(uintptr_t)hwndLong;
1095+
if (!hwnd) return;
1096+
1097+
DecoState *state = getState(hwnd);
1098+
if (!state) return;
1099+
1100+
state->maxSizePx.x = (LONG)widthPx;
1101+
state->maxSizePx.y = (LONG)heightPx;
1102+
}

decorated-window-jni/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.decorated-window-jni/reachability-metadata.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,28 @@
1212
]
1313
}
1414
]
15+
},
16+
{
17+
"type": "io.github.kdroidfilter.nucleus.window.utils.windows.JniWindowsDecorationBridge",
18+
"jniAccessible": true,
19+
"methods": [
20+
{
21+
"name": "nativeSetMinimumSize",
22+
"parameterTypes": [
23+
"long",
24+
"int",
25+
"int"
26+
]
27+
},
28+
{
29+
"name": "nativeSetMaximumSize",
30+
"parameterTypes": [
31+
"long",
32+
"int",
33+
"int"
34+
]
35+
}
36+
]
1537
}
1638
]
1739
}

docs/runtime/decorated-window.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ This module does not depend on JBR, making it compatible with **any JVM** (OpenJ
5858
!!! info "macOS: Liquid Glass and Xcode 26 appearance"
5959
Nucleus automatically patches the application launcher's `LC_BUILD_VERSION` to macOS SDK 26.0 via `vtool`, enabling Liquid Glass window decorations (larger traffic lights, rounded corners). This works with **any JDK** — a JDK compiled with Xcode 26 is no longer required. See [macOS 26 Window Appearance](../targets/macos.md#macos-26-window-appearance-liquid-glass) for details and configuration options.
6060

61+
!!! note "Windows: DPI-aware minimum and maximum window size"
62+
On non-JBR JVMs (OpenJDK, GraalVM), `Window.minimumSize` and `Window.maximumSize` are stored in logical pixels but Windows expects physical pixels in `WM_GETMINMAXINFO`. This causes the enforced min/max size to be too small on HiDPI displays (e.g. a 640×480 minimum becomes 427×320 at 150% scaling). JBR fixes this internally with `ScaleUpX`/`ScaleUpY`.
63+
64+
The JNI module replicates this fix: it intercepts `WM_GETMINMAXINFO` after AWT and applies `MulDiv(value, dpi, 96)` scaling. Just set `window.minimumSize` or `window.maximumSize` as usual — the DPI correction is automatic.
65+
66+
This fix is **not present** in `decorated-window-jbr` (JBR handles it natively).
67+
6168
!!! note "Windows: no white background flash during resize"
6269
On Windows, Skiko's rendering pipeline clears the DirectX canvas to white before each frame. When the window is resized larger, the newly exposed pixels remain white for one frame — producing a visible white flash. The JNI module eliminates this by adjusting Skiko's clear color to transparent for dark themes (rendered as opaque black on the DirectX surface), so the flash is invisible against a dark background. It also synchronizes the DWM caption and border colors (`DWMWA_CAPTION_COLOR`, `DWMWA_BORDER_COLOR`, `DWMWA_USE_IMMERSIVE_DARK_MODE`) with the title bar color for consistent Windows 11 window chrome styling.
6370

@@ -175,6 +182,7 @@ The following tables compare a standard Compose `Window()`, the JBR module (`dec
175182
| True fullscreen | Broken (doesn't cover taskbar) | Broken (doesn't cover taskbar) | **Fixed** — native Win32 fullscreen (`newFullscreenControls()`) |
176183
| Fullscreen sliding title bar | No | No | Yes (`newFullscreenControls()`) |
177184
| DWM dark mode sync | No | No | Yes (`DWMWA_USE_IMMERSIVE_DARK_MODE`, caption/border color) |
185+
| DPI-aware min/max size | Broken on non-JBR | JBR handles it | **Fixed**`WM_GETMINMAXINFO` DPI scaling |
178186
| RTL support | No custom title bar | Yes (no hot-swap, restart required) | Yes (live hot-swap) |
179187
| JDK requirement | Any | JBR only | Any |
180188
| Fallback (no native lib) | N/A | N/A | Compose `windowDragHandler()` (no WndProc subclass) |

example/src/main/kotlin/com/example/demo/Main.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ fun main(args: Array<String>) {
175175
onCloseRequest = ::exitApplication,
176176
title = "Nucleus Demo",
177177
) {
178+
// Set minimum window size (DPI-scaled automatically by JNI module)
179+
LaunchedEffect(Unit) {
180+
window.minimumSize = java.awt.Dimension(640, 480)
181+
}
178182
CompositionLocalProvider(
179183
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,
180184
) {

0 commit comments

Comments
 (0)