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
49 changes: 39 additions & 10 deletions Sources/Backend/Win32/Rendering/Win32Navigation.swift
Original file line numberDiff line numberDiff 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 DownExpand 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 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
Loading