Every dimension in the SwiftOpenUI core API is a logical point. One point is one pixel at 100% display scale (96 DPI on Windows); at 150% it is 1.5 physical pixels. App code never multiplies by a scale factor, exactly as in SwiftUI. Backends convert to device pixels when they render.
Decided in RFC #16 (D7). Status of the Win32
implementation: docs/plans/dpi-scaling-breakdown.md.
These are all logical points:
| API | Values in points |
|---|---|
.frame(...) |
width, height, minWidth … maxHeight (.infinity stays unbounded) |
.padding(...), .safeAreaPadding(...) |
every inset, including the 8pt / 16pt defaults |
VStack, HStack, Grid, LazyVStack, … |
spacing (default 8pt), Spacer(minLength:) |
.offset(x:y:), .position(x:y:) |
both coordinates |
.border(_:width:), .cornerRadius(_:), .shadow(radius:x:y:) |
widths, radii, offsets |
Shape.stroke(...), StrokeStyle |
lineWidth, dash, dashPhase |
Font.system(size:) and text styles |
font size |
Canvas |
its size, and every coordinate drawn in it |
WindowGroup / Window |
.defaultWindowSize, .windowSizeConstraints, .windowSizing(.size) |
.navigationSplitViewColumnWidth(...) |
ideal, min, max |
Values the framework hands back to app code are points too:
GeometryProxy.sizeDragGestureValue(startLocation,location,translation) and the dragminimumDistance- drop locations
- the size passed to a
Canvasdraw handler
Undecided: images loaded from files keep their bitmap's pixel size as their natural size. Whether that should be treated as points (scaled up, softer at 200%) or stay pixel-exact is an open question in RFC #16.
| Backend | Unit its toolkit uses | What the backend does |
|---|---|---|
| macOS | SwiftUI points | Nothing; native SwiftUI |
| GTK4 | GTK logical pixels (GDK applies the scale factor) | Nothing; pass values through |
| Web | CSS pixels (the browser applies devicePixelRatio) |
Nothing; pass values through |
| Android | Jetpack Compose dp |
Pass values through as dp; pointer input arrives in pixels and is divided by density (ComposeRenderHost.kt) |
| Win32 | Physical pixels (HWND geometry; Direct2D targets pinned to 96 DPI) |
Converts explicitly through a chokepoint, below |
Win32 is the only backend whose toolkit does not scale for us, so it is the only one with conversion code.
The helpers live in Sources/Backend/Win32/Rendering/LayoutEngine.swift:
| Helper | Use for |
|---|---|
px(_:for:) |
points → pixels, rounded to the nearest pixel; window and control geometry |
pxExact(_:for:) |
points → pixels, unrounded; layout math and Direct2D drawing |
pxForNewWindow(_:) |
points → pixels before a window exists (uses the system DPI) |
pxToPoints(_:for:) |
pixels → points, for values handed back to app code |
dpiScaleFactor(for:) |
the raw ratio, when a whole calculation needs it |
defaultBodyFontSize |
the 14pt default text size; never write 14 |
- Convert once, where the view is created. Each
winCreateWidgetconverts its point inputs at the top and works in pixels from then on. Layout functions (performVerticalLayout,computeFrameLayout, …) only ever see pixels; never convert inside them. - Never pass a literal to window geometry. A number written in
CreateWindowExW,SetWindowPosor a D2D draw call is a point and goes throughpx/pxExact.0is the only safe literal. - Convert back when handing values to app code, with
pxToPoints. Input and drawing must agree: Canvas draws under a base DPI transform, so gesture locations in points land where the pointer is. - Hairlines stay physical. 1px borders, their 0.5/1px alignment
insets,
Divider's line and native control glyphs are not scaled, so they stay crisp, as native Windows controls do. - Read the DPI from the window you are sizing, never a cached or global value. Per-monitor DPI belongs to a top-level window and changes when it moves.
- DPI changes re-render. On
WM_DPICHANGEDthe window applies the suggested size, then re-renders its root at the new DPI (RFC #16 D4, PR #39). Code that converts at creation is therefore correct after a move, as long as it reads the DPI per rule 5.
Confirmed at 150% on GNOME Shell 50 (Wayland) with GTK 4.22 (RFC #16 D8, issue #21). GTK needs no conversion code, at fractional scales too:
- Geometry stays in points. GTK reports a surface scale of 1.5 and lays out in logical pixels, so an 800×600pt window is 1200×900 device pixels and a 100pt frame is 150px. Window sizes are identical at 100% and 150%. Text is rendered at device resolution and is crisp.
- Canvas is crisp. Cairo drawing in a
Canvasis rasterized at 1.5×, not drawn at 2× and scaled down. - 1pt hairlines are soft. A 1pt border is 1.5 device pixels and falls between pixels, so it renders as a two-pixel soft line. Native GTK widget borders do the same; unlike Win32 (rule 4) we do not snap hairlines to physical pixels. Bitmaps are resampled and slightly soft. At 200% both are sharp.
- The integer scale factor is 2 at 150%.
gtk_widget_get_scale_factorrounds up; usegdk_surface_get_scalefor the real value. Nothing in the backend reads either today. GDK_SCALEandGDK_DPI_SCALEhave no effect under Wayland: the output is identical to an unset run. Display scaling comes from the compositor only.- Not tested: GNOME's text-only "Large Text" setting
(
text-scaling-factor), which could make text outgrow fixed frames the way Win32 text did before D3; and X11 sessions.
Evidence and the probe view used: docs/evidence/dpi/gtk4-150/.
swift testruns in a process that is not DPI-aware, so it always sees 96 DPI and every helper is the identity. Tests prove 100% behaviour, not scaling.- Above 100%, verify with screenshots: change the display scale and run
screenshots/capture-windows.ps1(DPI-aware since #35). Keep the display awake; a sleeping display drops Direct2D-drawn controls from captures. - Reference captures:
docs/evidence/dpi/150/(before),150-d3a/,150-d3b/(after). - GTK4:
screenshots/capture-linux-scaled.sh 1.5 [target…]runs the examples on a private headless GNOME Shell at the given scale, so your own desktop is left alone. Each example renders its own window to PNG, enabled bySWIFTOPENUI_GTK_CAPTURE=<file.png>(GTKCapture.swift).