From 89944ae06a346a3e5db59b32feb168afd0ab215c Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Thu, 20 Aug 2026 13:18:35 +0300 Subject: [PATCH 1/5] fix(Win32): scale Material Symbol glyph boxes with DPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glyph font is scaled by the monitor DPI (pointSize × dpiScale) but the STATIC box holding it was not, so icons were cropped on high-DPI displays. Scale the box too. Co-Authored-By: Claude --- Sources/Backend/Win32/Rendering/WinRenderer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Backend/Win32/Rendering/WinRenderer.swift b/Sources/Backend/Win32/Rendering/WinRenderer.swift index 3476b5e..68f6590 100644 --- a/Sources/Backend/Win32/Rendering/WinRenderer.swift +++ b/Sources/Backend/Win32/Rendering/WinRenderer.swift @@ -4252,7 +4252,8 @@ extension Image: WinRenderable { ) } - let size = Int32(scale.pointSize) + 4 + // Box must track the DPI-scaled font height or the glyph is cropped. + let size = Int32(Double(scale.pointSize) * dpiScale) + 4 // SS_CENTER | SS_CENTERIMAGE center the glyph within its box so the // icon sits centered rather than top-left. let hwnd = glyph.withCString(encodedAs: UTF16.self) { wstr in From 65dae24b497781b87322dcd16bb07aabdd6e3681 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Thu, 20 Aug 2026 13:27:20 +0300 Subject: [PATCH 2/5] fix(Win32): DPI-scale window sizes; system default when unspecified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI window sizes are logical points; windows were created at the same number of physical pixels, so a 1100×720 window appeared as 550×360 at 200% DPI. Sizes the app specifies (defaultWindowSize, windowSizing(.size), min/max track sizes) are now scaled by the window's own DPI (per-monitor). Windows without a specified size keep the CW_USEDEFAULT system default instead of the previous hardcoded 400×300/500×600 fallbacks. --- .../Win32/Rendering/Win32Backend.swift | 112 ++++++++++-------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/Sources/Backend/Win32/Rendering/Win32Backend.swift b/Sources/Backend/Win32/Rendering/Win32Backend.swift index 49fa70d..0269c03 100644 --- a/Sources/Backend/Win32/Rendering/Win32Backend.swift +++ b/Sources/Backend/Win32/Rendering/Win32Backend.swift @@ -126,7 +126,8 @@ extension WindowGroup: Win32WindowRenderable { break } - // Create with default size initially; we'll resize after rendering content + // Create at the system default size (CW_USEDEFAULT); resize to the + // specified size after rendering content. let titleWide: [WCHAR] = Array(title.utf16) + [0] let hwnd = titleWide.withUnsafeBufferPointer { titlePtr in className.withUnsafeBufferPointer { classPtr in @@ -136,7 +137,7 @@ extension WindowGroup: Win32WindowRenderable { titlePtr.baseAddress!, style, Int32(CW_USEDEFAULT), Int32(CW_USEDEFAULT), - 500, 600, + Int32(CW_USEDEFAULT), Int32(CW_USEDEFAULT), nil, nil, hInstance, @@ -161,48 +162,41 @@ extension WindowGroup: Win32WindowRenderable { let naturalContentW = contentRect.right - contentRect.left let naturalContentH = contentRect.bottom - contentRect.top - let desiredClientSize: (Int32, Int32) = { - switch windowSizing ?? .automatic { - case .automatic, .content, .contentFixed: - return (naturalContentW + 20, naturalContentH + 20) - case .size(let width, let height): - return (Int32(width), Int32(height)) - } - }() - + // The window starts at the system default size (CW_USEDEFAULT). + // Resize it only when the app specifies a size. let screenW = GetSystemMetrics(SM_CXSCREEN) let screenH = GetSystemMetrics(SM_CYSCREEN) - // When explicit sizing is provided (defaultWindowSize or windowSizing(.size)), - // don't enforce 300x200 minimum — the developer chose the size. - let hasExplicitSize = defaultWindowWidth != nil || defaultWindowHeight != nil || { - if case .size = windowSizing ?? .automatic { return true } - if case .contentFixed = windowSizing ?? .automatic { return true } - return false - }() - let minClientW = minWindowWidth.map { Int32($0) } ?? (hasExplicitSize ? 1 : 300) - let minClientH = minWindowHeight.map { Int32($0) } ?? (hasExplicitSize ? 1 : 200) - let maxClientW = maxWindowWidth.map { Int32($0) } ?? (screenW * 3 / 4) - let maxClientH = maxWindowHeight.map { Int32($0) } ?? (screenH * 3 / 4) - - let defaultClientW = defaultWindowWidth.map { Int32($0) } - let defaultClientH = defaultWindowHeight.map { Int32($0) } - let automaticDefaultClientSize: (Int32?, Int32?) = { - if case .automatic = windowSizing ?? .automatic { - return (Int32(defaultAutomaticWindowWidth), Int32(defaultAutomaticWindowHeight)) + // Scale by the existing window's DPI. + let dpiScale = Double(win32_GetDpiForWindow(hwnd)) / 96.0 + let minClientW = minWindowWidth.map { Int32(Double($0) * dpiScale) } + let minClientH = minWindowHeight.map { Int32(Double($0) * dpiScale) } + let maxClientW = maxWindowWidth.map { Int32(Double($0) * dpiScale) } ?? (screenW * 3 / 4) + let maxClientH = maxWindowHeight.map { Int32(Double($0) * dpiScale) } ?? (screenH * 3 / 4) + + var requestedSize: (Int32, Int32)? + if let dw = defaultWindowWidth, let dh = defaultWindowHeight { + requestedSize = (Int32(Double(dw) * dpiScale), Int32(Double(dh) * dpiScale)) + } else if let sizing = windowSizing { + switch sizing { + case .size(let width, let height): + requestedSize = (Int32(Double(width) * dpiScale), Int32(Double(height) * dpiScale)) + case .content, .contentFixed: + requestedSize = (naturalContentW + 20, naturalContentH + 20) + case .automatic: + break } - return (nil, nil) - }() - let unclampedW = defaultClientW ?? automaticDefaultClientSize.0 ?? desiredClientSize.0 - let unclampedH = defaultClientH ?? automaticDefaultClientSize.1 ?? desiredClientSize.1 - let clientW = max(minClientW, min(unclampedW, maxClientW)) - let clientH = max(minClientH, min(unclampedH, maxClientH)) - - let windowSize = adjustedWindowSize(clientWidth: clientW, clientHeight: clientH, style: style) - SetWindowPos(hwnd, nil, - Int32(CW_USEDEFAULT), Int32(CW_USEDEFAULT), - windowSize.0, - windowSize.1, - UINT(SWP_NOMOVE | SWP_NOZORDER)) + } + + if let (reqW, reqH) = requestedSize { + let clientW = max(minClientW ?? 1, min(reqW, maxClientW)) + let clientH = max(minClientH ?? 1, min(reqH, maxClientH)) + let windowSize = adjustedWindowSize(clientWidth: clientW, clientHeight: clientH, style: style) + SetWindowPos(hwnd, nil, + Int32(CW_USEDEFAULT), Int32(CW_USEDEFAULT), + windowSize.0, + windowSize.1, + UINT(SWP_NOMOVE | SWP_NOZORDER)) + } // SwiftUI's WindowGroup centers intrinsically-sized root content // (e.g. a plain Text) and stretches fill-semantic roots (e.g. a @@ -283,13 +277,20 @@ private let mainWindowProc: WNDPROC = { (hwnd, uMsg, wParam, lParam) in let userData = win32_GetWindowLongPtrW(hwnd!, GWLP_USERDATA) if userData != 0, let info = UnsafeMutablePointer(bitPattern: Int(lParam)) { let state = Unmanaged.fromOpaque(UnsafeMutableRawPointer(bitPattern: Int(userData))!).takeUnretainedValue() + let dpiScale = Double(win32_GetDpiForWindow(hwnd!)) / 96.0 if let minW = state.minClientWidth, let minH = state.minClientHeight { - let adjusted = adjustedWindowSize(clientWidth: minW, clientHeight: minH, style: state.style) + let adjusted = adjustedWindowSize( + clientWidth: Int32(Double(minW) * dpiScale), + clientHeight: Int32(Double(minH) * dpiScale), + style: state.style) info.pointee.ptMinTrackSize.x = LONG(adjusted.0) info.pointee.ptMinTrackSize.y = LONG(adjusted.1) } if let maxW = state.maxClientWidth, let maxH = state.maxClientHeight { - let adjusted = adjustedWindowSize(clientWidth: maxW, clientHeight: maxH, style: state.style) + let adjusted = adjustedWindowSize( + clientWidth: Int32(Double(maxW) * dpiScale), + clientHeight: Int32(Double(maxH) * dpiScale), + style: state.style) info.pointee.ptMaxTrackSize.x = LONG(adjusted.0) info.pointee.ptMaxTrackSize.y = LONG(adjusted.1) } @@ -768,11 +769,8 @@ extension Window: Win32WindowRenderable { } let style = DWORD(WS_OVERLAPPEDWINDOW) - let clientW = defaultWindowWidth.map { Int32($0) } ?? 400 - let clientH = defaultWindowHeight.map { Int32($0) } ?? 300 - let windowSize = adjustedWindowSize( - clientWidth: clientW, clientHeight: clientH, style: style) - + // Create at the system default size (CW_USEDEFAULT); resize to the + // specified logical size (DPI-scaled) once the window exists. let titleWide: [WCHAR] = Array(title.utf16) + [0] let hwnd = titleWide.withUnsafeBufferPointer { titlePtr in classNameWide.withUnsafeBufferPointer { classPtr in @@ -782,7 +780,7 @@ extension Window: Win32WindowRenderable { titlePtr.baseAddress!, style, Int32(CW_USEDEFAULT), Int32(CW_USEDEFAULT), - windowSize.0, windowSize.1, + Int32(CW_USEDEFAULT), Int32(CW_USEDEFAULT), nil, nil, hInstance, nil ) } @@ -833,6 +831,17 @@ extension Window: Win32WindowRenderable { let windowId = id Win32WindowRegistry.shared.setLiveWindow(id: windowId, hwnd: hwnd) + // Resize to the specified logical size, scaled by this window's DPI. + if let dw = defaultWindowWidth, let dh = defaultWindowHeight { + let dpiScale = Double(win32_GetDpiForWindow(hwnd)) / 96.0 + let scaledSize = adjustedWindowSize( + clientWidth: Int32(Double(dw) * dpiScale), + clientHeight: Int32(Double(dh) * dpiScale), + style: style) + SetWindowPos(hwnd, nil, 0, 0, scaledSize.0, scaledSize.1, + UINT(SWP_NOMOVE | SWP_NOZORDER)) + } + ShowWindow(hwnd, SW_SHOWDEFAULT) UpdateWindow(hwnd) } @@ -864,9 +873,12 @@ private let windowSceneWndProc: WNDPROC = { (hwnd, uMsg, wParam, lParam) in let state = Unmanaged.fromOpaque( UnsafeMutableRawPointer(bitPattern: Int(userData))! ).takeUnretainedValue() + let dpiScale = Double(win32_GetDpiForWindow(hwnd!)) / 96.0 if let minW = state.minClientWidth, let minH = state.minClientHeight { let adjusted = adjustedWindowSize( - clientWidth: minW, clientHeight: minH, style: state.style) + clientWidth: Int32(Double(minW) * dpiScale), + clientHeight: Int32(Double(minH) * dpiScale), + style: state.style) info.pointee.ptMinTrackSize.x = LONG(adjusted.0) info.pointee.ptMinTrackSize.y = LONG(adjusted.1) } From f33dd41188ec988469a98925557dd3e694443a80 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Thu, 20 Aug 2026 15:54:47 +0300 Subject: [PATCH 3/5] fix(Win32): DPI-scale text measurement and D2D rendering Text rendered at DPI-scaled font sizes while the boxes around it were measured at 96 DPI, so at 200% DPI buttons came out half-size and cropped. - measureText scales the default 14pt by the window's DPI (DirectWrite format and GDI fallback) - the D2D flat button paints with the scaled default size - the D2D render target pins its DPI to 96 so drawing coordinates match the layout engine's physical pixels (the default system-DPI target reinterpreted every coordinate as DIPs and doubled sizes at 200%) --- Sources/Backend/Win32/CWin32/d2d1_shim.cpp | 6 ++++++ Sources/Backend/Win32/Rendering/LayoutEngine.swift | 9 ++++++--- Sources/Backend/Win32/Rendering/WinRenderer.swift | 9 ++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/Backend/Win32/CWin32/d2d1_shim.cpp b/Sources/Backend/Win32/CWin32/d2d1_shim.cpp index 9b1d4ae..d5ce77f 100644 --- a/Sources/Backend/Win32/CWin32/d2d1_shim.cpp +++ b/Sources/Backend/Win32/CWin32/d2d1_shim.cpp @@ -49,7 +49,13 @@ HRESULT d2d1_Factory_CreateHwndRenderTarget( D2DRenderTarget *ppTarget ) { auto f = AS_FACTORY(factory); + // The layout engine works in physical pixels, so pin the target's DPI + // to 96 (1 DIP = 1 physical pixel). The default (system DPI) would + // reinterpret every coordinate as device-independent pixels and + // double all sizes on a 200% display. D2D1_RENDER_TARGET_PROPERTIES rtProps = D2D1::RenderTargetProperties(); + rtProps.dpiX = 96.0f; + rtProps.dpiY = 96.0f; D2D1_HWND_RENDER_TARGET_PROPERTIES hwndProps = D2D1::HwndRenderTargetProperties( hwnd, D2D1::SizeU(width, height) ); diff --git a/Sources/Backend/Win32/Rendering/LayoutEngine.swift b/Sources/Backend/Win32/Rendering/LayoutEngine.swift index 508db7a..9a81b1c 100644 --- a/Sources/Backend/Win32/Rendering/LayoutEngine.swift +++ b/Sources/Backend/Win32/Rendering/LayoutEngine.swift @@ -5,8 +5,10 @@ import SwiftOpenUI /// Measure a text string's size using DirectWrite (preferred) or GDI fallback. /// DirectWrite provides more accurate sub-pixel measurement than GDI. public func measureText(_ text: String, hwnd: HWND) -> (width: Int32, height: Int32) { + // Text renders DPI-scaled, so measure with the scaled default size. + let dpiScale = Float(win32_GetDpiForWindow(hwnd)) / 96.0 // Try DirectWrite first — more accurate and consistent with D2D rendering - if let fmt = D2DRenderer.shared.textFormat() { + if let fmt = D2DRenderer.shared.textFormat(fontSize: 14 * dpiScale) { let (w, h) = D2DRenderer.shared.measureText(text, format: fmt) if w > 0 || h > 0 { return (width: Int32(w) + 4, height: Int32(h) + 2) @@ -23,12 +25,13 @@ public func measureText(_ text: String, hwnd: HWND) -> (width: Int32, height: In win32_GetTextExtentPoint32W(hdc, wstr, len, &size) } - return (width: size.cx, height: size.cy) + return (width: Int32(Double(size.cx) * Double(dpiScale)), height: Int32(Double(size.cy) * Double(dpiScale))) } /// Measure text with a specific font family using DirectWrite. public func measureText(_ text: String, fontFamily: String, hwnd: HWND) -> (width: Int32, height: Int32) { - if let fmt = D2DRenderer.shared.textFormat(fontFamily: fontFamily) { + let dpiScale = Float(win32_GetDpiForWindow(hwnd)) / 96.0 + if let fmt = D2DRenderer.shared.textFormat(fontFamily: fontFamily, fontSize: 14 * dpiScale) { let (w, h) = D2DRenderer.shared.measureText(text, format: fmt) if w > 0 || h > 0 { return (width: Int32(w) + 4, height: Int32(h) + 2) diff --git a/Sources/Backend/Win32/Rendering/WinRenderer.swift b/Sources/Backend/Win32/Rendering/WinRenderer.swift index 68f6590..1f032bf 100644 --- a/Sources/Backend/Win32/Rendering/WinRenderer.swift +++ b/Sources/Backend/Win32/Rendering/WinRenderer.swift @@ -1333,7 +1333,8 @@ class FlatButtonState { tr = textColorR ?? 0.1; tg = textColorG ?? 0.1; tb = textColorB ?? 0.1 } d2d1_SolidColorBrush_SetColor(brush, tr, tg, tb, 1) - if let fmt = customTextFormat ?? D2DRenderer.shared.textFormat() { + let dpiScale = Float(win32_GetDpiForWindow(hwnd)) / 96.0 + if let fmt = customTextFormat ?? D2DRenderer.shared.textFormat(fontSize: 14 * dpiScale) { dwrite_TextFormat_SetTextAlignment(fmt, 2) // center D2DRenderer.shared.drawText(title, target: rt, format: fmt, brush: brush, x: 0, y: 0, width: w, height: h) @@ -7053,6 +7054,12 @@ extension NavigationSplitView: WinRenderable { SetWindowPos(container, nil, 0, 0, max(w, 400), max(h, 300), UINT(SWP_NOZORDER | SWP_NOMOVE)) + // Propagate the columns' expansion so the split view fills its + // available space (SwiftUI/GTK4 behavior). + let columnHwnds = [sidebarHwnd, contentHwnd, detailHwnd].compactMap { $0 } + if columnHwnds.contains(where: { shouldExpandWidth($0) }) { markExpandWidth(container) } + if columnHwnds.contains(where: { shouldExpandHeight($0) }) { markExpandHeight(container) } + return container } } From 8d5deb6fc641f9e113932a1166884368dc259774 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Thu, 20 Aug 2026 13:27:20 +0300 Subject: [PATCH 4/5] feat: SF symbol map entries for devices/media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SF Symbol → Material Symbol mappings: iphone.gen3 → smartphone, iphone.slash → no_sim, square.on.square → history, camera → photo_camera, record.circle → fiber_manual_record, plus the matching PUA codepoints. --- Sources/SwiftOpenUISymbols/MaterialSymbolsCodepoints.swift | 6 ++++++ Sources/SwiftOpenUISymbols/SFSymbolCompatibility.swift | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/Sources/SwiftOpenUISymbols/MaterialSymbolsCodepoints.swift b/Sources/SwiftOpenUISymbols/MaterialSymbolsCodepoints.swift index 54594f8..dfcfa53 100644 --- a/Sources/SwiftOpenUISymbols/MaterialSymbolsCodepoints.swift +++ b/Sources/SwiftOpenUISymbols/MaterialSymbolsCodepoints.swift @@ -73,6 +73,12 @@ public enum MaterialSymbolsCodepoints { "info": 0xE88E, "verified": 0xEF76, "warning": 0xE002, + // Devices / media + "fiber_manual_record": 0xE061, + "history": 0xE889, + "no_sim": 0xE0CE, + "photo_camera": 0xE412, + "smartphone": 0xE32C, // Common actions "add": 0xE145, diff --git a/Sources/SwiftOpenUISymbols/SFSymbolCompatibility.swift b/Sources/SwiftOpenUISymbols/SFSymbolCompatibility.swift index 4933315..b79d6d0 100644 --- a/Sources/SwiftOpenUISymbols/SFSymbolCompatibility.swift +++ b/Sources/SwiftOpenUISymbols/SFSymbolCompatibility.swift @@ -142,5 +142,12 @@ public enum SFSymbolCompatibility { "star.fill": "star", "tag": "label", "tag.fill": "label", + + // MARK: Devices / media + "camera": "photo_camera", + "iphone.gen3": "smartphone", + "iphone.slash": "no_sim", + "record.circle": "fiber_manual_record", + "square.on.square": "history", ] } From 67ee6a6221656a2f1a82dd690045aac593508619 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Thu, 20 Aug 2026 14:31:02 +0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(Win32):=20unblock=20test=20suite=20buil?= =?UTF-8?q?d=20=E2=80=94=20Menu=20no=20longer=20exposes=20.elements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Menu control rework removed the .elements accessor the Menu command-dispatch test relied on, breaking the Win32 test target compile. Build the elements via MenuBuilder instead; the test's purpose — environment capture at render time — is unchanged. --- Tests/BackendTests/Win32Tests/Win32RenderTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/BackendTests/Win32Tests/Win32RenderTests.swift b/Tests/BackendTests/Win32Tests/Win32RenderTests.swift index a4fea9e..fafb206 100644 --- a/Tests/BackendTests/Win32Tests/Win32RenderTests.swift +++ b/Tests/BackendTests/Win32Tests/Win32RenderTests.swift @@ -2595,7 +2595,8 @@ final class Win32RenderTests: XCTestCase { renderEnv.setObject(model) setCurrentEnvironment(renderEnv) - let menu = DelayedEnvironmentMenuHostView().menu + let elements: [MenuElement] = MenuBuilder.buildExpression( + MenuItem("Increment") { model.count += 1 }) guard let hMenu = CreatePopupMenu() else { return XCTFail("Expected popup menu creation to succeed in test harness") } @@ -2603,7 +2604,7 @@ final class Win32RenderTests: XCTestCase { var nextMenuID: UINT = 50000 var actions: [UINT: () -> Void] = [:] - winPopulateMenu(hMenu, elements: menu.elements, nextMenuID: &nextMenuID, actions: &actions) + winPopulateMenu(hMenu, elements: elements, nextMenuID: &nextMenuID, actions: &actions) guard let itemAction = actions[50000] else { return XCTFail("Expected first menu item action to be registered through winPopulateMenu")