Skip to content
Open
6 changes: 6 additions & 0 deletions Sources/Backend/Win32/CWin32/d2d1_shim.cpp
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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 Down Expand 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
49 changes: 39 additions & 10 deletions Sources/Backend/Win32/Rendering/Win32Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,23 +299,42 @@ private let navContainerWndProc: WNDPROC = { (hwnd, uMsg, wParam, lParam) in
}
}

/// Measure the back button text with the title font (the font applied
/// to the button via WM_SETFONT). Querying the button for its font here
/// would re-enter the control while it processes a message.
private func measureBackButton(ctx: Win32NavigationContext) -> Int32 {
let hdc = GetDC(ctx.backButton)
defer { ReleaseDC(ctx.backButton, hdc) }
var size = SIZE()
"← Back".withCString(encodedAs: UTF16.self) { wstr in
let len = Int32(wcslen(wstr))
let old = ctx.titleFont.flatMap { SelectObject(hdc, $0) }
win32_GetTextExtentPoint32W(hdc, wstr, len, &size)
if let old = old { SelectObject(hdc, old) }
}
return size.cx
}

/// Layout the navigation container: header bar at top, content area fills the rest.
private func layoutNavContainer(_ ctx: Win32NavigationContext) {
var rect = RECT()
GetClientRect(ctx.container, &rect)
let w = rect.right - rect.left
let h = rect.bottom - rect.top

let headerHeight: Int32 = 32
// Header sizes are physical px; scale the 96-DPI defaults by the window DPI.
let dpiScale = Int32(Double(win32_GetDpiForWindow(ctx.container)) / 96.0)
let headerHeight: Int32 = 32 * dpiScale
SetWindowPos(ctx.headerContainer, nil, 0, 0, w, headerHeight, UINT(SWP_NOZORDER))

// Layout back button (child of headerContainer)
let backVisible = IsWindowVisible(ctx.backButton) != false
let backWidth: Int32 = backVisible ? 60 : 0
let backWidth: Int32 = backVisible ? measureBackButton(ctx: ctx) + 16 * dpiScale : 0
if backVisible {
SetWindowPos(ctx.backButton, nil, 4, 4, backWidth - 8, headerHeight - 8, UINT(SWP_NOZORDER))
}
SetWindowPos(ctx.titleLabel, nil, backWidth + 4, 0, w - backWidth - 8, headerHeight, UINT(SWP_NOZORDER))
SetWindowPos(ctx.titleLabel, nil, backWidth + 4, 0,
w - backWidth - 8, headerHeight, UINT(SWP_NOZORDER))

// Content area fills the rest
SetWindowPos(ctx.contentArea, nil, 0, headerHeight, w, h - headerHeight, UINT(SWP_NOZORDER))
Expand All @@ -341,11 +360,14 @@ extension NavigationStack: WinRenderable {
context.parent, nil, context.hInstance, nil
)!

// Header bar (background: button face color)
// Header bar (background: button face color). Sizes are physical
// pixels, so scale the 96-DPI defaults by the window's DPI.
let dpiScale = Int32(Double(win32_GetDpiForWindow(container)) / 96.0)
let headerHeight: Int32 = 32 * dpiScale
let headerContainer = CreateWindowExW(
0, stackContainerClassName, nil,
DWORD(WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN),
0, 0, 0, 32,
0, 0, 0, headerHeight,
container, nil, context.hInstance, nil
)!

Expand All @@ -357,7 +379,7 @@ extension NavigationStack: WinRenderable {
let backButton = "← Back".withCString(encodedAs: UTF16.self) { wstr in
win32_CreateChildWindow(
win32_WC_BUTTON(), wstr, DWORD(BS_PUSHBUTTON),
0, 0, 60, 24,
0, 0, 60 * dpiScale, 24 * dpiScale,
headerContainer,
HMENU(bitPattern: UInt(backControlID)),
context.hInstance
Expand All @@ -371,19 +393,23 @@ extension NavigationStack: WinRenderable {
// Title label
let titleLabel = win32_CreateChildWindow(
win32_WC_STATIC(), nil, DWORD(SS_CENTER | SS_CENTERIMAGE),
0, 0, 0, 32,
0, 0, 0, headerHeight,
headerContainer, nil, context.hInstance
)!

// Apply bold font to title (tracked for cleanup in context deinit)
// Apply bold font to title (tracked for cleanup in context deinit).
// -height is physical px; scale the 16px default by the window DPI.
let titleFont = "Segoe UI".withCString(encodedAs: UTF16.self) { namePtr in
CreateFontW(-16, 0, 0, 0, FW_BOLD, 0, 0, 0,
CreateFontW(-16 * dpiScale, 0, 0, 0, FW_BOLD, 0, 0, 0,
DWORD(DEFAULT_CHARSET), DWORD(OUT_DEFAULT_PRECIS),
DWORD(CLIP_DEFAULT_PRECIS), DWORD(CLEARTYPE_QUALITY),
DWORD(DEFAULT_PITCH), namePtr)
}
if let f = titleFont {
SendMessageW(titleLabel, UINT(WM_SETFONT), WPARAM(UInt(bitPattern: f)), 1)
// The native button's default font is not DPI-scaled; use the
// same scaled font as the title.
SendMessageW(backButton, UINT(WM_SETFONT), WPARAM(UInt(bitPattern: f)), 1)
}

// Content area
Expand Down Expand Up @@ -436,7 +462,6 @@ extension NavigationStack: WinRenderable {
setCurrentNavigationContext(nil)

// Add root as first entry and size the nav container
let headerHeight: Int32 = 32
if let rootHwnd = rootHwnd {
// Get root content's natural size
var rootRect = RECT()
Expand All @@ -459,6 +484,10 @@ extension NavigationStack: WinRenderable {
caRect.right, caRect.bottom, UINT(SWP_NOZORDER))

navCtx.entries.append(Win32NavigationEntry(title: title, hwnd: rootHwnd))
// Propagate the root content's expansion so the stack fills
// its available space.
if shouldExpandWidth(rootHwnd) { markExpandWidth(container) }
if shouldExpandHeight(rootHwnd) { markExpandHeight(container) }
}

// Set initial title
Expand Down
12 changes: 10 additions & 2 deletions Sources/Backend/Win32/Rendering/WinRenderer.swift
Original file line number Diff line number Diff 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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
Loading