Anti-alias rounded primitives and adopt Per-Monitor V2 DPI on Windows (#81)

* Anti-alias rounded primitives and adopt Per-Monitor V2 DPI on Windows

- Rounded-rect fills and strokes render through one continuous signed-distance coverage field, so curved edges ramp smoothly with no silhouette drift; a supersampled ground-truth test pins shape fidelity and radius-0 rects stay bit-identical
- Hairline borders snap to whole device pixel columns at emit time and geometry snapping is on by default in the house and Geist packs, keeping 1px borders crisp while arcs stay smooth; pure-SDF geometry remains available by disabling pixel_snap.geometry
- Windows apps declare Per-Monitor V2 DPI awareness in the embedded manifest and the Win32 host sizes windows, child views, and surfaces in physical pixels with WM_DPICHANGED re-rasterization, so canvases render at device scale instead of being bitmap-stretched

* Re-pin example reference signatures for the snap default

- gpu-dashboard and gpu-components pin their reference-surface signatures inside the example suites, which only test-examples-native runs; the geometry-snap default changed those pixels

* Re-apply explicit webview frames on DPI change and stamp static tokens with surface scale

- WM_DPICHANGED now re-applies explicit child webview frames rooted at the message window, matching the native-view pass
- effectiveTokens stamps pixel_snap.scale onto a copy of static tokens and scale changes rebuild static-token apps, so hairline snapping stays on the device grid across monitor density changes

* Lay out the components scene with the tokens it renders with

- The catalog laid out under default tokens (geometry snapping off) and rendered under pack tokens (snapping on), so the ceil rule for label-exact widths no-oped and per-edge frame rounding elided the third theme tab
- Layout builders now take the token set, the live app lays out with its surface tokens, and a regression test asserts the theme strip never elides under snapping in either pack

* Snap hairline borders to the lighter whole-pixel width

- Within the existing snap-eligibility window, fractional hairline widths now floor to the lighter device-pixel count instead of rounding, so a 1px border at 1.5x covers one crisp device column instead of two
- Sub-half-pixel strokes still never snap and 1x, 1.25x, and 2x rendering is pinned byte-identical by the updated tests

* Adopt resize-carried density and give each window its own snap scale

- handleResize adopts the event's scale factor before rebuilding, so a DPI-only monitor move re-stamps tokens and re-emits even when the logical size is unchanged
- Window slots own a per-window pixel_snap_scale stamped into their token emission, so secondary windows on different-density monitors snap on their own device grid

* Round native view frames once from accumulated logical coordinates

- Native child view origins accumulate logical x/y through the parent chain and every physical edge rounds exactly once, so nested controls no longer drift a pixel at fractional scales and abutting frames share pixel columns
- The app manifest declares an ordered DPI awareness chain (PerMonitorV2, PerMonitor, legacy dpiAware) so pre-1607 systems degrade gracefully instead of losing awareness entirely

* Round hidden-titlebar content sizes like the standard path

- hiddenOuterSizeForContent rounds scaled content extents through a shared helper instead of truncating, so hidden-titlebar windows and min-size floors match the requested logical size at fractional scales
- check-framework-sync now explains that the package mirror is generated and points at copy-framework.js instead of implying the mirror should be committed

* Chain window DPI resolution through monitor and system fallbacks

- dpiForWindow now mirrors the manifest's awareness chain: GetDpiForWindow, then GetDpiForMonitor via shcore, then the system DPI, so pre-1607 systems that the manifest makes DPI-aware no longer render tiny 1x content
- gpuSurfaceScale delegates to the shared helper and the build pin asserts the chain alongside the manifest elements
This commit is contained in:
Chris Tate
2026-07-09 21:32:50 -05:00
committed by GitHub
parent c17c64e4c9
commit 512298b474
24 changed files with 1128 additions and 161 deletions
+17 -1
View File
@@ -2,11 +2,27 @@
<!-- Application manifest embedded into Windows app executables. Declaring
the common-controls v6 side-by-side dependency activates the modern
control styling and the v6-only exports (TaskDialogIndirect); without
it the loader binds the system-default v5 assembly. -->
it the loader binds the system-default v5 assembly. Declaring
per-monitor-v2 DPI awareness makes GetDpiForWindow report the real
monitor DPI (instead of a virtualized 96) so the canvas rasterizes
at device scale and Windows never bitmap-stretches the window.
dpiAwareness carries an ordered fallback list: readers take the first
value they recognize, so PerMonitorV2 degrades to PerMonitor where v2
is unknown. The dpiAwareness element itself is ignored before Windows
10 1607; there the legacy dpiAware element beside it picks up, and its
true/pm value requests per-monitor awareness where supported with
system-DPI awareness as the floor. Readers that understand dpiAwareness
prefer it over dpiAware, so the two elements coexist safely. -->
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>
+26
View File
@@ -688,6 +688,32 @@ pub fn build(b: *std.Build) void {
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "gpuImeCommitAction(pending, result)" },
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "ISC_SHOWUICOMPOSITIONWINDOW" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-windows-view-frame-rounding", "Verify Windows native view frames accumulate logical coordinates before rounding and declare the full DPI awareness fallback chain", &.{
// The frame policy: every physical edge is the once-rounded
// product of an ACCUMULATED logical coordinate and the window
// scale, with width/height as edge differences. Per-level
// rounding drifts from the round of the sum (the static_asserts
// beside nativeViewCoord carry the numeric proof, compiled on
// every Windows host build).
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "static void nativeViewLogicalOrigin(Host *host, const NativeView &view, double *logical_x, double *logical_y)" },
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "frame.right = nativeViewCoord((logical_x + view.width) * scale);" },
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "static_assert(nativeViewCoord(10.4 * 1.5) + nativeViewCoord(10.4 * 1.5) == 32 && nativeViewCoord((10.4 + 10.4) * 1.5) == 31," },
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "MoveWindow(view.hwnd, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, TRUE);" },
// The embedded manifest's DPI declarations: the ordered
// dpiAwareness list degrades PerMonitorV2 to PerMonitor, and the
// legacy dpiAware element covers systems that ignore dpiAwareness
// entirely.
.{ .path = "assets/native-sdk.manifest", .pattern = "<dpiAware xmlns=\"http://schemas.microsoft.com/SMI/2005/WindowsSettings\">true/pm</dpiAware>" },
.{ .path = "assets/native-sdk.manifest", .pattern = "<dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">PerMonitorV2, PerMonitor</dpiAwareness>" },
// The host's runtime DPI resolution mirrors that manifest chain:
// GetDpiForWindow, then shcore's GetDpiForMonitor (per-monitor v1),
// then the system DPI. Without the per-monitor-v1 and system rungs
// an aware process on a pre-1607 system would render at real
// physical pixels while the host still scaled everything at 1x.
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "GetProcAddress(shcore, \"GetDpiForMonitor\")" },
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)" },
.{ .path = "src/platform/windows/webview2_host.cpp", .pattern = "return systemDpi();" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-text-command-bridge", "Verify AppKit GPU text widgets route native text commands", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "- (void)selectAll:(id)sender" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "@selector(selectAll:)" },
+3
View File
@@ -544,6 +544,9 @@ fn linkPlatform(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Res
// Common-controls v6 side-by-side dependency: without this
// manifest the loader binds the system-default v5 assembly, which
// renders classic-styled controls and lacks the v6-only exports.
// The manifest also declares per-monitor-v2 DPI awareness so the
// canvas rasterizes at real device scale instead of Windows
// bitmap-stretching a 96-DPI surface on scaled displays.
exe.win32_manifest = dep.path("assets/native-sdk.manifest");
switch (web_engine) {
.system => app_mod.addCSourceFile(.{ .file = dep.path("src/platform/windows/webview2_host.cpp"), .flags = &.{"-std=c++17"} }),
+1 -1
View File
@@ -33,7 +33,7 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
<tr>
<td>Native rendering</td>
<td><Tier tier="full" note="Metal presentation with OS scroll physics" fn={2} /></td>
<td><Tier tier="caveats" note="deterministic software renderer, GDI blit" fn={2} /></td>
<td><Tier tier="caveats" note="deterministic software renderer, GDI blit; per-monitor DPI aware" fn={2} /></td>
<td><Tier tier="caveats" note="deterministic software renderer, cairo blit" fn={2} /></td>
<td><Tier tier="caveats" note="deterministic software renderer; the toolkit host presents through Metal" fn={2} /></td>
<td><Tier tier="caveats" note="deterministic software renderer; the toolkit host copies pixels into the surface" fn={2} /></td>
+7 -2
View File
@@ -64,7 +64,7 @@ const componentSizesEqual = component_scene.componentSizesEqual;
const componentTokensForScaleMotionAndContrast = component_scene.componentTokensForScaleMotionAndContrast;
const componentThemeModeForAppearance = component_scene.componentThemeModeForAppearance;
const normalizedPixelSnapScale = component_scene.normalizedPixelSnapScale;
const buildComponentsWidgetLayoutWithStateAndSize = component_scene.buildComponentsWidgetLayoutWithStateAndSize;
const buildComponentsWidgetLayoutWithStateSizeAndTokens = component_scene.buildComponentsWidgetLayoutWithStateSizeAndTokens;
const surfaceOverlayKind = component_scene.surfaceOverlayKind;
const surfaceOverlayFrameForSidebar = component_scene.surfaceOverlayFrameForSidebar;
const gpuFrameEvent = component_scene.gpuFrameEvent;
@@ -656,7 +656,12 @@ pub const GpuComponentsApp = struct {
pub fn updateComponentsCanvasModel(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId) anyerror!void {
var nodes: [max_component_widgets]canvas.WidgetLayoutNode = undefined;
const layout = try buildComponentsWidgetLayoutWithStateAndSize(&nodes, self.virtual_scroll, self.componentUiState(), self.canvas_size);
// Layout under the SAME tokens the display list is emitted
// with: under geometry pixel snapping, label-hugging intrinsic
// widths ceil to the snap grid, and only a token-matched layout
// keeps the renderer's edge snapping from shaving those widths
// below their labels (eliding text that fits unsnapped).
const layout = try buildComponentsWidgetLayoutWithStateSizeAndTokens(&nodes, self.virtual_scroll, self.componentUiState(), self.canvas_size, self.componentTokens());
_ = try runtime.setCanvasWidgetLayout(window_id, canvas_label, layout);
_ = try runtime.emitCanvasWidgetDisplayListWithStoredTokensAndChrome(window_id, canvas_label, .{
.prefix_command_count = component_chrome_prefix_commands,
+18 -3
View File
@@ -335,7 +335,7 @@ pub fn installComponentsCanvasModel(runtime: *native_sdk.Runtime, window_id: nat
var commands: [max_component_commands]canvas.CanvasCommand = undefined;
var nodes: [max_component_widgets]canvas.WidgetLayoutNode = undefined;
var builder = canvas.Builder.init(&commands);
const layout = try buildComponentsWidgetLayoutWithStateAndSize(&nodes, virtual_scroll, ui_state, surface_size);
const layout = try buildComponentsWidgetLayoutWithStateSizeAndTokens(&nodes, virtual_scroll, ui_state, surface_size, tokens);
try buildComponentsDisplayListForSize(&builder, layout, tokens, surface_size);
_ = try runtime.setCanvasDisplayList(window_id, canvas_label, builder.displayList());
_ = try runtime.setCanvasWidgetLayout(window_id, canvas_label, layout);
@@ -355,7 +355,7 @@ pub fn buildComponentsDisplayListFromWidgets(builder: *canvas.Builder) canvas.Er
/// is machine-verified pixel-for-pixel, not just the default one.
pub fn buildComponentsDisplayListFromWidgetsWithTokens(builder: *canvas.Builder, tokens: canvas.DesignTokens) canvas.Error!void {
var nodes: [max_component_widgets]canvas.WidgetLayoutNode = undefined;
const layout = try buildComponentsWidgetLayout(&nodes);
const layout = try buildComponentsWidgetLayoutWithStateSizeAndTokens(&nodes, .{}, .{}, default_canvas_size, tokens);
try buildComponentsDisplayList(builder, layout, tokens);
}
@@ -709,6 +709,21 @@ pub fn appendComponentWidget(output: []canvas.Widget, count: *usize, widget: can
}
pub fn buildComponentsWidgetLayoutWithStateAndSize(nodes: []canvas.WidgetLayoutNode, virtual_scroll: ComponentVirtualScroll, ui_state: ComponentUiState, surface_size: geometry.SizeF) canvas.Error!canvas.WidgetLayoutTree {
return buildComponentsWidgetLayoutWithStateSizeAndTokens(nodes, virtual_scroll, ui_state, surface_size, componentTokens());
}
/// Layout under an explicit token set — REQUIRED to be the same tokens
/// the display list is emitted with. Layout and render must agree on
/// `pixel_snap`: under geometry snapping, intrinsic label-hugging
/// widths ceil to the snap grid (a grid-aligned width survives the
/// renderer's per-edge rounding at any position), while a layout
/// computed under non-snapping tokens keeps exact fractional widths
/// that render-time edge snapping can shave below the label — eliding
/// text that fits unsnapped. The wrappers above default to the
/// catalog's stock tokens; any caller rendering under other tokens
/// (theme packs, dark scheme, surface scale) must lay out here with
/// those same tokens.
pub fn buildComponentsWidgetLayoutWithStateSizeAndTokens(nodes: []canvas.WidgetLayoutNode, virtual_scroll: ComponentVirtualScroll, ui_state: ComponentUiState, surface_size: geometry.SizeF, tokens: canvas.DesignTokens) canvas.Error!canvas.WidgetLayoutTree {
const nav_items = [_]canvas.Widget{
.{ .id = 121, .kind = .list_item, .text = "Controls", .state = .{ .selected = true } },
.{ .id = 122, .kind = .list_item, .text = "Inputs" },
@@ -993,7 +1008,7 @@ pub fn buildComponentsWidgetLayoutWithStateAndSize(nodes: []canvas.WidgetLayoutN
}));
}
return canvas.layoutWidgetTree(.{ .kind = .stack, .children = root_widgets[0..root_widget_count] }, rect(0, 0, size.width, size.height), nodes);
return canvas.layoutWidgetTreeWithTokens(.{ .kind = .stack, .children = root_widgets[0..root_widget_count] }, rect(0, 0, size.width, size.height), tokens, nodes);
}
pub fn componentFrame(display_list: canvas.DisplayList, previous: ?canvas.DisplayList, options: canvas.CanvasFrameOptions, storage: canvas.CanvasFrameStorage) canvas.Error!canvas.CanvasFrame {
+40 -7
View File
@@ -616,7 +616,7 @@ test "gpu components display list renders stable reference snapshot" {
// on checked/filled states. Update deliberately when component
// rendering changes, reviewing the rendered pixels (reference render
// dump or docs previews — same emitters) first.
try std.testing.expectEqual(@as(u64, 4863232662243686658), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 9599670827131584552), referenceSurfaceSignature(pixels));
try expectVisiblePixel(surface.pixelRgba8(36, 36));
try expectVisiblePixel(surface.pixelRgba8(92, 88));
try expectVisiblePixel(surface.pixelRgba8(330, 160));
@@ -693,7 +693,7 @@ test "gpu components display list renders stable geist reference snapshot" {
const scratch = try std.testing.allocator.alloc(u8, pixel_count);
defer std.testing.allocator.free(scratch);
const surface = try renderComponentsReferenceSurface(componentTokensForPack(.geist, .light), pixels, scratch);
try std.testing.expectEqual(@as(u64, 13313358543749413523), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 12445785486770386627), referenceSurfaceSignature(pixels));
try expectVisiblePixel(surface.pixelRgba8(36, 36));
try expectVisiblePixel(surface.pixelRgba8(92, 88));
try expectVisiblePixel(surface.pixelRgba8(330, 160));
@@ -785,12 +785,12 @@ test "geist button group renders the detached secondary-tab register in both sch
// one step short of the pack's pure-black light primary — probed at
// the chip's lower body, clear of the knockout label.
try std.testing.expectEqual([4]u8{ 23, 23, 23, 255 }, light.pixelRgba8(30, 48));
try std.testing.expectEqual(@as(u64, 8187074409027429810), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 13158221911267186466), referenceSurfaceSignature(pixels));
const dark = try renderButtonGroupReferenceSurface(componentTokensForPack(.geist, .dark), pixels, scratch);
// Dark inverts to porcelain #ededed.
try std.testing.expectEqual([4]u8{ 237, 237, 237, 255 }, dark.pixelRgba8(30, 48));
try std.testing.expectEqual(@as(u64, 272499359279849024), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 9922762454948493824), referenceSurfaceSignature(pixels));
}
test "house button group keeps the attached segmented bar through the shared specimen" {
@@ -804,7 +804,7 @@ test "house button group keeps the attached segmented bar through the shared spe
const scratch = try std.testing.allocator.alloc(u8, button_group_surface_pixels);
defer std.testing.allocator.free(scratch);
_ = try renderButtonGroupReferenceSurface(componentTokens(), pixels, scratch);
try std.testing.expectEqual(@as(u64, 12529290438367463158), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 16337717838073850632), referenceSurfaceSignature(pixels));
}
/// Render a two-trigger tab strip (one active) on a small reference
@@ -922,7 +922,7 @@ test "house tabs keep the flush pill strip through the shared specimen" {
const scratch = try std.testing.allocator.alloc(u8, tabs_surface_pixels);
defer std.testing.allocator.free(scratch);
_ = try renderTabsReferenceSurface(componentTokens(), pixels, scratch);
try std.testing.expectEqual(@as(u64, 1813963338460688705), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 15995515449431694753), referenceSurfaceSignature(pixels));
}
test "gpu components house reference snapshot is reproducible through the shared per-theme path" {
@@ -935,7 +935,7 @@ test "gpu components house reference snapshot is reproducible through the shared
const scratch = try std.testing.allocator.alloc(u8, pixel_count);
defer std.testing.allocator.free(scratch);
_ = try renderComponentsReferenceSurface(componentTokens(), pixels, scratch);
try std.testing.expectEqual(@as(u64, 4863232662243686658), referenceSurfaceSignature(pixels));
try std.testing.expectEqual(@as(u64, 9599670827131584552), referenceSurfaceSignature(pixels));
}
test "gpu components catalog previews use canonical built-in foundations" {
@@ -2190,3 +2190,36 @@ test "gpu components sidebar handle drag resizes retained layout" {
try std.testing.expectEqual(@as(f32, 0), drawer_frame.x);
try std.testing.expectEqual(canvas_width, drawer_frame.width);
}
test "theme strip triggers never elide under geometry pixel snapping" {
// The toolbar's segmented theme strip hugs its measured labels, so
// its triggers sit exactly at the elision threshold. Under geometry
// snapping the renderer rounds every frame edge to the device grid,
// and a fractional label-exact width can lose most of a pixel to
// that rounding — swapping the third label's glyphs for a trailing
// ellipsis while the unsnapped layout fits it. The guard is
// layout/render token agreement: the scene lays out with the SAME
// tokens it renders with, so intrinsic label widths ceil to the
// snap grid and the snapped frame is never narrower than its label.
// Assert on the drawn text runs of all three triggers under every
// pack the catalog pins, snapping on, 1x — the exact configuration
// that elided "High" when the layout ran under mismatched tokens.
const packs = [_]canvas.ThemePack{ .house, .geist };
for (packs) |pack| {
const tokens = componentTokensForPack(pack, .light);
try std.testing.expect(tokens.pixel_snap.geometry);
var commands: [max_component_commands]canvas.CanvasCommand = undefined;
var builder = canvas.Builder.init(&commands);
try buildComponentsDisplayListFromWidgetsWithTokens(&builder, tokens);
const display_list = builder.displayList();
const modes = [_]ComponentThemeMode{ .light, .dark, .high };
for (modes) |mode| {
const ref = display_list.findCommandById(canvas.widgetPartId(themeModeTriggerId(mode), 3)) orelse return error.MissingTriggerText;
const text = ref.command.draw_text;
var lines: [4]canvas.TextLine = undefined;
const text_layout = try canvas.layoutTextRun(text, text.text_layout.?, &lines);
try std.testing.expect(text_layout.lines.len > 0);
for (text_layout.lines) |line| try std.testing.expect(!line.isElided());
}
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ const expected_dashboard_interaction_command_count: usize = 70;
// deliberately when rendering changes, reviewing the rendered pixels
// (reference captures or the docs previews — same emitters) first; the
// spot pixels below still guard basic visibility.
const expected_dashboard_reference_signature: u64 = 2871108290690187392;
const expected_dashboard_reference_signature: u64 = 14864234856790650620;
const expected_dashboard_widget_node_count: usize = 48;
const expected_dashboard_snapshot_widget_count: usize = 48;
const refresh_command = "dashboard.refresh";
@@ -132,7 +132,13 @@ if (errors.length > 0) {
if (errors.length > 20) {
console.error(` ... ${errors.length - 20} more`);
}
console.error('\nRun "node packages/native-sdk/scripts/copy-framework.js" from the repo root.');
console.error('');
console.error('The package mirror is GENERATED output: copy-framework.js stages it');
console.error('from the repo-root framework sources (prepack and scripts:check run');
console.error('the copy first), and the mirror paths are gitignored — committing the');
console.error('mirror is not the fix. Regenerate it, then re-run this check:');
console.error('');
console.error(' node packages/native-sdk/scripts/copy-framework.js (from the repo root)');
process.exit(1);
}
+266 -64
View File
@@ -1124,6 +1124,8 @@ static std::string extractHtmlClipboardFragment(const std::string &payload) {
return payload;
}
static UINT dpiForWindow(HWND hwnd);
static void emit(Host *host, const Window &window, EventKind kind) {
if (!host || !host->callback) return;
RECT rect = {};
@@ -1131,9 +1133,14 @@ static void emit(Host *host, const Window &window, EventKind kind) {
WindowsEvent event = {};
event.kind = kind;
event.window_id = window.id;
event.width = rect.right > rect.left ? (double)(rect.right - rect.left) : window.width;
event.height = rect.bottom > rect.top ? (double)(rect.bottom - rect.top) : window.height;
event.scale = 1.0;
/* Window geometry crosses the runtime boundary in LOGICAL points:
* the client rect is physical pixels, so divide by the window's
* device scale. In a DPI-unaware process the reported DPI is 96 and
* the two units coincide, so this stays the identity there. */
const double scale = window.hwnd ? (double)dpiForWindow(window.hwnd) / 96.0 : 1.0;
event.width = rect.right > rect.left ? (double)(rect.right - rect.left) / scale : window.width;
event.height = rect.bottom > rect.top ? (double)(rect.bottom - rect.top) / scale : window.height;
event.scale = scale;
event.x = window.x;
event.y = window.y;
event.open = window.hwnd != nullptr;
@@ -1421,18 +1428,52 @@ static int webViewExtent(double value) {
return value > 1 ? (int)(value + 0.5) : 1;
}
/* Explicit webview frames arrive from the runtime in LOGICAL points and
* scale to physical pixels at the window's DPI, exactly like native view
* frames. The auto-filling main webview is the one exception: its frame
* is copied straight from the physical client rect (the top-level
* WM_SIZE handler), so it passes through unscaled. */
static double webViewFrameScale(const ChildWebView &webview) {
if (!webview.frame_explicit || !webview.hwnd) return 1.0;
return (double)dpiForWindow(webview.hwnd) / 96.0;
}
static bool validChildWebViewFrame(double x, double y, double width, double height) {
return x >= 0 && y >= 0 && width > 0 && height > 0;
}
static int nativeViewCoord(double value) {
static constexpr int nativeViewCoord(double value) {
return value > 0 ? (int)(value + 0.5) : 0;
}
static int nativeViewExtent(double value) {
return value > 0 ? (int)(value + 0.5) : 0;
/* Compile-time proof of the accumulate-then-round frame policy (see
* nativeViewPhysicalFrame): rounding each nesting level separately drifts
* from the round of the logical sum parent x=10.4 plus child x=10.4 at
* scale 1.5 is round(15.6) + round(15.6) = 32, one pixel right of the
* true origin round(20.8 * 1.5) = 31 and an independently rounded
* extent opens the same one-pixel seam against an edge derived from the
* accumulated logical coordinates: origin 10.2 with width 10.2 at scale
* 1.5 ends at round(15.3) + round(15.3) = 30, but the true right edge is
* round(20.4 * 1.5) = 31. */
static_assert(nativeViewCoord(10.4 * 1.5) + nativeViewCoord(10.4 * 1.5) == 32 && nativeViewCoord((10.4 + 10.4) * 1.5) == 31,
"per-level rounding must drift so the accumulate-then-round policy is load-bearing");
static_assert(nativeViewCoord(10.2 * 1.5) + nativeViewCoord(10.2 * 1.5) == 30 && nativeViewCoord((10.2 + 10.2) * 1.5) == 31,
"independently rounded extents must open seams that edge-derived extents close");
/* A scaled window CONTENT extent rounded to whole physical pixels — the
* same round-once policy as native view frames (see
* nativeViewPhysicalFrame). Truncating instead would land fractional-DPI
* windows one physical pixel short of the request: logical 726 at 125%
* is 907.5 physical, which must become 908, not 907. Every conversion of
* a scaled content size to a physical extent goes through here so the
* standard-chrome and hidden-titlebar paths cannot drift apart. */
static constexpr LONG physicalContentExtent(double value) {
return (LONG)(value + 0.5);
}
static_assert(physicalContentExtent(726 * 1.25) == 908 && (LONG)(726 * 1.25) == 907,
"truncation must land a pixel short of the round so the rounding is load-bearing");
static bool validNativeViewFrame(double x, double y, double width, double height) {
return x >= 0 && y >= 0 && width >= 0 && height >= 0;
}
@@ -1507,17 +1548,53 @@ static void applySegmentedControlText(HWND hwnd, const std::string &text) {
TabCtrl_SetCurSel(hwnd, 0);
}
static POINT nativeViewAbsoluteOrigin(Host *host, const NativeView &view) {
POINT point = { nativeViewCoord(view.x), nativeViewCoord(view.y) };
if (!host || view.parent.empty()) return point;
/* Scale for converting the runtime's LOGICAL view frames into physical
* client pixels: the owning top-level window's DPI over 96. A
* DPI-unaware process reports 96, so this is 1.0 there and view frames
* pass through unchanged. */
static double nativeViewFrameScale(Host *host, const NativeView &view) {
if (!host) return 1.0;
auto window = host->windows.find(view.window_id);
if (window == host->windows.end() || !window->second.hwnd) return 1.0;
return (double)dpiForWindow(window->second.hwnd) / 96.0;
}
/* Absolute LOGICAL origin of a view: its own frame origin plus every
* ancestor's, summed before any scaling or rounding. */
static void nativeViewLogicalOrigin(Host *host, const NativeView &view, double *logical_x, double *logical_y) {
*logical_x = view.x;
*logical_y = view.y;
if (!host || view.parent.empty()) return;
auto parent = host->native_views.find(nativeViewKey(view.window_id, view.parent));
while (parent != host->native_views.end()) {
point.x += nativeViewCoord(parent->second.x);
point.y += nativeViewCoord(parent->second.y);
*logical_x += parent->second.x;
*logical_y += parent->second.y;
if (parent->second.parent.empty()) break;
parent = host->native_views.find(nativeViewKey(parent->second.window_id, parent->second.parent));
}
return point;
}
/* Physical frame policy: every physical EDGE is the once-rounded product
* of an ACCUMULATED logical coordinate and the window scale. Accumulating
* before rounding matters because the sum of per-level rounds drifts from
* the round of the sum at fractional scales (and at fractional logical
* coordinates even at scale 1.0) see the static_asserts beside
* nativeViewCoord for the numeric proof. Width and height fall out as
* edge differences (right = round((logical_x + width) * scale)) rather
* than independently rounded extents, so frames that abut logically
* a sibling starting where the previous one ends, a child flush against
* its parent's edge land on the same physical pixel column with no
* one-pixel gap or overlap at any scale. */
static RECT nativeViewPhysicalFrame(Host *host, const NativeView &view, double scale) {
double logical_x = 0;
double logical_y = 0;
nativeViewLogicalOrigin(host, view, &logical_x, &logical_y);
RECT frame = {};
frame.left = nativeViewCoord(logical_x * scale);
frame.top = nativeViewCoord(logical_y * scale);
frame.right = nativeViewCoord((logical_x + view.width) * scale);
frame.bottom = nativeViewCoord((logical_y + view.height) * scale);
return frame;
}
static void applyNativeViewText(NativeView &view, const std::string &text) {
@@ -1561,8 +1638,9 @@ static void applyNativeViewAccessibility(NativeView &view) {
static void applyNativeViewFrame(Host *host, NativeView &view) {
if (!view.hwnd) return;
POINT origin = nativeViewAbsoluteOrigin(host, view);
MoveWindow(view.hwnd, origin.x, origin.y, nativeViewExtent(view.width), nativeViewExtent(view.height), TRUE);
const double scale = nativeViewFrameScale(host, view);
RECT frame = nativeViewPhysicalFrame(host, view, scale);
MoveWindow(view.hwnd, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, TRUE);
}
static void applyNativeViewState(NativeView &view, bool update_text, const std::string &text) {
@@ -1740,20 +1818,12 @@ static uint64_t gpuTimestampNs() {
return seconds * 1000000000ull + remainder * 1000000000ull / (uint64_t)frequency.QuadPart;
}
/* Device scale for a gpu_surface child. GetDpiForWindow is resolved
* dynamically so the host keeps working on Windows versions (and Wine
* prefixes) that predate per-monitor DPI. In a DPI-unaware process the
* call reports 96, so logical size == client pixels, matching how the
* rest of this host treats coordinates. */
/* Device scale for a gpu_surface child: the shared per-window DPI
* resolution (dpiForWindow) over the 96-dpi baseline. In a DPI-unaware
* process the resolved DPI is 96, so logical size == client pixels,
* matching how the rest of this host treats coordinates. */
static double gpuSurfaceScale(HWND hwnd) {
using GetDpiForWindowFn = UINT(WINAPI *)(HWND);
static GetDpiForWindowFn get_dpi = reinterpret_cast<GetDpiForWindowFn>(
reinterpret_cast<void *>(GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow")));
if (get_dpi && hwnd) {
const UINT dpi = get_dpi(hwnd);
if (dpi > 0) return (double)dpi / 96.0;
}
return 1.0;
return hwnd ? (double)dpiForWindow(hwnd) / 96.0 : 1.0;
}
static NativeView *gpuSurfaceViewForHwnd(Host *host, HWND hwnd) {
@@ -1846,8 +1916,17 @@ static Window *chromelessWindowForHwnd(Host *host, HWND hwnd) {
return nullptr;
}
/* Per-window dpi with the same dynamic resolution (and the same 96
* fallback) as gpuSurfaceScale. */
static UINT systemDpi();
/* Per-window DPI, resolved dynamically to mirror the awareness chain
* the embedded manifest declares. GetDpiForWindow (Windows 10 1607+,
* per-monitor v2 modern Wine prefixes export it too) is preferred;
* where it is absent, shcore's GetDpiForMonitor reports the effective
* DPI of the window's monitor (Windows 8.1+, matching per-monitor v1
* awareness); where shcore is absent too, the system DPI (matching
* system-DPI awareness; older Wine prefixes land here through
* systemDpi's GetDeviceCaps branch). A DPI-unaware process reports 96
* on every branch, so logical points == client pixels there. */
static UINT dpiForWindow(HWND hwnd) {
using GetDpiForWindowFn = UINT(WINAPI *)(HWND);
static GetDpiForWindowFn get_dpi = reinterpret_cast<GetDpiForWindowFn>(
@@ -1856,7 +1935,22 @@ static UINT dpiForWindow(HWND hwnd) {
const UINT dpi = get_dpi(hwnd);
if (dpi > 0) return dpi;
}
return 96;
using GetDpiForMonitorFn = HRESULT(WINAPI *)(HMONITOR, int, UINT *, UINT *);
static GetDpiForMonitorFn get_monitor_dpi = []() -> GetDpiForMonitorFn {
HMODULE shcore = LoadLibraryW(L"shcore.dll");
if (!shcore) return nullptr;
return reinterpret_cast<GetDpiForMonitorFn>(
reinterpret_cast<void *>(GetProcAddress(shcore, "GetDpiForMonitor")));
}();
if (get_monitor_dpi && hwnd) {
HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
UINT dpi_x = 0;
UINT dpi_y = 0;
/* 0 = MDT_EFFECTIVE_DPI, spelled numerically so headers that
* predate shellscalingapi.h still compile. */
if (monitor && get_monitor_dpi(monitor, 0, &dpi_x, &dpi_y) == S_OK && dpi_y > 0) return dpi_y;
}
return systemDpi();
}
/* Caption metrics MUST scale with the monitor the window sits on, or a
@@ -1871,6 +1965,43 @@ static int systemMetricForDpi(int index, UINT dpi) {
return GetSystemMetrics(index);
}
/* System DPI for sizing a window that does not exist yet (creation
* scales the requested logical content size to physical pixels before
* the first monitor is known; WM_DPICHANGED re-derives the frame when
* the window lands elsewhere), and the tail of dpiForWindow's chain
* where no per-monitor API is available. GetDpiForSystem is resolved
* dynamically (Windows 10 1607+) with the desktop DC's LOGPIXELSY,
* then 96, as fallbacks. A DPI-unaware process reports 96 on every
* branch, keeping points == pixels exactly as before. */
static UINT systemDpi() {
using GetDpiForSystemFn = UINT(WINAPI *)();
static GetDpiForSystemFn get_dpi = reinterpret_cast<GetDpiForSystemFn>(
reinterpret_cast<void *>(GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForSystem")));
if (get_dpi) {
const UINT dpi = get_dpi();
if (dpi > 0) return dpi;
}
HDC dc = GetDC(nullptr);
if (dc) {
const int dpi = GetDeviceCaps(dc, LOGPIXELSY);
ReleaseDC(nullptr, dc);
if (dpi > 0) return (UINT)dpi;
}
return 96;
}
/* AdjustWindowRectEx pinned to an explicit DPI so the frame borders
* match the monitor the window is being sized against; resolved
* dynamically (Windows 10 1607+) with the classic system-metric call
* as the fallback. */
static BOOL adjustWindowRectForDpi(RECT *rect, DWORD style, BOOL menu, DWORD ex_style, UINT dpi) {
using AdjustWindowRectExForDpiFn = BOOL(WINAPI *)(LPRECT, DWORD, BOOL, DWORD, UINT);
static AdjustWindowRectExForDpiFn adjust = reinterpret_cast<AdjustWindowRectExForDpiFn>(
reinterpret_cast<void *>(GetProcAddress(GetModuleHandleW(L"user32.dll"), "AdjustWindowRectExForDpi")));
if (adjust) return adjust(rect, style, menu, ex_style, dpi);
return AdjustWindowRectEx(rect, style, menu, ex_style);
}
/* Thickness of the top resize frame (sizing border + padded border) at
* the window's dpi: the WM_NCCALCSIZE maximize inset and the
* WM_NCHITTEST top resize band are both exactly this tall. */
@@ -1943,12 +2074,12 @@ static void applyHiddenTitlebarFrame(Window &window) {
* bottom borders, plus the menu bar when one is attached. Plain
* AdjustWindowRectEx would count the caption band the custom calc gives
* back, landing the client one band taller than requested. */
static SIZE hiddenOuterSizeForContent(DWORD style, DWORD ex_style, bool has_menu, double content_width, double content_height) {
static SIZE hiddenOuterSizeForContent(DWORD style, DWORD ex_style, bool has_menu, double content_width, double content_height, UINT dpi) {
RECT borders = { 0, 0, 0, 0 };
AdjustWindowRectEx(&borders, style & ~WS_CAPTION, FALSE, ex_style);
adjustWindowRectForDpi(&borders, style & ~WS_CAPTION, FALSE, ex_style, dpi);
SIZE outer = {};
outer.cx = (LONG)content_width + (borders.right - borders.left);
outer.cy = (LONG)content_height + borders.bottom + (has_menu ? GetSystemMetrics(SM_CYMENU) : 0);
outer.cx = physicalContentExtent(content_width) + (borders.right - borders.left);
outer.cy = physicalContentExtent(content_height) + borders.bottom + (has_menu ? systemMetricForDpi(SM_CYMENU, dpi) : 0);
return outer;
}
@@ -3942,17 +4073,19 @@ static const wchar_t *nativeSdkBridgeScript() {
}
static RECT webViewRect(const ChildWebView &webview) {
const double scale = webViewFrameScale(webview);
RECT rect = {};
rect.left = 0;
rect.top = 0;
rect.right = webViewExtent(webview.width);
rect.bottom = webViewExtent(webview.height);
rect.right = webViewExtent(webview.width * scale);
rect.bottom = webViewExtent(webview.height * scale);
return rect;
}
static void applyWebViewFrame(ChildWebView &webview) {
if (!webview.hwnd) return;
MoveWindow(webview.hwnd, webViewCoord(webview.x), webViewCoord(webview.y), webViewExtent(webview.width), webViewExtent(webview.height), TRUE);
const double scale = webViewFrameScale(webview);
MoveWindow(webview.hwnd, webViewCoord(webview.x * scale), webViewCoord(webview.y * scale), webViewExtent(webview.width * scale), webViewExtent(webview.height * scale), TRUE);
if (webview.controller) {
RECT bounds = webViewRect(webview);
webview.controller->put_Bounds(bounds);
@@ -4545,6 +4678,56 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
}
}
return 0;
case WM_DPICHANGED:
/* The window moved to a monitor with a different DPI (or the
* user changed display scaling). Adopt the system-suggested
* frame it keeps the window the same LOGICAL size on the
* new scale then re-derive everything DPI-dependent: the
* hidden-titlebar band (its caption metrics scale with the
* monitor), each native child's physical frame, every gpu
* surface's logical-size/scale pairing so the runtime
* re-rasterizes at the new density, and each explicit child
* webview's physical frame. The SetWindowPos WM_SIZE
* re-emits kResize, which now carries the new scale. Only
* per-monitor-DPI-aware processes receive this message. */
if (host) {
const RECT *suggested = reinterpret_cast<const RECT *>(lparam);
if (suggested) {
SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, suggested->right - suggested->left, suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE);
}
for (auto &entry : host->windows) {
if (entry.second.hwnd == hwnd && windowUsesHiddenTitlebar(entry.second)) applyHiddenTitlebarFrame(entry.second);
}
for (auto &view_entry : host->native_views) {
NativeView &view = view_entry.second;
if (!view.hwnd || GetAncestor(view.hwnd, GA_ROOT) != hwnd) continue;
applyNativeViewFrame(host, view);
if (view.kind != kViewGpuSurface) continue;
const double surface_scale = gpuSurfaceScale(view.hwnd);
double width = 0;
double height = 0;
if (gpuSurfaceLogicalSize(view, view.hwnd, surface_scale, &width, &height)) {
(void)syncGpuSurfaceGeometry(host, view, width, height, surface_scale);
}
}
#if NATIVE_SDK_HAS_WEBVIEW2
for (auto &webview_entry : host->webviews) {
ChildWebView &webview = webview_entry.second;
/* Explicit frames are LOGICAL points scaled to
* physical pixels at apply time, so a monitor-scale
* change strands them until re-applied here. The
* auto-fill main webview stores PHYSICAL client-rect
* pixels and already re-derived them in the WM_SIZE
* that the SetWindowPos above dispatched, so it is
* skipped. */
if (!webview.frame_explicit) continue;
if (!webview.hwnd || GetAncestor(webview.hwnd, GA_ROOT) != hwnd) continue;
applyWebViewFrame(webview);
}
#endif
return 0;
}
break;
case WM_TIMER:
if (host && handleAppTimerMessage(host, wparam)) return 0;
if (host && handleAudioTimerMessage(host, wparam)) return 0;
@@ -4558,20 +4741,26 @@ static LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wparam, LPARA
Window &window = entry.second;
if (window.hwnd != hwnd) continue;
if (window.min_width <= 0 && window.min_height <= 0) break;
/* The declared floor is a CONTENT size; convert to the
* outer track size for this window's current style.
* Hidden titlebar styles carry no top chrome (the
* custom WM_NCCALCSIZE hands the caption band to the
* client), so their conversion skips it too. */
RECT frame = { 0, 0, (LONG)(window.min_width > 0 ? window.min_width : 0), (LONG)(window.min_height > 0 ? window.min_height : 0) };
/* The declared floor is a CONTENT size in LOGICAL
* points; scale to physical pixels at this window's
* DPI, then convert to the outer track size for its
* current style. Hidden titlebar styles carry no top
* chrome (the custom WM_NCCALCSIZE hands the caption
* band to the client), so their conversion skips it
* too. */
const UINT dpi = dpiForWindow(hwnd);
const double scale = (double)dpi / 96.0;
const double min_content_width = window.min_width > 0 ? window.min_width * scale : 0;
const double min_content_height = window.min_height > 0 ? window.min_height * scale : 0;
RECT frame = { 0, 0, physicalContentExtent(min_content_width), physicalContentExtent(min_content_height) };
const DWORD style = (DWORD)GetWindowLongPtrW(hwnd, GWL_STYLE);
const DWORD ex_style = (DWORD)GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
const bool has_menu = GetMenu(hwnd) != nullptr;
AdjustWindowRectEx(&frame, style, has_menu, ex_style);
adjustWindowRectForDpi(&frame, style, has_menu, ex_style, dpi);
LONG outer_width = frame.right - frame.left;
LONG outer_height = frame.bottom - frame.top;
if (windowUsesHiddenTitlebar(window)) {
const SIZE outer = hiddenOuterSizeForContent(style, ex_style, has_menu, window.min_width > 0 ? window.min_width : 0, window.min_height > 0 ? window.min_height : 0);
const SIZE outer = hiddenOuterSizeForContent(style, ex_style, has_menu, min_content_width, min_content_height, dpi);
outer_width = outer.cx;
outer_height = outer.cy;
}
@@ -4640,19 +4829,26 @@ static bool createNativeWindow(Host *host, Window &window) {
style = WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX;
if (window.resizable) style |= WS_THICKFRAME | WS_MAXIMIZEBOX;
}
/* The requested frame is a CONTENT size (the other hosts size the
* content area); grow it to the outer size for this style so the
* client rect lands at the request. The menu bar is attached after
* creation, so account for it here when menus are declared. Hidden
* styles use the custom-calc shape (no top chrome) plain
* AdjustWindowRectEx would land their client one caption band tall. */
/* The requested frame is a CONTENT size in LOGICAL points (the
* other hosts size the content area); scale it to physical pixels
* at the DPI the window opens at (the system DPI WM_DPICHANGED
* re-derives the frame if it lands on another monitor), then grow
* it to the outer size for this style so the client rect lands at
* the request. The menu bar is attached after creation, so account
* for it here when menus are declared. Hidden styles use the
* custom-calc shape (no top chrome) plain adjustment would land
* their client one caption band tall. */
const bool has_menu = !host->menus.empty();
RECT frame = { 0, 0, (LONG)window.width, (LONG)window.height };
AdjustWindowRectEx(&frame, style, has_menu ? TRUE : FALSE, 0);
const UINT dpi = systemDpi();
const double scale = (double)dpi / 96.0;
const double content_width = window.width * scale;
const double content_height = window.height * scale;
RECT frame = { 0, 0, physicalContentExtent(content_width), physicalContentExtent(content_height) };
adjustWindowRectForDpi(&frame, style, has_menu ? TRUE : FALSE, 0, dpi);
LONG outer_width = frame.right - frame.left;
LONG outer_height = frame.bottom - frame.top;
if (windowUsesHiddenTitlebar(window)) {
const SIZE outer = hiddenOuterSizeForContent(style, 0, has_menu, window.width, window.height);
const SIZE outer = hiddenOuterSizeForContent(style, 0, has_menu, content_width, content_height, dpi);
outer_width = outer.cx;
outer_height = outer.cy;
}
@@ -5367,16 +5563,17 @@ int native_sdk_windows_create_view(Host *host, uint64_t window_id, const char *l
}
if (view.visible) style |= WS_VISIBLE;
POINT origin = nativeViewAbsoluteOrigin(host, view);
const double scale = nativeViewFrameScale(host, view);
RECT frame = nativeViewPhysicalFrame(host, view, scale);
HWND hwnd = CreateWindowExW(
ex_style,
class_name.c_str(),
wide_text.c_str(),
style,
origin.x,
origin.y,
nativeViewExtent(width),
nativeViewExtent(height),
frame.left,
frame.top,
frame.right - frame.left,
frame.bottom - frame.top,
window->second.hwnd,
nullptr,
host->instance,
@@ -5923,15 +6120,19 @@ int native_sdk_windows_create_webview(Host *host, uint64_t window_id, const char
if (host->webviews.find(key) != host->webviews.end()) return 0;
std::string url_string = slice(url, url_len);
/* The requested frame is in logical points (frame_explicit child
* webviews scale like native view frames); physical placement at
* the owning window's DPI. */
const double frame_scale = (double)dpiForWindow(window->second.hwnd) / 96.0;
HWND hwnd = CreateWindowExW(
0,
L"STATIC",
L"",
WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
webViewCoord(x),
webViewCoord(y),
webViewExtent(width),
webViewExtent(height),
webViewCoord(x * frame_scale),
webViewCoord(y * frame_scale),
webViewExtent(width * frame_scale),
webViewExtent(height * frame_scale),
window->second.hwnd,
nullptr,
host->instance,
@@ -5973,7 +6174,8 @@ int native_sdk_windows_set_webview_frame(Host *host, uint64_t window_id, const c
found->second.width = width;
found->second.height = height;
found->second.frame_explicit = true;
MoveWindow(found->second.hwnd, webViewCoord(x), webViewCoord(y), webViewExtent(width), webViewExtent(height), TRUE);
const double frame_scale = webViewFrameScale(found->second);
MoveWindow(found->second.hwnd, webViewCoord(x * frame_scale), webViewCoord(y * frame_scale), webViewExtent(width * frame_scale), webViewExtent(height * frame_scale), TRUE);
#if NATIVE_SDK_HAS_WEBVIEW2
if (found->second.controller) {
RECT bounds = webViewRect(found->second);
+2 -2
View File
@@ -722,8 +722,8 @@ test "chart golden: line + bar + band render byte-identically in light and dark"
// category labels (q1, q3) under the bars. Both themes clear with their
// background token. Update deliberately when chart rendering changes,
// reviewing the dumped pixels first.
const golden_light_signature: u64 = 11760269401975515075;
const golden_dark_signature: u64 = 1706071444071293822;
const golden_light_signature: u64 = 11237334215963301158;
const golden_dark_signature: u64 = 4053146140543071333;
fn goldenDumpRequested() bool {
if (comptime !@import("builtin").link_libc) return false;
+1 -1
View File
@@ -405,7 +405,7 @@ test "the README-shaped fixture renders through the mapper and the reference ren
// separators, fenced-code panels, and near-black underlined links.
// Update deliberately when markdown rendering changes, reviewing the
// rendered pixels first (see reference_tests.zig conventions).
const markdown_document_reference_signature: u64 = 1138378532370101207;
const markdown_document_reference_signature: u64 = 6015448079037287912;
test "bare URLs autolink at word boundaries with trailing punctuation trimmed" {
+82 -3
View File
@@ -295,12 +295,21 @@ pub const ReferenceRenderSurface = struct {
// so no apron rows join the key.
const probe = self.memoProbe(pixel_rect, 0, referenceMemoParamsHash(2, command, value));
if (self.memoReplay(probe, pixel_rect)) return;
// Square-cornered fills keep the legacy binary pixel-center
// test (bit-identical on integer bounds); rounded fills take
// the anti-aliased signed-distance coverage.
const binary = referenceRadiusIsZero(radius);
var y = pixel_rect.y;
while (y < pixel_rect.y + pixel_rect.height) : (y += 1) {
var x = pixel_rect.x;
while (x < pixel_rect.x + pixel_rect.width) : (x += 1) {
const point = referencePixelCenter(x, y);
if (referencePointInRoundedRect(point, rect, radius)) self.blendPixel(@intCast(x), @intCast(y), referenceSampleFill(value.fill, command.transform, point), command.opacity);
if (binary) {
if (referencePointInRoundedRect(point, rect, radius)) self.blendPixel(@intCast(x), @intCast(y), referenceSampleFill(value.fill, command.transform, point), command.opacity);
continue;
}
const coverage = referenceRoundedRectCoverage(point, rect, radius);
if (coverage > 0) self.blendPixel(@intCast(x), @intCast(y), referenceScaleColorAlpha(referenceSampleFill(value.fill, command.transform, point), coverage), command.opacity);
}
}
self.memoStore(probe, pixel_rect);
@@ -323,13 +332,28 @@ pub const ReferenceRenderSurface = struct {
// blends into, so no apron rows join the key.
const probe = self.memoProbe(pixel_rect, 0, referenceMemoParamsHash(5, command, value));
if (self.memoReplay(probe, pixel_rect)) return;
// Square-cornered borders keep the legacy binary ring test
// (bit-identical on integer bounds). Rounded borders derive
// BOTH ring edges from the same signed-distance field —
// coverage is outer-edge coverage minus inner-edge coverage —
// so the border weight stays uniform around the whole shape
// (the inner edge stays in the field even when the inset
// radius bottoms out at a square corner).
const binary = referenceRadiusIsZero(radius);
var y = pixel_rect.y;
while (y < pixel_rect.y + pixel_rect.height) : (y += 1) {
var x = pixel_rect.x;
while (x < pixel_rect.x + pixel_rect.width) : (x += 1) {
const point = referencePixelCenter(x, y);
if (referencePointInRoundedRect(point, outer, outer_radius) and !referencePointInRoundedRect(point, inner, inner_radius)) {
self.blendPixel(@intCast(x), @intCast(y), referenceSampleFill(value.stroke.fill, command.transform, point), command.opacity);
if (binary) {
if (referencePointInRoundedRect(point, outer, outer_radius) and !referencePointInRoundedRect(point, inner, inner_radius)) {
self.blendPixel(@intCast(x), @intCast(y), referenceSampleFill(value.stroke.fill, command.transform, point), command.opacity);
}
continue;
}
const coverage = std.math.clamp(referenceRoundedRectCoverage(point, outer, outer_radius) - referenceRoundedRectCoverage(point, inner, inner_radius), 0, 1);
if (coverage > 0) {
self.blendPixel(@intCast(x), @intCast(y), referenceScaleColorAlpha(referenceSampleFill(value.stroke.fill, command.transform, point), coverage), command.opacity);
}
}
}
@@ -1359,6 +1383,61 @@ fn referencePointInRoundedRect(point: geometry.PointF, rect: geometry.RectF, rad
return true;
}
/// True when every corner is square — the gate for the legacy binary
/// pixel-center rasterization, which keeps radius-0 rects on integer
/// bounds bit-identical to their historical bytes.
fn referenceRadiusIsZero(radius: Radius) bool {
return radius.top_left <= 0 and radius.top_right <= 0 and
radius.bottom_right <= 0 and radius.bottom_left <= 0;
}
/// Fractional coverage of the pixel centered at `point` against a
/// rounded rect: `clamp(0.5 - d, 0, 1)` of the EXACT signed distance to
/// the shape boundary. One closed form serves arcs and straight
/// segments alike, so coverage is a continuous function around the
/// whole perimeter — a per-region model (arc ramp here, binary edge
/// there) disagrees with itself about where the boundary is at the
/// hand-off points and grows nubs or notches into the silhouette.
///
/// The distance is the classic rounded-rect field: with `q = |p - c| -
/// (half_extent - r)` for the corner radius `r` of the quadrant `p`
/// lies in, `d = length(max(q, 0)) + min(max(q.x, q.y), 0) - r`. It is
/// exact for any per-corner radii, including square (r = 0) corners,
/// and along a straight segment it reduces to the plain axis distance
/// (the selected radius cancels), so the field is continuous where
/// quadrants with different radii meet. Cheap fully-inside/outside
/// short circuits keep the square root off interior and far-apron
/// pixels; the exact form runs only near the boundary band.
fn referenceRoundedRectCoverage(point: geometry.PointF, rect: geometry.RectF, radius: Radius) f32 {
const normalized = rect.normalized();
if (normalized.isEmpty()) return 0;
const max_radius = @min(normalized.width, normalized.height) * 0.5;
const half_width = normalized.width * 0.5;
const half_height = normalized.height * 0.5;
const dx = point.x - (normalized.x + half_width);
const dy = point.y - (normalized.y + half_height);
const corner_radius = std.math.clamp(nonNegative(if (dx < 0)
(if (dy < 0) radius.top_left else radius.bottom_left)
else
(if (dy < 0) radius.top_right else radius.bottom_right)), 0, max_radius);
const qx = @abs(dx) - half_width + corner_radius;
const qy = @abs(dy) - half_height + corner_radius;
// Fully inside: with both components non-positive the distance is
// `max(qx, qy) - r`, so anything at least half a pixel inside the
// radius-inset cross is full coverage.
if (@max(qx, qy) <= -0.5) return 1;
// Fully outside: the distance is at least `qx - r` (and `qy - r`),
// so anything at least half a pixel beyond the bounding box is
// zero coverage.
if (qx - corner_radius >= 0.5 or qy - corner_radius >= 0.5) return 0;
const mx = @max(qx, 0);
const my = @max(qy, 0);
const distance = @sqrt(mx * mx + my * my) + @min(@max(qx, qy), 0) - corner_radius;
return std.math.clamp(0.5 - distance, 0, 1);
}
fn referencePointInCorner(point: geometry.PointF, center: geometry.PointF, radius: f32) bool {
if (radius <= 0) return false;
const dx = point.x - center.x;
+155 -1
View File
@@ -628,12 +628,166 @@ test "reference renderer captures Phase 2 primitive signature" {
const surface = (try ReferenceRenderSurface.initWithScratch(32, 24, &pixels, &scratch)).withImages(&images);
try surface.renderPass(frame.renderPass(), Color.rgb8(0, 0, 0));
try std.testing.expectEqual(@as(u64, 12197497484215834747), referenceSurfaceSignature(&pixels));
try std.testing.expectEqual(@as(u64, 8143217197410062006), referenceSurfaceSignature(&pixels));
try expectVisiblePixel(surface.pixelRgba8(6, 6));
try expectVisiblePixel(surface.pixelRgba8(20, 8));
try expectVisiblePixel(surface.pixelRgba8(6, 16));
}
// ---------------------------------------------------------------------------
// Rounded-rect coverage shape fidelity.
//
// The anti-aliased rounded-rect rasterization derives per-pixel coverage
// from one continuous signed-distance field of the whole shape. These
// tests hold that coverage against ground truth — 16x16 supersampling of
// the exact point-in-shape predicate per pixel — with two assertions:
//
// 1. NO PROTRUSION: any pixel the supersampled shape leaves empty must
// stay empty. A coverage model that treats perimeter regions
// differently (an arc ramp handing off to a binary straight edge)
// disagrees with itself about where the boundary is and pushes
// nubs outside the silhouette; this catches that class of bug
// exactly.
// 2. BOUNDED DEVIATION: the worst per-pixel difference stays within
// the intrinsic error of distance-ramp coverage. A half-pixel
// linear ramp reproduces box-filter coverage exactly on straight
// edges; on arcs it deviates more as curvature tightens, and at a
// sharp concave ring corner the distance-to-coverage mapping has a
// known worst case of ~1/4 at the single corner pixel. The
// per-case bounds encode those regimes with a small margin; the
// discontinuous-coverage bug class lands far outside them (2-4x).
const roundedRectCoverageFidelityCase = struct {
kind: enum { fill, stroke },
rect: geometry.RectF,
radius: Radius,
stroke_width: f32 = 0,
/// Max per-pixel |rendered - supersampled| in 1/255 levels.
tolerance: i32,
};
fn fidelityClampRadius(value: f32, max_radius: f32) f32 {
return std.math.clamp(@max(0, value), 0, max_radius);
}
fn fidelityInCorner(x: f32, y: f32, cx: f32, cy: f32, r: f32) bool {
if (r <= 0) return false;
const dx = x - cx;
const dy = y - cy;
return dx * dx + dy * dy <= r * r;
}
/// The exact rounded-rect interior predicate the supersampled ground
/// truth integrates: the rect with each corner replaced by a quarter
/// disc of its (clamped) radius.
fn fidelityInRoundedRect(x: f32, y: f32, rect: geometry.RectF, radius: Radius) bool {
if (rect.width <= 0 or rect.height <= 0) return false;
if (x < rect.x or x > rect.x + rect.width or y < rect.y or y > rect.y + rect.height) return false;
const max_radius = @min(rect.width, rect.height) * 0.5;
const top_left = fidelityClampRadius(radius.top_left, max_radius);
const top_right = fidelityClampRadius(radius.top_right, max_radius);
const bottom_right = fidelityClampRadius(radius.bottom_right, max_radius);
const bottom_left = fidelityClampRadius(radius.bottom_left, max_radius);
const max_x = rect.x + rect.width;
const max_y = rect.y + rect.height;
if (x < rect.x + top_left and y < rect.y + top_left) return fidelityInCorner(x, y, rect.x + top_left, rect.y + top_left, top_left);
if (x >= max_x - top_right and y < rect.y + top_right) return fidelityInCorner(x, y, max_x - top_right, rect.y + top_right, top_right);
if (x >= max_x - bottom_right and y >= max_y - bottom_right) return fidelityInCorner(x, y, max_x - bottom_right, max_y - bottom_right, bottom_right);
if (x < rect.x + bottom_left and y >= max_y - bottom_left) return fidelityInCorner(x, y, rect.x + bottom_left, max_y - bottom_left, bottom_left);
return true;
}
fn fidelityOutsetRadius(radius: Radius, outset: f32) Radius {
return .{
.top_left = @max(0, radius.top_left + outset),
.top_right = @max(0, radius.top_right + outset),
.bottom_right = @max(0, radius.bottom_right + outset),
.bottom_left = @max(0, radius.bottom_left + outset),
};
}
/// Ground-truth alpha of one pixel: the fraction of a 16x16 subsample
/// grid inside the case's shape (the stroke ring is outer minus inner,
/// derived exactly as the renderer derives them), quantized to a byte
/// like the renderer's blend quantizes coverage.
fn fidelityTruthAlpha(case: roundedRectCoverageFidelityCase, px: usize, py: usize) u8 {
const half = case.stroke_width * 0.5;
const outer = case.rect.inflate(geometry.InsetsF.all(half));
const inner = case.rect.deflate(geometry.InsetsF.all(@min(half, @min(case.rect.width, case.rect.height) * 0.5)));
const outer_radius = fidelityOutsetRadius(case.radius, half);
const inner_radius = fidelityOutsetRadius(case.radius, -half);
var covered: usize = 0;
var sub_y: usize = 0;
while (sub_y < 16) : (sub_y += 1) {
var sub_x: usize = 0;
while (sub_x < 16) : (sub_x += 1) {
const x = @as(f32, @floatFromInt(px)) + (@as(f32, @floatFromInt(sub_x)) + 0.5) / 16.0;
const y = @as(f32, @floatFromInt(py)) + (@as(f32, @floatFromInt(sub_y)) + 0.5) / 16.0;
const in = switch (case.kind) {
.fill => fidelityInRoundedRect(x, y, case.rect, case.radius),
.stroke => fidelityInRoundedRect(x, y, outer, outer_radius) and !fidelityInRoundedRect(x, y, inner, inner_radius),
};
if (in) covered += 1;
}
}
return @intFromFloat(@round(@as(f32, @floatFromInt(covered)) / 256.0 * 255.0));
}
test "rounded-rect coverage matches supersampled ground truth with no silhouette protrusion" {
const fidelity_width: usize = 48;
const fidelity_height: usize = 40;
const white = Color{ .r = 1, .g = 1, .b = 1, .a = 1 };
const cases = [_]roundedRectCoverageFidelityCase{
// Button-shaped fills: smooth arcs, integer and fractional bounds.
.{ .kind = .fill, .rect = geometry.RectF.init(6, 5, 30, 22), .radius = Radius.all(8), .tolerance = 16 },
.{ .kind = .fill, .rect = geometry.RectF.init(6.3, 5.7, 29.4, 21.6), .radius = Radius.all(8), .tolerance = 16 },
// Pill: the radius clamps to the half-height.
.{ .kind = .fill, .rect = geometry.RectF.init(4, 6, 40, 20), .radius = Radius.all(22), .tolerance = 16 },
// Tight curvature and mixed per-corner radii (one square corner).
.{ .kind = .fill, .rect = geometry.RectF.init(10.2, 8.6, 21.7, 14.3), .radius = Radius.all(0.75), .tolerance = 32 },
.{ .kind = .fill, .rect = geometry.RectF.init(7.6, 6.2, 30.8, 24.9), .radius = .{ .top_left = 0, .top_right = 6, .bottom_right = 12, .bottom_left = 2 }, .tolerance = 32 },
// Thin borders: both ring edges from the same field.
.{ .kind = .stroke, .rect = geometry.RectF.init(8, 7, 28, 20), .radius = Radius.all(6), .stroke_width = 1, .tolerance = 20 },
.{ .kind = .stroke, .rect = geometry.RectF.init(8.4, 7.8, 27.3, 19.5), .radius = Radius.all(6), .stroke_width = 2, .tolerance = 20 },
.{ .kind = .stroke, .rect = geometry.RectF.init(9.1, 8.3, 25.6, 17.2), .radius = Radius.all(0.75), .stroke_width = 1, .tolerance = 32 },
// Thick border whose inset radius bottoms out: the ring's inner
// corners go sharp and concave, the distance-ramp worst case.
.{ .kind = .stroke, .rect = geometry.RectF.init(8, 7, 28, 20), .radius = Radius.all(1), .stroke_width = 3, .tolerance = 72 },
};
for (cases) |case| {
var pixels: [fidelity_width * fidelity_height * 4]u8 = undefined;
const surface = try ReferenceRenderSurface.init(fidelity_width, fidelity_height, &pixels);
const bounds = geometry.RectF.init(0, 0, fidelity_width, fidelity_height);
const command = RenderCommand{
.command = switch (case.kind) {
.fill => .{ .fill_rounded_rect = .{ .rect = case.rect, .radius = case.radius, .fill = .{ .color = white } } },
.stroke => .{ .stroke_rect = .{ .rect = case.rect, .radius = case.radius, .stroke = .{ .fill = .{ .color = white }, .width = case.stroke_width } } },
},
.local_bounds = bounds,
.bounds = bounds,
};
const pass = CanvasRenderPass{
.surface_size = geometry.SizeF.init(fidelity_width, fidelity_height),
.scale = 1,
.full_repaint = true,
.commands = &.{command},
};
try surface.renderPass(pass, Color{ .r = 0, .g = 0, .b = 0, .a = 0 });
var y: usize = 0;
while (y < fidelity_height) : (y += 1) {
var x: usize = 0;
while (x < fidelity_width) : (x += 1) {
const rendered: i32 = surface.pixelRgba8(x, y)[3];
const truth: i32 = fidelityTruthAlpha(case, x, y);
if (truth == 0) try std.testing.expectEqual(@as(i32, 0), rendered);
try std.testing.expect(@max(rendered - truth, truth - rendered) <= case.tolerance);
}
}
}
}
test "reference renderer applies clip transform and opacity" {
const commands = [_]CanvasCommand{
.{ .push_clip = .{ .rect = geometry.RectF.init(1, 1, 2, 2) } },
+6
View File
@@ -54,6 +54,12 @@ pub fn designTokens(color_scheme: ColorScheme, contrast: ColorContrast) DesignTo
// then a 2px blue ring — the stroke defaults already carry that
// geometry, restated here so the pack is explicit about it.
.stroke = .{ .hairline = 1, .regular = 1, .focus = 2, .focus_offset = 2 },
// Crisp 1px lines are core to this register: geometry pixel
// snapping lands hairline borders on whole device columns. The
// runtime stamps the live surface scale on top; apps opt out
// through the token override (`.pixel_snap = .{ .geometry =
// false }`).
.pixel_snap = .{ .geometry = true },
// Shadows are quiet: a 2px settle under raised cards, one
// dominant 16/24 layer under overlays. Buttons stay flat.
.shadow = .{
+7
View File
@@ -1478,6 +1478,13 @@ pub const DesignTokens = struct {
.house => .{
.colors = ColorTokens.theme(options.color_scheme, options.contrast),
.controls = ControlTokens.theme(options.color_scheme, options.contrast),
// Geometry pixel snapping is part of the house register:
// hairline borders land on whole device columns (crisp
// 1px lines) and intrinsic boxes round to the device
// grid. The runtime stamps the live surface scale on
// top; apps opt out through the token override
// (`.pixel_snap = .{ .geometry = false }`).
.pixel_snap = .{ .geometry = true },
},
.geist => geist_theme.designTokens(options.color_scheme, options.contrast),
};
@@ -3158,3 +3158,87 @@ test "theme packs resolve by name and compose with every theme axis" {
// Fields the override left null keep the pack's values.
try std.testing.expectEqual(geist_light.metrics.control_height_lg, overridden.metrics.control_height_lg);
}
test "hairline borders snap to whole device columns with smooth arcs" {
const snapHairlineStrokeRect = @import("widget_render_style.zig").snapHairlineStrokeRect;
const white = Color{ .r = 1, .g = 1, .b = 1, .a = 1 };
// Inactive without the geometry token or a usable scale.
const unsnapped: StrokeRect = .{ .rect = geometry.RectF.init(4, 3, 24, 14), .radius = Radius.all(6), .stroke = .{ .fill = .{ .color = white }, .width = 1 } };
const off = DesignTokens{ .pixel_snap = .{ .geometry = false, .scale = 1 } };
try std.testing.expectEqual(unsnapped.rect.x, snapHairlineStrokeRect(off, unsnapped).rect.x);
// 1x: a 1px border pulls its centerline half a pixel inward so the
// band covers exactly one column with its outer edge on the frame.
const at_1x = DesignTokens{ .pixel_snap = .{ .geometry = true, .scale = 1 } };
const snapped = snapHairlineStrokeRect(at_1x, unsnapped);
try std.testing.expectEqual(@as(f32, 4.5), snapped.rect.x);
try std.testing.expectEqual(@as(f32, 3.5), snapped.rect.y);
try std.testing.expectEqual(@as(f32, 23), snapped.rect.width);
try std.testing.expectEqual(@as(f32, 1), snapped.stroke.width);
// The outer arc keeps the frame's radius: centerline radius shrinks
// by the half-width inset.
try std.testing.expectEqual(@as(f32, 5.5), snapped.radius.top_left);
// Wider borders keep true geometry (device width above the
// hairline range).
const wide: StrokeRect = .{ .rect = geometry.RectF.init(4, 3, 24, 14), .radius = Radius.all(6), .stroke = .{ .fill = .{ .color = white }, .width = 3 } };
try std.testing.expectEqual(@as(f32, 4), snapHairlineStrokeRect(at_1x, wide).rect.x);
// Fractional scale: snapping happens on the DEVICE grid. At 1.25x a
// 1px logical border snaps to one device pixel (0.8 logical) — the
// floor of its exact 1.25-device width — and a frame edge at
// logical 4 (device 5) pulls the centerline to device 5.5 =
// logical 4.4.
const at_fractional = DesignTokens{ .pixel_snap = .{ .geometry = true, .scale = 1.25 } };
const fractional = snapHairlineStrokeRect(at_fractional, unsnapped);
try std.testing.expectEqual(@as(f32, 4.4), fractional.rect.x);
try std.testing.expectEqual(@as(f32, 0.8), fractional.stroke.width);
// At 1.5x the exact device width of a 1px logical border is 1.5 —
// between grid widths — and the snap takes the FLOOR: one device
// column (2/3 logical), the crisp-and-light choice, never two. The
// frame edge at logical 4 (device 6) pulls the centerline to
// device 6.5.
const at_halfstep = DesignTokens{ .pixel_snap = .{ .geometry = true, .scale = 1.5 } };
const halfstep = snapHairlineStrokeRect(at_halfstep, unsnapped);
try std.testing.expectEqual(@as(f32, 6.5 / 1.5), halfstep.rect.x);
try std.testing.expectEqual(@as(f32, 1.0 / 1.5), halfstep.stroke.width);
// A stroke whose device width rounds to zero stays unsnapped — the
// floor's one-pixel minimum never darkens a sub-half-pixel stroke.
var whisper = unsnapped;
whisper.stroke.width = 0.25;
try std.testing.expectEqual(@as(f32, 0.25), snapHairlineStrokeRect(at_halfstep, whisper).stroke.width);
try std.testing.expectEqual(unsnapped.rect.x, snapHairlineStrokeRect(at_halfstep, whisper).rect.x);
// Rendered at 1x, the straight runs land as exactly one full-alpha
// column inside the frame — no half-covered neighbors — while the
// corners keep fractional arc coverage from the continuous field.
var pixels: [40 * 24 * 4]u8 = undefined;
const surface = try ReferenceRenderSurface.init(40, 24, &pixels);
const bounds = geometry.RectF.init(0, 0, 40, 24);
const command = RenderCommand{
.command = .{ .stroke_rect = snapped },
.local_bounds = bounds,
.bounds = bounds,
};
try surface.renderPass(.{
.surface_size = geometry.SizeF.init(40, 24),
.scale = 1,
.full_repaint = true,
.commands = &.{command},
}, Color{ .r = 0, .g = 0, .b = 0, .a = 0 });
// Mid-height row: outside column empty, frame column fully inked,
// next column inside empty again.
try std.testing.expectEqual(@as(u8, 0), surface.pixelRgba8(3, 10)[3]);
try std.testing.expectEqual(@as(u8, 255), surface.pixelRgba8(4, 10)[3]);
try std.testing.expectEqual(@as(u8, 0), surface.pixelRgba8(5, 10)[3]);
// Mid-width column: same on the top run.
try std.testing.expectEqual(@as(u8, 0), surface.pixelRgba8(16, 2)[3]);
try std.testing.expectEqual(@as(u8, 255), surface.pixelRgba8(16, 3)[3]);
try std.testing.expectEqual(@as(u8, 0), surface.pixelRgba8(16, 4)[3]);
// Corner pixels carry fractional arc coverage — smooth, not binary.
const corner = surface.pixelRgba8(5, 4)[3];
try std.testing.expect(corner > 0 and corner < 255);
}
+9 -8
View File
@@ -87,6 +87,7 @@ const widgetAccentForegroundColor = widget_render_style.widgetAccentForegroundCo
const widgetRadius = widget_render_style.widgetRadius;
pub const controlRadius = widget_render_style.controlRadius;
pub const controlStrokeWidth = widget_render_style.controlStrokeWidth;
const snapHairlineStrokeRect = widget_render_style.snapHairlineStrokeRect;
pub const selectControlVisualTokens = widget_render_style.selectControlVisualTokens;
pub const textInputControlVisualTokens = widget_render_style.textInputControlVisualTokens;
const alertControlVisualTokens = widget_render_style.alertControlVisualTokens;
@@ -1201,7 +1202,7 @@ fn emitAvatarWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Err
});
}
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 4),
.rect = widget.frame,
.radius = radius,
@@ -1209,7 +1210,7 @@ fn emitAvatarWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Err
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
}
fn emitBadgeWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
@@ -1223,7 +1224,7 @@ fn emitBadgeWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Erro
.radius = radius,
.fill = colorFill(badgeBackgroundColor(widget, tokens, visual)),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -1231,7 +1232,7 @@ fn emitBadgeWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Erro
.fill = widgetBorderFill(widget, badgeBorderColor(widget, tokens, visual)),
.width = badgeStrokeWidth(widget, tokens, visual),
},
});
}));
const content_color = badgeTextColor(widget, tokens, visual);
// Inline vector icon: icon-only badges center it (the stepper's
// completed check, status chips); icon + text draws it before the
@@ -1329,7 +1330,7 @@ fn emitSplitDividerWidget(builder: *Builder, widget: Widget, tokens: DesignToken
.fill = colorFill(line_color),
});
if (widget.state.focused) {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = pixelSnapGeometryRect(tokens, normalized),
.radius = Radius.all(tokens.radius.sm),
@@ -1337,7 +1338,7 @@ fn emitSplitDividerWidget(builder: *Builder, widget: Widget, tokens: DesignToken
.fill = widget_render_style.widgetFocusRingFill(widget, tokens),
.width = tokens.stroke.focus,
},
});
}));
}
}
@@ -2322,12 +2323,12 @@ fn emitChartHoverDetail(builder: *Builder, widget: Widget, tokens: DesignTokens,
.radius = radius,
.fill = colorFill(tokens.colors.surface),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = chartCommandId(widget.id, chart_hover_seed, 0, 3),
.rect = card,
.radius = radius,
.stroke = .{ .fill = colorFill(tokens.colors.border), .width = hairline },
});
}));
// Title line, then one swatch/name/value row per series holding
// this sample. Values right-align on the card's inner edge so a
@@ -74,6 +74,7 @@ const controlRadius = widget_render_style.controlRadius;
const buttonControlRadius = widget_render_style.buttonControlRadius;
const widgetSizedRadiusValue = widget_render_style.widgetSizedRadiusValue;
const controlStrokeWidth = widget_render_style.controlStrokeWidth;
const snapHairlineStrokeRect = widget_render_style.snapHairlineStrokeRect;
const buttonFill = widget_render_style.buttonFill;
const buttonTextColorForWidget = widget_render_style.buttonTextColorForWidget;
const buttonBorderFill = widget_render_style.buttonBorderFill;
@@ -294,22 +295,7 @@ fn buttonGroupSegmentRadius(widget: Widget, visual: ControlVisualTokens, tokens:
/// freed by the retired button shadow.
fn emitButtonBorder(builder: *Builder, widget: Widget, tokens: DesignTokens, radius: Radius) Error!void {
const stroke_width = buttonStrokeWidth(widget, tokens);
// Seams exist only in the segmented register — a detached chip has
// no shared boundary to collapse.
const drop_left_border = !widget_render_style.buttonInDetachedGroup(widget, tokens) and
(widget.group_segment == .middle or widget.group_segment == .last);
if (drop_left_border) {
try builder.pushClip(.{
.id = widgetPartId(widget.id, 0),
.rect = geometry.RectF.init(
widget.frame.x + stroke_width * 0.5,
widget.frame.y - stroke_width,
@max(0, widget.frame.width + stroke_width * 0.5),
widget.frame.height + stroke_width * 2,
),
});
}
try builder.strokeRect(.{
const border = snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -318,6 +304,26 @@ fn emitButtonBorder(builder: *Builder, widget: Widget, tokens: DesignTokens, rad
.width = stroke_width,
},
});
// Seams exist only in the segmented register — a detached chip has
// no shared boundary to collapse.
const drop_left_border = !widget_render_style.buttonInDetachedGroup(widget, tokens) and
(widget.group_segment == .middle or widget.group_segment == .last);
if (drop_left_border) {
// The clip's left edge sits on the border band's INNER edge —
// computed from the (possibly snapped) emitted stroke, so the
// whole left band drops whether or not snapping moved it.
const band_inner_x = border.rect.x + border.stroke.width * 0.5;
try builder.pushClip(.{
.id = widgetPartId(widget.id, 0),
.rect = geometry.RectF.init(
band_inner_x,
widget.frame.y - stroke_width,
@max(0, widget.frame.maxX() + stroke_width - band_inner_x),
widget.frame.height + stroke_width * 2,
),
});
}
try builder.strokeRect(border);
if (drop_left_border) try builder.popClip();
}
@@ -404,7 +410,7 @@ pub fn emitSelectWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.radius = radius,
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed, washHovered(widget), tokens.colors.surface))),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -412,7 +418,7 @@ pub fn emitSelectWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 6, widget.frame, radius);
if (visible_text.len > 0) {
const text_color = if (is_placeholder)
@@ -467,7 +473,7 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke
.radius = radius,
.fill = textInputFill(widget, tokens, visual),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -475,7 +481,7 @@ pub fn emitTextFieldWidget(builder: *Builder, widget: Widget, tokens: DesignToke
.fill = textInputBorderFill(widget, visual, tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 7, widget.frame, radius);
if (clips_text) try builder.pushClip(.{ .id = widgetPartId(widget.id, 16), .rect = clip_rect, .radius = radius });
if (selection_range) |range| {
@@ -529,7 +535,7 @@ pub fn emitInputGroupWidget(builder: *Builder, widget: Widget, tokens: DesignTok
.radius = radius,
.fill = textInputFill(widget, tokens, visual),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -537,7 +543,7 @@ pub fn emitInputGroupWidget(builder: *Builder, widget: Widget, tokens: DesignTok
.fill = textInputBorderFill(widget, visual, tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 3, widget.frame, radius);
}
@@ -560,7 +566,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
.radius = radius,
.fill = textInputFill(widget, tokens, visual),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -568,7 +574,7 @@ pub fn emitSearchFieldWidget(builder: *Builder, widget: Widget, tokens: DesignTo
.fill = textInputBorderFill(widget, visual, tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 14, widget.frame, radius);
try emitSearchFieldIcon(builder, widget, tokens, icon_size);
if (selection_range) |range| {
@@ -871,14 +877,14 @@ pub fn emitDataCellWidgetChrome(builder: *Builder, widget: Widget, tokens: Desig
// border/stroke opts a cell back into an edge.
const wants_stroke = widget.style.border != null or visual.border != null or widget.style.stroke_width != null or visual.stroke_width != null;
if (wants_stroke) {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.stroke = .{
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
}
if (widget.state.focused) try emitWidgetFocusRing(builder, widget, tokens, 3);
return visual;
@@ -954,7 +960,7 @@ pub fn emitSegmentedControlWidget(builder: *Builder, widget: Widget, tokens: Des
});
}
if (selected) {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -962,7 +968,7 @@ pub fn emitSegmentedControlWidget(builder: *Builder, widget: Widget, tokens: Des
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
}
},
// The underline treatment: triggers are bare text — no pill, no
@@ -1023,7 +1029,7 @@ pub fn emitCheckboxWidget(builder: *Builder, widget: Widget, tokens: DesignToken
else
colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = box,
.radius = radius,
@@ -1031,7 +1037,7 @@ pub fn emitCheckboxWidget(builder: *Builder, widget: Widget, tokens: DesignToken
.fill = colorFill(disabledWash(if (selected) widgetAccentColor(widget, visual.border orelse visual.active_background orelse tokens.colors.accent) else widgetBorderColor(widget, visual.border orelse tokens.colors.border), widget.state.disabled, tokens.states.disabled_alpha)),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 3, box, radius);
if (selected) {
// The check keeps the accent-foreground tint even when disabled
@@ -1068,7 +1074,7 @@ pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.radius = radius,
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = circle,
.radius = radius,
@@ -1078,7 +1084,7 @@ pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.fill = colorFill(disabledWash(widgetBorderColor(widget, visual.border orelse tokens.colors.border), widget.state.disabled, tokens.states.disabled_alpha)),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 3, circle, radius);
if (selected) {
const dot_size = @max(0, circle.height * 0.5);
@@ -1128,7 +1134,7 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
const wants_track_stroke = widget.style.border != null or visual.border != null;
const track_stroke_width = controlStrokeWidth(widget, visual, if (wants_track_stroke) tokens.stroke.regular else 0);
if (track_stroke_width > 0) {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = track,
.radius = track_radius,
@@ -1136,7 +1142,7 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = track_stroke_width,
},
});
}));
}
try builder.fillRoundedRect(.{
.id = widgetPartId(widget.id, 3),
@@ -1215,7 +1221,7 @@ pub fn emitSliderWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
// The thumb's resting hairline wears the focus-ring neutral (a mid
// gray in both schemes), so the ring on focus reads as a brighter
// echo of an edge the control already owns — not a recolor.
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 4),
.rect = knob,
.radius = knob_radius,
@@ -1223,7 +1229,7 @@ pub fn emitSliderWidget(builder: *Builder, widget: Widget, tokens: DesignTokens)
.fill = widgetBorderFill(widget, disabledWash(visual.border orelse tokens.colors.focus_ring, washed, tokens.states.disabled_alpha)),
.width = controlStrokeWidth(widget, visual, tokens.stroke.regular),
},
});
}));
if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 5, knob, knob_radius);
}
@@ -1332,7 +1338,7 @@ fn emitWidgetFocusRing(builder: *Builder, widget: Widget, tokens: DesignTokens,
/// (`stroke.focus_offset`) outside it, so focus adds an outline instead
/// of recoloring the control's edge.
fn emitWidgetFocusRingForRect(builder: *Builder, widget: Widget, tokens: DesignTokens, slot: ObjectId, rect: geometry.RectF, radius: Radius) Error!void {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, slot),
.rect = widget_render_style.focusRingRect(rect, tokens),
.radius = widget_render_style.focusRingRadius(radius, tokens),
@@ -1340,7 +1346,7 @@ fn emitWidgetFocusRingForRect(builder: *Builder, widget: Widget, tokens: DesignT
.fill = widgetFocusRingFill(widget, tokens),
.width = tokens.stroke.focus,
},
});
}));
}
fn emitControlLabelWithColor(builder: *Builder, widget: Widget, tokens: DesignTokens, x: f32, slot: ObjectId, color: Color) Error!void {
@@ -7,11 +7,64 @@ const widget_model = @import("widgets.zig");
const Color = drawing_model.Color;
const Fill = drawing_model.Fill;
const Radius = drawing_model.Radius;
const StrokeRect = drawing_model.StrokeRect;
const DesignTokens = token_model.DesignTokens;
const ControlVisualTokens = token_model.ControlVisualTokens;
const Widget = widget_model.Widget;
const WidgetState = widget_model.WidgetState;
/// Snap a hairline border to the device-pixel grid at emit time. A
/// stroke centered on its rect straddles the boundary by half its
/// width, so a 1-device-pixel border rasterizes as two half-covered
/// columns — geometrically faithful but visually soft. Eligibility is
/// judged on the ROUNDED device width: strokes rounding to one or two
/// device pixels snap, wider borders keep true geometry, and a width
/// that rounds to zero stays unsnapped rather than darkening a
/// sub-half-pixel stroke to a full column. An eligible stroke takes the
/// FLOOR of its exact device width, never below one pixel, so a
/// fractional width lands on the lighter whole-pixel neighbor — a 1px
/// logical border at a 1.5x scale covers one device column, not two.
/// A hairline is a boundary, not content: when the grid forces a
/// choice, crisp-and-light reads better than crisp-and-heavy. The snap
/// moves each frame edge to the grid and pulls the centerline INWARD
/// by half the snapped width, so the band covers exactly that many
/// whole device columns and rows with its outer silhouette on the
/// widget frame (never bleeding past layout bounds). Corner radii
/// shrink by the same inset so the outer arc keeps the frame's radius,
/// and the arcs stay smooth: the snapped geometry is still one rounded
/// rect, rasterized by one continuous coverage field. Inactive without
/// the pixel-snap geometry token or a usable scale, so fractional
/// device scales snap in DEVICE space and unsnapped surfaces render
/// untouched.
pub fn snapHairlineStrokeRect(tokens: DesignTokens, value: StrokeRect) StrokeRect {
if (!tokens.pixel_snap.geometry) return value;
const scale = tokens.pixel_snap.scale;
if (!std.math.isFinite(scale) or scale <= 0) return value;
const exact_device_width = nonNegative(value.stroke.width) * scale;
const rounded_device_width = @round(exact_device_width);
if (rounded_device_width < 1 or rounded_device_width > 2) return value;
const device_width = @max(1, @floor(exact_device_width));
const rect = value.rect.normalized();
if (rect.isEmpty()) return value;
const half_device = device_width * 0.5;
const x0 = (@round(rect.x * scale) + half_device) / scale;
const y0 = (@round(rect.y * scale) + half_device) / scale;
const x1 = (@round(rect.maxX() * scale) - half_device) / scale;
const y1 = (@round(rect.maxY() * scale) - half_device) / scale;
if (x1 <= x0 or y1 <= y0) return value;
const inset = half_device / scale;
var snapped = value;
snapped.rect = geometry.RectF.init(x0, y0, x1 - x0, y1 - y0);
snapped.radius = .{
.top_left = @max(0, value.radius.top_left - inset),
.top_right = @max(0, value.radius.top_right - inset),
.bottom_right = @max(0, value.radius.bottom_right - inset),
.bottom_left = @max(0, value.radius.bottom_left - inset),
};
snapped.stroke.width = device_width / scale;
return snapped;
}
/// The frame the focus ring strokes: the control rect pushed out by the
/// ring-offset token (`stroke.focus_offset`) — the control keeps its
/// own border and the ring floats that gap outside it, so focus never
@@ -40,6 +40,7 @@ const widgetForegroundColor = widget_render_style.widgetForegroundColor;
const widgetRadius = widget_render_style.widgetRadius;
const controlRadius = widget_render_style.controlRadius;
const controlStrokeWidth = widget_render_style.controlStrokeWidth;
const snapHairlineStrokeRect = widget_render_style.snapHairlineStrokeRect;
const buttonStateBackground = widget_render_style.buttonStateBackground;
const washHovered = widget_render_style.washHovered;
const alertControlVisualTokens = widget_render_style.alertControlVisualTokens;
@@ -58,7 +59,7 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
.radius = radius,
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface))),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -66,7 +67,7 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
if (widget.text.len == 0) return;
// The house style alert geometry: a FIXED 16px icon centered on the first
@@ -144,7 +145,7 @@ pub fn emitCardWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTok
.radius = radius,
.fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface))),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = widget.frame,
.radius = radius,
@@ -152,7 +153,7 @@ pub fn emitCardWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTok
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
if (widget.text.len == 0) return;
const title_size = widgetTypographySize(widget, tokens.typography.body_size + 1);
@@ -207,7 +208,7 @@ pub fn emitModalSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: D
.radius = radius,
.fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 3),
.rect = widget.frame,
.radius = radius,
@@ -215,7 +216,7 @@ pub fn emitModalSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: D
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
if (widget.text.len == 0) return;
const title_size = widgetTypographySize(widget, tokens.typography.title_size);
@@ -264,7 +265,7 @@ pub fn emitPanelWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
.radius = radius,
.fill = colorFill(background),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 3),
.rect = widget.frame,
.radius = radius,
@@ -272,7 +273,7 @@ pub fn emitPanelWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
if (widget.kind == .resizable) try emitResizableWidgetHandle(builder, widget, tokens, visual);
}
@@ -320,7 +321,7 @@ pub fn emitBubbleWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignT
});
}
if (bubbleBorderColor(widget, tokens)) |border| {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 3),
.rect = widget.frame,
.radius = radius,
@@ -328,7 +329,7 @@ pub fn emitBubbleWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignT
.fill = colorFill(border),
.width = controlStrokeWidth(widget, tokens.controls.bubble, tokens.stroke.hairline),
},
});
}));
}
}
@@ -599,7 +600,7 @@ pub fn emitTabsListWidgetChrome(builder: *Builder, widget: Widget, tokens: Desig
.fill = colorFill(widgetBackgroundColor(widget, visual.background orelse tokens.colors.surface_subtle)),
});
if (widget.style.border orelse visual.border) |border| {
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 2),
.rect = frame,
.radius = radius,
@@ -607,7 +608,7 @@ pub fn emitTabsListWidgetChrome(builder: *Builder, widget: Widget, tokens: Desig
.fill = colorFill(border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
}
},
.underline => {
@@ -691,7 +692,7 @@ pub fn emitPopoverWidgetChrome(builder: *Builder, widget: Widget, tokens: Design
.radius = radius,
.fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 3),
.rect = widget.frame,
.radius = radius,
@@ -699,7 +700,7 @@ pub fn emitPopoverWidgetChrome(builder: *Builder, widget: Widget, tokens: Design
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
}
pub fn emitMenuSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void {
@@ -724,7 +725,7 @@ pub fn emitMenuSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: De
.radius = radius,
.fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)),
});
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, 3),
.rect = widget.frame,
.radius = radius,
@@ -732,7 +733,7 @@ pub fn emitMenuSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: De
.fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border),
.width = controlStrokeWidth(widget, visual, tokens.stroke.hairline),
},
});
}));
}
fn pixelSnapScale(tokens: DesignTokens) ?f32 {
@@ -782,7 +783,7 @@ fn emitWidgetFocusRing(builder: *Builder, widget: Widget, tokens: DesignTokens,
// The shared ring-offset treatment: a concentric ring the
// token-stated gap outside the widget's own border (see
// widget_render_style).
try builder.strokeRect(.{
try builder.strokeRect(snapHairlineStrokeRect(tokens, .{
.id = widgetPartId(widget.id, slot),
.rect = widget_render_style.focusRingRect(widget.frame, tokens),
.radius = widget_render_style.focusRingRadius(widgetRadius(widget, tokens.radius.md), tokens),
@@ -790,5 +791,5 @@ fn emitWidgetFocusRing(builder: *Builder, widget: Widget, tokens: DesignTokens,
.fill = widgetFocusRingFill(widget, tokens),
.width = tokens.stroke.focus,
},
});
}));
}
+78 -10
View File
@@ -659,6 +659,15 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
on_close: ?MsgT = null,
installed: bool = false,
canvas_size: geometry.SizeF = .{ .width = 1, .height = 1 },
/// The device scale of THIS window's surface, adopted from
/// its own frame and resize events. Secondary windows can sit
/// on a different-density monitor than the main canvas, so
/// the scale is per-window state: the app owns ONE appearance,
/// but each slot's rebuild stamps its own scale into
/// `pixel_snap.scale` (`slotEffectiveTokens`) so this window's
/// hairlines snap against the grid it actually renders on.
/// The main canvas keeps its scale in `Self.pixel_snap_scale`.
pixel_snap_scale: f32 = 1,
tree: ?Ui.Tree = null,
arena_index: usize = 0,
arenas: [2]std.heap.ArenaAllocator,
@@ -1131,15 +1140,21 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
/// `tokens_fn`, explicit static `tokens`, or — the default — the
/// stock theme derived from the SYSTEM appearance the runtime
/// tracks (scheme, contrast, reduced motion), so an unthemed app
/// honors the OS light/dark setting live. Derived tokens carry
/// the surface scale in `pixel_snap.scale`.
/// honors the OS light/dark setting live. Every path carries the
/// surface scale in `pixel_snap.scale` — the app owns the
/// appearance, the runtime owns the device scale — so static
/// tokens snap hairlines against the real surface density too.
pub fn effectiveTokens(self: *const Self) canvas.DesignTokens {
if (self.options.tokens_fn) |tokens_fn| {
var tokens = tokens_fn(&self.model);
tokens.pixel_snap.scale = self.pixel_snap_scale;
return tokens;
}
if (self.options.tokens) |static_tokens| return static_tokens;
if (self.options.tokens) |static_tokens| {
var tokens = static_tokens;
tokens.pixel_snap.scale = self.pixel_snap_scale;
return tokens;
}
var tokens = canvas.DesignTokens.theme(.{
.color_scheme = switch (self.system_appearance.color_scheme) {
.light => .light,
@@ -1153,10 +1168,20 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
return tokens;
}
/// The design tokens for a secondary window's rebuild: the same
/// app-owned appearance as `effectiveTokens`, restamped with the
/// SLOT's device scale. Each window snaps hairlines against its
/// own monitor's grid — only the scale differs per window, never
/// the appearance.
fn slotEffectiveTokens(self: *const Self, slot: *const WindowSlot) canvas.DesignTokens {
var tokens = self.effectiveTokens();
tokens.pixel_snap.scale = slot.pixel_snap_scale;
return tokens;
}
/// Whether the stock tokens derive from the system appearance:
/// true only when the app claims neither token override, so an
/// appearance flip (or a surface-scale change) must re-derive
/// and re-render.
/// appearance flip must re-derive and re-render.
fn followsSystemAppearance(self: *const Self) bool {
return self.options.tokens_fn == null and self.options.tokens == null;
}
@@ -1167,6 +1192,20 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
return self.options.tokens_fn != null or self.followsSystemAppearance();
}
/// Whether a rebuild must push its tokens into the runtime's
/// stored copy. Derived tokens can change with any model or
/// appearance input, so they always re-emit. Static tokens are
/// fixed by the app, but the runtime stamps the surface scale
/// onto them (`effectiveTokens`), so a stored copy holding a
/// stale scale re-emits too — hairlines re-snap after a move
/// between monitors — while ordinary rebuilds keep skipping the
/// redundant emission.
fn rebuildEmitsTokens(self: *const Self, runtime: *Runtime, window_id: platform.WindowId, canvas_label: []const u8, tokens: canvas.DesignTokens) bool {
if (self.derivesTokens()) return true;
const stored = runtime.canvasWidgetDesignTokens(window_id, canvas_label) catch return true;
return stored.pixel_snap.scale != tokens.pixel_snap.scale;
}
/// Read runtime-owned widget state back into the model through the
/// optional `sync` hook.
fn syncModel(self: *Self, runtime: *Runtime, window_id: platform.WindowId) void {
@@ -1279,7 +1318,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
try self.installChromeDisplayList(runtime, window_id, chrome, layout, tokens);
} else {
_ = try runtime.setCanvasWidgetLayout(window_id, self.options.canvas_label, layout);
if (self.installed and self.derivesTokens()) {
if (self.installed and self.rebuildEmitsTokens(runtime, window_id, self.options.canvas_label, tokens)) {
_ = try runtime.emitCanvasWidgetDisplayList(window_id, self.options.canvas_label, tokens);
}
}
@@ -1732,6 +1771,10 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
slot.on_close = descriptor.on_close;
slot.installed = false;
slot.canvas_size = .{ .width = descriptor.width, .height = descriptor.height };
// Until this window's first frame reports its real density,
// assume the main canvas's — new windows usually open on the
// same monitor, and the installing frame corrects the guess.
slot.pixel_snap_scale = self.pixel_snap_scale;
slot.tree = null;
slot.arena_index = 0;
self.window_slot_count += 1;
@@ -1808,7 +1851,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
fn rebuildWindowSlot(self: *Self, runtime: *Runtime, slot: *WindowSlot) anyerror!void {
const window_view = self.options.window_view orelse return;
const tokens = runtime.tokensWithTextMeasure(self.effectiveTokens());
const tokens = runtime.tokensWithTextMeasure(self.slotEffectiveTokens(slot));
const next_index = slot.arena_index ^ 1;
_ = slot.arenas[next_index].reset(.retain_capacity);
var ui = Ui.init(slot.arenas[next_index].allocator());
@@ -1827,7 +1870,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
return err;
};
_ = try runtime.setCanvasWidgetLayout(slot.window_id, slot.canvasLabel(), layout);
if (slot.installed and self.derivesTokens()) {
if (slot.installed and self.rebuildEmitsTokens(runtime, slot.window_id, slot.canvasLabel(), tokens)) {
_ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), tokens);
}
slot.tree = tree;
@@ -2749,7 +2792,12 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
self.installed = true;
self.startMarkupWatch(runtime);
self.installStatusItem(runtime);
} else if (self.derivesTokens() and @abs(self.pixel_snap_scale - scale) > 0.001) {
} else if (@abs(self.pixel_snap_scale - scale) > 0.001) {
// The surface moved to a different density (a drag between
// monitors): EVERY token path carries the scale in
// `pixel_snap.scale`, so static-token apps rebuild here
// too — the re-emit inside `rebuild` re-snaps hairlines
// against the new grid.
self.pixel_snap_scale = scale;
try self.rebuild(runtime, frame_event.window_id);
} else if (self.options.web_panes != null) {
@@ -2778,13 +2826,24 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
fn handleWindowSlotFrame(self: *Self, runtime: *Runtime, frame_event: platform.GpuSurfaceFrameEvent) anyerror!void {
const slot = self.windowSlotByCanvasLabel(frame_event.label) orelse return;
slot.window_id = frame_event.window_id;
const scale = normalizedSurfaceScale(frame_event.scale_factor);
var installing = false;
if (!slot.installed) {
installing = true;
slot.canvas_size = frame_event.size;
slot.pixel_snap_scale = scale;
try self.rebuildWindowSlot(runtime, slot);
_ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), runtime.tokensWithTextMeasure(self.effectiveTokens()));
_ = try runtime.emitCanvasWidgetDisplayList(slot.window_id, slot.canvasLabel(), runtime.tokensWithTextMeasure(self.slotEffectiveTokens(slot)));
slot.installed = true;
} else if (@abs(slot.pixel_snap_scale - scale) > 0.001) {
// THIS window moved to a different density (the main
// canvas may still be on its old monitor): adopt the
// slot's scale and rebuild so the re-emit inside
// `rebuildWindowSlot` re-snaps this window's hairlines —
// static-token apps included, because the stored copy
// holds the stale scale (`rebuildEmitsTokens`).
slot.pixel_snap_scale = scale;
try self.rebuildWindowSlot(runtime, slot);
}
try self.presentFrame(runtime, frame_event, slot.canvasLabel(), installing);
}
@@ -2897,13 +2956,22 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
}
fn handleResize(self: *Self, runtime: *Runtime, resize_event: platform.GpuSurfaceResizeEvent) anyerror!void {
// Resize events carry the surface density alongside the frame:
// a move to a different-DPI monitor can arrive as a resize
// whose LOGICAL size is unchanged (the OS rescales the frame),
// so the scale must be adopted BEFORE the rebuild below — the
// rebuild's re-emit then stamps freshly-snapped tokens, and
// `rebuildEmitsTokens` sees the stored copy's stale scale and
// forces the emission even when nothing else changed.
if (!std.mem.eql(u8, resize_event.label, self.options.canvas_label)) {
const slot = self.windowSlotByCanvasLabel(resize_event.label) orelse return;
slot.canvas_size = .{ .width = resize_event.frame.width, .height = resize_event.frame.height };
slot.pixel_snap_scale = normalizedSurfaceScale(resize_event.scale_factor);
if (slot.installed) try self.rebuildWindowSlot(runtime, slot);
return;
}
self.canvas_size = .{ .width = resize_event.frame.width, .height = resize_event.frame.height };
self.pixel_snap_scale = normalizedSurfaceScale(resize_event.scale_factor);
if (!self.installed) return;
// Fullscreen transitions resize the canvas AND flip the
// chrome overlay insets (macOS hides the titlebar band and
+109
View File
@@ -1044,6 +1044,115 @@ test "unthemed apps follow the system appearance live; explicit tokens opt out"
try std.testing.expectEqualDeep(light.colors.background, fixed_state.effectiveTokens().colors.background);
}
test "static tokens carry the surface scale and re-snap on a scale change" {
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
// The app pins its look with static tokens (geometry snapping on,
// scale 1 inside — the app never knows the monitor).
var options = counterOptions();
var static_tokens = canvas.DesignTokens.theme(.{ .color_scheme = .light });
static_tokens.pixel_snap = .{ .geometry = true, .text = true };
options.tokens = static_tokens;
const app_state = try std.testing.allocator.create(CounterApp);
defer std.testing.allocator.destroy(app_state);
app_state.* = CounterApp.init(std.heap.page_allocator, .{}, options);
defer app_state.deinit();
const app = app_state.app();
try harness.start(app);
// Install on a 2x surface: the effective tokens are a stamped COPY —
// the app owns the appearance, the runtime owns the device scale —
// and the stored tokens carry the real density, so hairlines snap
// against the physical grid instead of scale 1.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
try std.testing.expect(app_state.installed);
try std.testing.expectEqual(@as(f32, 2), app_state.effectiveTokens().pixel_snap.scale);
var stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
try std.testing.expectEqual(@as(f32, 2), stored.pixel_snap.scale);
try std.testing.expectEqualDeep(static_tokens.colors.background, stored.colors.background);
// A frame at a new density (the window dragged to a 1x monitor)
// rebuilds and re-emits for a static-token app too: the stored
// tokens re-snap to the new grid without any app involvement. The
// model is poked directly (no dispatch) so the refreshed retained
// text proves the FRAME triggered the rebuild.
app_state.model.count = 5;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 1,
.frame_index = 2,
.timestamp_ns = 2_000_000,
.nonblank = true,
} });
try std.testing.expect(try retainedTextExists(&harness.runtime, "Count 5"));
try std.testing.expectEqual(@as(f32, 1), app_state.effectiveTokens().pixel_snap.scale);
stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
try std.testing.expectEqual(@as(f32, 1), stored.pixel_snap.scale);
try std.testing.expectEqualDeep(static_tokens.colors.background, stored.colors.background);
}
test "a resize carrying a new density re-stamps and re-emits at the unchanged logical size" {
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
// Static tokens make this the strict case: ordinary rebuilds skip
// the redundant emission, so a re-stamped stored copy below proves
// the stale-scale re-emit fired.
var options = counterOptions();
var static_tokens = canvas.DesignTokens.theme(.{ .color_scheme = .light });
static_tokens.pixel_snap = .{ .geometry = true, .text = true };
options.tokens = static_tokens;
const app_state = try std.testing.allocator.create(CounterApp);
defer std.testing.allocator.destroy(app_state);
app_state.* = CounterApp.init(std.heap.page_allocator, .{}, options);
defer app_state.deinit();
const app = app_state.app();
try harness.start(app);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
try std.testing.expect(app_state.installed);
var stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
try std.testing.expectEqual(@as(f32, 2), stored.pixel_snap.scale);
// A DPI-only monitor move arrives as a resize whose LOGICAL size is
// unchanged — only the event's scale differs. The resize path must
// adopt the density before rebuilding, and the rebuild must re-emit
// even though the layout inputs are identical: without both, the
// stored tokens keep snapping against the old grid until the next
// input. The model is poked directly (no dispatch) so the refreshed
// retained text proves the RESIZE triggered the rebuild.
app_state.model.count = 7;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_resized = .{
.window_id = 1,
.label = canvas_label,
.frame = geometry.RectF.init(0, 0, 400, 300),
.scale_factor = 1,
} });
try std.testing.expect(try retainedTextExists(&harness.runtime, "Count 7"));
try std.testing.expectEqual(@as(f32, 1), app_state.effectiveTokens().pixel_snap.scale);
stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
try std.testing.expectEqual(@as(f32, 1), stored.pixel_snap.scale);
try std.testing.expectEqualDeep(static_tokens.colors.background, stored.colors.background);
}
test "markup watch polls from the reserved runtime timer" {
const io = std.testing.io;
const watch_path = ".zig-cache/ui-app-markup-watch-test.native";
+93
View File
@@ -309,6 +309,99 @@ test "input from the secondary window dispatches through its own tree with its w
try std.testing.expectEqual(@as(u32, 1), fixture.app_state.model.bumps);
}
test "each window's tokens carry its own surface density, not the main canvas's" {
const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) });
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
// Static tokens make this the strict case: ordinary slot rebuilds
// skip the redundant emission, so every re-stamped stored copy below
// proves the stale-scale re-emit fired for THAT window.
var static_tokens = canvas.DesignTokens.theme(.{ .color_scheme = .light });
static_tokens.pixel_snap = .{ .geometry = true, .text = true };
const app_state = try PanelApp.create(std.heap.page_allocator, .{
.name = "ui-app-panel-density",
.scene = panel_scene,
.canvas_label = canvas_label,
.tokens = static_tokens,
.update = panelUpdate,
.view = panelView,
.windows_fn = panelWindows,
.window_view = panelWindowView,
});
defer app_state.destroy();
const app = app_state.app();
try harness.start(app);
// Main canvas installs on a 1x monitor; the model already declares
// the settings window, so the installing rebuild creates it.
app_state.model.settings_open = true;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.label = canvas_label,
.size = geometry.SizeF.init(400, 300),
.scale_factor = 1,
.frame_index = 1,
.timestamp_ns = 1_000_000,
.nonblank = true,
} });
var buffer: [support.platform.max_windows]support.platform.WindowInfo = undefined;
var settings_id: support.platform.WindowId = 0;
for (harness.runtime.listWindows(&buffer)) |info| {
if (std.mem.eql(u8, info.label, settings_window_label)) settings_id = info.id;
}
try std.testing.expect(settings_id != 0);
// The settings window installs on a 2x monitor: ITS stored tokens
// carry 2 while the main canvas keeps 1 — the scale is per-window
// state, the appearance is still the app's single set.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = settings_id,
.label = settings_canvas_label,
.size = geometry.SizeF.init(320, 240),
.scale_factor = 2,
.frame_index = 1,
.timestamp_ns = 2_000_000,
.nonblank = true,
} });
const main_stored = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
try std.testing.expectEqual(@as(f32, 1), main_stored.pixel_snap.scale);
var slot_stored = try harness.runtime.canvasWidgetDesignTokens(settings_id, settings_canvas_label);
try std.testing.expectEqual(@as(f32, 2), slot_stored.pixel_snap.scale);
try std.testing.expectEqualDeep(static_tokens.colors.background, slot_stored.colors.background);
// Dragging the SECONDARY window to a 1x monitor re-stamps only its
// own tokens. The model is poked directly (no dispatch) so the
// refreshed slot text proves the frame triggered the slot rebuild.
app_state.model.bumps = 3;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = settings_id,
.label = settings_canvas_label,
.size = geometry.SizeF.init(320, 240),
.scale_factor = 1,
.frame_index = 2,
.timestamp_ns = 3_000_000,
.nonblank = true,
} });
const rebuilt = try harness.runtime.canvasWidgetLayout(settings_id, settings_canvas_label);
try std.testing.expect(widgetIdByText(rebuilt, .text, "bumped 3") != null);
slot_stored = try harness.runtime.canvasWidgetDesignTokens(settings_id, settings_canvas_label);
try std.testing.expectEqual(@as(f32, 1), slot_stored.pixel_snap.scale);
// A density-carrying RESIZE (the DPI-change channel on hosts that
// rescale the frame in place) re-stamps the slot's tokens too, even
// at an unchanged logical size.
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_resized = .{
.window_id = settings_id,
.label = settings_canvas_label,
.frame = geometry.RectF.init(0, 0, 320, 240),
.scale_factor = 2,
} });
slot_stored = try harness.runtime.canvasWidgetDesignTokens(settings_id, settings_canvas_label);
try std.testing.expectEqual(@as(f32, 2), slot_stored.pixel_snap.scale);
const main_after = try harness.runtime.canvasWidgetDesignTokens(1, canvas_label);
try std.testing.expectEqual(@as(f32, 1), main_after.pixel_snap.scale);
}
// ------------------------------------------------- window-action effects
const VerbModel = struct {