Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/Backend/Win32/CWin32/d2d1_shim.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
);
Expand Down
9 changes: 6 additions & 3 deletions Sources/Backend/Win32/Rendering/LayoutEngine.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand All@@ -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)
Expand Down
112 changes: 62 additions & 50 deletions Sources/Backend/Win32/Rendering/Win32Backend.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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,
Expand All@@ -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
Expand DownExpand Up@@ -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<MINMAXINFO>(bitPattern: Int(lParam)) {
let state = Unmanaged<MainWindowState>.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)
}
Expand DownExpand Up@@ -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
Expand All@@ -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
)
}
Expand DownExpand Up@@ -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)
}
Expand DownExpand Up@@ -864,9 +873,12 @@ private let windowSceneWndProc: WNDPROC = { (hwnd, uMsg, wParam, lParam) in
let state = Unmanaged<MainWindowState>.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)
}
Expand Down
12 changes: 10 additions & 2 deletions Sources/Backend/Win32/Rendering/WinRenderer.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand DownExpand Up@@ -4252,7 +4253,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
Expand DownExpand Up@@ -7052,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
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/SwiftOpenUISymbols/MaterialSymbolsCodepoints.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
7 changes: 7 additions & 0 deletions Sources/SwiftOpenUISymbols/SFSymbolCompatibility.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
]
}
5 changes: 3 additions & 2 deletions Tests/BackendTests/Win32Tests/Win32RenderTests.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -2595,15 +2595,16 @@ 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")
}
defer { DestroyMenu(hMenu) }

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")
Expand Down