a73x

446f1a20

fix: render native fonts at the display pixel scale

a73x   2026-09-04 20:04

Commit message
fix: render native fonts at the display pixel scale

README.md
Old New
@@ -37,8 +37,11 @@ handoff, including that handoff's own remote-start behavior. `--via` uses a
37 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`. 37 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
38 Closing the window detaches; the session stays on its daemon. When the shell 38 Closing the window detaches; the session stays on its daemon. When the shell
39 exits, the window closes with its exit code. `--font-px` sets the font size 39 exits, the window closes with its exit code. `--font-px` sets the font size
40 in pixels (default 16). `kill -USR1 PID` prints the frame timing table to 40 at 100% display scale (default 16); glyphs are rasterized at the monitor's
41 stderr, as does exit. 41 actual scale and refreshed when the window moves between displays. Linux
42 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER`
43 or `SDL_VIDEODRIVER` setting overrides that preference.
44 `kill -USR1 PID` prints the frame timing table to stderr, as does exit.
42 The end-to-end leg checks the real build mode, reads rendered pixels back 45 The end-to-end leg checks the real build mode, reads rendered pixels back
43 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent 46 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent
44 output; pump apply time is reported separately. 47 output; pump apply time is reported separately.
docs/superpowers/plans/2026-09-04-native-client.md
Old New
@@ -56,6 +56,23 @@ nominal monospace cell metrics, complete HarfBuzz runs, clipped glyph UVs,
56 real drawable-size queries, and build-mode verification from `--version`. 56 real drawable-size queries, and build-mode verification from `--version`.
57 The timing total excludes independently sampled pump work. 57 The timing total excludes independently sampled pump work.
58 58
59 Follow-up fixes from live use:
60
61 - `5570679` fixes `SnapshotAborted` after raw binary output. Ghostty can
62 retain DEL/C0 cells; shared wire readers and writers replace their text
63 with U+FFFD. An isolated pre-fix daemon passed live output and native
64 reattachment with the rebuilt client. The native leg now has ten
65 checkpoints, including retained DEL output and reattachment.
66 - High-DPI rendering now prefers native Wayland on Linux and rasterizes
67 fonts at SDL's display scale. The same window used an 850×1380 X11 buffer
68 before and a 1700×2760 Wayland buffer after, with a 32-pixel raster font
69 at 200% scale. Explicit SDL driver overrides and X11 fallback were checked.
70 Scale changes rebuild font resources and recalculate the session geometry.
71
72 Follow-up validation passed: `make check`, all 16 native unit tests in
73 ReleaseSafe, and all ten native end-to-end checkpoints. Implementation and
74 adversarial review agreed with no outstanding findings.
75
59 --- 76 ---
60 77
61 ## Global Constraints 78 ## Global Constraints
docs/superpowers/specs/2026-09-04-native-client-design.md
Old New
@@ -165,7 +165,7 @@ candidate to move onto it. It is not moved in this change.
165 ### 3. The painter — `src/gui/` 165 ### 3. The painter — `src/gui/`
166 166
167 **`font.zig`** asks fontconfig for the system monospace face (`monospace` 167 **`font.zig`** asks fontconfig for the system monospace face (`monospace`
168 pattern, default size 16 pixels overridable by `--font-px`) and freetype 168 pattern, default size 16 pixels at 100% display scale, overridable by `--font-px`) and freetype
169 for glyph bitmaps. HarfBuzz shapes each cell's complete UTF-8 cluster 169 for glyph bitmaps. HarfBuzz shapes each cell's complete UTF-8 cluster
170 within the daemon's narrow or wide span; it never shapes across cells. 170 within the daemon's narrow or wide span; it never shapes across cells.
171 Regular, bold, italic and bold-italic variants are matched or synthesized. It measures the cell from the face's advance width and its 171 Regular, bold, italic and bold-italic variants are matched or synthesized. It measures the cell from the face's advance width and its
@@ -173,6 +173,13 @@ ascender-plus-descender height, in whole pixels, and rasterises glyph IDs
173 on demand. Colour emoji, fallback faces and hinting choices are deferred; 173 on demand. Colour emoji, fallback faces and hinting choices are deferred;
174 a codepoint the face lacks paints as the face's missing-glyph box. 174 a codepoint the face lacks paints as the face's missing-glyph box.
175 175
176 The window selects its raster size from SDL's display scale after creation.
177 At 200% scale, the default font uses 32 physical pixels. A scale change
178 rebuilds the font, shaped runs and atlas, then resizes the session from the
179 new cell metrics and framebuffer size. Linux prefers native Wayland to
180 avoid compositor scaling of an X11 buffer; explicit SDL driver settings
181 still take precedence.
182
176 **`atlas.zig`** is one R8 texture, shelf-packed, grown by re-upload only 183 **`atlas.zig`** is one R8 texture, shelf-packed, grown by re-upload only
177 when a glyph first appears. It never shrinks. Each entry is the glyph's 184 when a glyph first appears. It never shrinks. Each entry is the glyph's
178 texture rectangle plus its bearing, keyed by face variant and glyph ID. 185 texture rectangle plus its bearing, keyed by face variant and glyph ID.
src/cli/muxg.zig
Old New
@@ -24,7 +24,7 @@ const usage =
24 \\ --sock a local daemon's socket path 24 \\ --sock a local daemon's socket path
25 \\ --via a command whose stdio is the daemon 25 \\ --via a command whose stdio is the daemon
26 \\ --key the QUIC key file (or MUX_KEY_FILE) 26 \\ --key the QUIC key file (or MUX_KEY_FILE)
27 \\ --font-px the face's pixel size (default 16) 27 \\ --font-px font pixels at 100% display scale (default 16)
28 \\ --help --version 28 \\ --help --version
29 \\ 29 \\
30 ; 30 ;
src/gui/font.zig
Old New
@@ -11,6 +11,18 @@ const c = @cImport({
11 @cInclude("harfbuzz/hb-ft.h"); 11 @cInclude("harfbuzz/hb-ft.h");
12 }); 12 });
13 13
14 /// Base font pixels are measured at 100% content scale. The returned size
15 /// is the bitmap resolution in framebuffer pixels, applied exactly once.
16 pub fn scaledPixels(base: u16, display_scale: f32) u16 {
17 const scale = if (std.math.isFinite(display_scale) and display_scale > 0) display_scale else 1;
18 const px = @round(@as(f32, @floatFromInt(base)) * scale);
19 return @intFromFloat(std.math.clamp(px, 1, 1024));
20 }
21
22 pub fn atlasWidth(px: u16) u16 {
23 return @intCast(@max(@as(u32, px) * 2, 1024));
24 }
25
14 pub const Variant = atlas.Variant; 26 pub const Variant = atlas.Variant;
15 pub const PositionedGlyph = struct { glyph_id: u32, x_advance: i32, y_advance: i32, x_offset: i32, y_offset: i32 }; 27 pub const PositionedGlyph = struct { glyph_id: u32, x_advance: i32, y_advance: i32, x_offset: i32, y_offset: i32 };
16 pub const Run = struct { 28 pub const Run = struct {
@@ -37,6 +49,7 @@ pub const Face = struct {
37 cell_w: u16, 49 cell_w: u16,
38 cell_h: u16, 50 cell_h: u16,
39 ascent: u16, 51 ascent: u16,
52 pixels: u16,
40 pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, Shape, GlyphLoad, OutOfMemory }; 53 pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, Shape, GlyphLoad, OutOfMemory };
41 fn handle(self: *Face, v: Variant) *Handle { 54 fn handle(self: *Face, v: Variant) *Handle {
42 return &self.handles[@intFromEnum(v)]; 55 return &self.handles[@intFromEnum(v)];
@@ -105,7 +118,7 @@ pub const Face = struct {
105 const w = @max(@as(i64, @intCast((hs[0].face.*.glyph.*.advance.x + 63) >> 6)), 1); 118 const w = @max(@as(i64, @intCast((hs[0].face.*.glyph.*.advance.x + 63) >> 6)), 1);
106 const h = @max(@as(i64, @intCast((m.height + 63) >> 6)), 1); 119 const h = @max(@as(i64, @intCast((m.height + 63) >> 6)), 1);
107 const asc = @as(i64, @intCast((m.ascender + 63) >> 6)); 120 const asc = @as(i64, @intCast((m.ascender + 63) >> 6));
108 return .{ .lib = lib, .handles = hs, .cell_w = @intCast(w), .cell_h = @intCast(h), .ascent = @intCast(std.math.clamp(asc, 1, h)) }; 121 return .{ .lib = lib, .handles = hs, .cell_w = @intCast(w), .cell_h = @intCast(h), .ascent = @intCast(std.math.clamp(asc, 1, h)), .pixels = px };
109 } 122 }
110 pub fn deinit(self: *Face) void { 123 pub fn deinit(self: *Face) void {
111 for (&self.handles) |*h| { 124 for (&self.handles) |*h| {
@@ -165,6 +178,24 @@ pub const GlyphCache = struct {
165 } 178 }
166 self.runs.deinit(self.alloc); 179 self.runs.deinit(self.alloc);
167 } 180 }
181 /// Keep face and atlas addresses stable for the renderer. Build both
182 /// replacements first, so an allocation/font failure leaves the current
183 /// cache usable; glyph IDs and bitmap coordinates never cross sizes.
184 pub fn setPixelSize(self: *GlyphCache, px: u16) !bool {
185 if (self.face.pixels == px) return false;
186 var next_face = try Face.open(px);
187 errdefer next_face.deinit();
188 var next_atlas = try atlas.Atlas.init(self.alloc, atlasWidth(px), 256);
189 next_atlas.dirty = true;
190 self.deinit();
191 self.face.deinit();
192 self.glyph_atlas.deinit(self.alloc);
193 self.face.* = next_face;
194 self.glyph_atlas.* = next_atlas;
195 self.runs = .empty;
196 return true;
197 }
198
168 fn key(self: *GlyphCache, text: []const u8, variant: Variant) ![]u8 { 199 fn key(self: *GlyphCache, text: []const u8, variant: Variant) ![]u8 {
169 const out = try self.alloc.alloc(u8, text.len + 1); 200 const out = try self.alloc.alloc(u8, text.len + 1);
170 out[0] = @intFromEnum(variant); 201 out[0] = @intFromEnum(variant);
@@ -250,3 +281,70 @@ test "cache owns complete styled cluster and preserves shaping through atlas gro
250 try std.testing.expect(v1 <= 1.0); 281 try std.testing.expect(v1 <= 1.0);
251 } 282 }
252 } 283 }
284
285 test "display scaling chooses rounded bounded framebuffer font pixels" {
286 try std.testing.expectEqual(@as(u16, 16), scaledPixels(16, 1));
287 try std.testing.expectEqual(@as(u16, 20), scaledPixels(16, 1.25));
288 try std.testing.expectEqual(@as(u16, 24), scaledPixels(16, 1.5));
289 try std.testing.expectEqual(@as(u16, 32), scaledPixels(16, 2));
290 try std.testing.expectEqual(@as(u16, 21), scaledPixels(17, 1.25));
291 try std.testing.expectEqual(@as(u16, 1), scaledPixels(1, 0.25));
292 try std.testing.expectEqual(@as(u16, 1024), scaledPixels(256, 8));
293 for ([_]f32{ 0, -1, std.math.nan(f32), std.math.inf(f32) }) |scale| {
294 try std.testing.expectEqual(@as(u16, 16), scaledPixels(16, scale));
295 }
296 }
297
298 test "scale rebuild replaces glyph bitmaps and cached runs at stable resource addresses" {
299 const alloc = std.testing.allocator;
300 var face = try Face.open(16);
301 defer face.deinit();
302 var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256);
303 defer glyph_atlas.deinit(alloc);
304 var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas };
305 defer cache.deinit();
306 try cache.prepare("M", .regular);
307 const small = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
308 try std.testing.expect(!try cache.setPixelSize(16));
309 try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
310 for ([_]u16{ 32, 20, 16 }) |px| {
311 try std.testing.expect(try cache.setPixelSize(px));
312 try std.testing.expectEqual(@as(u16, px), face.pixels);
313 try std.testing.expectEqual(@as(usize, 0), cache.runs.count());
314 try std.testing.expectEqual(@as(u32, 0), glyph_atlas.entries.count());
315 try std.testing.expect(glyph_atlas.dirty);
316 try cache.prepare("M", .regular);
317 const glyph = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
318 if (px == 32) {
319 try std.testing.expect(glyph.w > small.w);
320 try std.testing.expect(glyph.h > small.h);
321 } else if (px == 16) {
322 try std.testing.expectEqual(small.w, glyph.w);
323 try std.testing.expectEqual(small.h, glyph.h);
324 }
325 try std.testing.expectEqual(@as(*Face, &face), cache.face);
326 try std.testing.expectEqual(@as(*atlas.Atlas, &glyph_atlas), cache.glyph_atlas);
327 }
328 }
329
330 test "failed scale atlas allocation keeps the old face and glyph cache usable" {
331 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
332 const alloc = failing.allocator();
333 var face = try Face.open(16);
334 defer face.deinit();
335 var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256);
336 defer glyph_atlas.deinit(alloc);
337 var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas };
338 defer cache.deinit();
339 try cache.prepare("M", .regular);
340 const previous = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
341 const previous_pixels = glyph_atlas.pixels.ptr;
342 failing.fail_index = failing.alloc_index;
343 try std.testing.expectError(error.OutOfMemory, cache.setPixelSize(32));
344 try std.testing.expectEqual(@as(u16, 16), face.pixels);
345 try std.testing.expectEqual(previous_pixels, glyph_atlas.pixels.ptr);
346 try std.testing.expectEqual(previous, (try GlyphCache.resolve(&cache, "M", .regular))[0].entry);
347 failing.fail_index = std.math.maxInt(usize);
348 try cache.prepare("W", .regular);
349 try std.testing.expectEqual(@as(usize, 2), cache.runs.count());
350 }
src/gui/frame.zig
Old New
@@ -26,6 +26,7 @@ const c = @cImport({
26 pub const Options = struct { 26 pub const Options = struct {
27 target: client.Target, 27 target: client.Target,
28 session: []const u8, 28 session: []const u8,
29 /// Face pixel size at 100% display scale.
29 font_px: u16 = 16, 30 font_px: u16 = 16,
30 width: u32 = 960, 31 width: u32 = 960,
31 height: u32 = 600, 32 height: u32 = 600,
@@ -246,12 +247,13 @@ const Events = struct {
246 win: *c.SDL_Window, 247 win: *c.SDL_Window,
247 wake: *Wake, 248 wake: *Wake,
248 hook: ?*HookReader, 249 hook: ?*HookReader,
249 cell_w: u16, 250 cache: *font.GlyphCache,
250 cell_h: u16, 251 base_font_px: u16,
251 cells: CellsOf, 252 cells: CellsOf,
252 fb_w: c_int, 253 fb_w: c_int,
253 fb_h: c_int, 254 fb_h: c_int,
254 dirty: bool = true, 255 dirty: bool = true,
256 geometry_dirty: bool = false,
255 suppress_text: bool = false, 257 suppress_text: bool = false,
256 258
257 fn handle(self: *Events, ev: c.SDL_Event) !bool { 259 fn handle(self: *Events, ev: c.SDL_Event) !bool {
@@ -282,15 +284,7 @@ const Events = struct {
282 }, 284 },
283 c.SDL_EVENT_KEY_UP => self.suppress_text = false, 285 c.SDL_EVENT_KEY_UP => self.suppress_text = false,
284 c.SDL_EVENT_WINDOW_FOCUS_LOST => self.suppress_text = false, 286 c.SDL_EVENT_WINDOW_FOCUS_LOST => self.suppress_text = false,
285 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED => { 287 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true,
286 if (!c.SDL_GetWindowSizeInPixels(self.win, &self.fb_w, &self.fb_h)) return error.WindowSizeFailed;
287 const now = cellsOf(@intCast(@max(self.fb_w, 1)), @intCast(@max(self.fb_h, 1)), self.cell_w, self.cell_h);
288 if (!std.meta.eql(now, self.cells)) {
289 self.cells = now;
290 try self.pump.say(.{ .resize = .{ .cols = now.cols, .rows = now.rows } });
291 }
292 self.dirty = true;
293 },
294 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, 288 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true,
295 else => if (ev.type == self.wake.event_type) { 289 else => if (ev.type == self.wake.event_type) {
296 self.dirty = true; 290 self.dirty = true;
@@ -298,6 +292,30 @@ const Events = struct {
298 } 292 }
299 return true; 293 return true;
300 } 294 }
295
296 /// Resize and scale notifications can arrive together in either order.
297 /// Query once after the event batch, then use the same physical metrics
298 /// for the daemon's size claim and this frame's glyphs.
299 fn refreshGeometry(self: *Events) !void {
300 if (!self.geometry_dirty) return;
301 var fb_w: c_int = 0;
302 var fb_h: c_int = 0;
303 if (!c.SDL_GetWindowSizeInPixels(self.win, &fb_w, &fb_h)) return error.WindowSizeFailed;
304 try self.updateGeometry(fb_w, fb_h, c.SDL_GetWindowDisplayScale(self.win));
305 }
306
307 fn updateGeometry(self: *Events, fb_w: c_int, fb_h: c_int, display_scale: f32) !void {
308 _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, display_scale));
309 self.fb_w = fb_w;
310 self.fb_h = fb_h;
311 const now = cellsOf(@intCast(@max(self.fb_w, 1)), @intCast(@max(self.fb_h, 1)), self.cache.face.cell_w, self.cache.face.cell_h);
312 if (!std.meta.eql(now, self.cells)) {
313 try self.pump.say(.{ .resize = .{ .cols = now.cols, .rows = now.rows } });
314 self.cells = now;
315 }
316 self.geometry_dirty = false;
317 self.dirty = true;
318 }
301 }; 319 };
302 320
303 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { 321 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
@@ -309,11 +327,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
309 std.posix.sigaction(std.posix.SIG.USR1, &sa, &old_sa); 327 std.posix.sigaction(std.posix.SIG.USR1, &sa, &old_sa);
310 defer std.posix.sigaction(std.posix.SIG.USR1, &old_sa, null); 328 defer std.posix.sigaction(std.posix.SIG.USR1, &old_sa, null);
311 329
312 var face = font.Face.open(opts.font_px) catch |err| { 330 // A low-resolution X11 window may be scaled by the compositor. Prefer
313 std.debug.print("muxg: font: {s}\n", .{@errorName(err)}); 331 // native Wayland, while leaving both explicit driver variables in control.
314 return 2; 332 if (@import("builtin").os.tag == .linux and std.posix.getenv("SDL_VIDEO_DRIVER") == null and std.posix.getenv("SDL_VIDEODRIVER") == null) {
315 }; 333 _ = c.SDL_SetHintWithPriority(c.SDL_HINT_VIDEO_DRIVER, "wayland,x11", c.SDL_HINT_DEFAULT);
316 defer face.deinit(); 334 }
317 if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init"); 335 if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init");
318 defer c.SDL_Quit(); 336 defer c.SDL_Quit();
319 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); 337 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
@@ -321,6 +339,12 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
321 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE); 339 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
322 const win = c.SDL_CreateWindow("muxg", @intCast(opts.width), @intCast(opts.height), c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY) orelse return sdlFail("SDL_CreateWindow"); 340 const win = c.SDL_CreateWindow("muxg", @intCast(opts.width), @intCast(opts.height), c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY) orelse return sdlFail("SDL_CreateWindow");
323 defer c.SDL_DestroyWindow(win); 341 defer c.SDL_DestroyWindow(win);
342 const raster_px = font.scaledPixels(opts.font_px, c.SDL_GetWindowDisplayScale(win));
343 var face = font.Face.open(raster_px) catch |err| {
344 std.debug.print("muxg: font: {s}\n", .{@errorName(err)});
345 return 2;
346 };
347 defer face.deinit();
324 const context = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext"); 348 const context = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext");
325 defer _ = c.SDL_GL_DestroyContext(context); 349 defer _ = c.SDL_GL_DestroyContext(context);
326 _ = c.SDL_GL_SetSwapInterval(1); 350 _ = c.SDL_GL_SetSwapInterval(1);
@@ -330,7 +354,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
330 }; 354 };
331 defer renderer.deinit(); 355 defer renderer.deinit();
332 if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput"); 356 if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput");
333 var glyph_atlas = try atlas.Atlas.init(alloc, 1024, 256); 357 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(raster_px), 256);
334 defer glyph_atlas.deinit(alloc); 358 defer glyph_atlas.deinit(alloc);
335 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; 359 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas };
336 defer cache.deinit(); 360 defer cache.deinit();
@@ -349,7 +373,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
349 defer pump.stop(); 373 defer pump.stop();
350 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; 374 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
351 defer if (hook) |*h| h.deinit(); 375 defer if (hook) |*h| h.deinit();
352 var events: Events = .{ .pump = pump, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cell_w = face.cell_w, .cell_h = face.cell_h, .cells = cells, .fb_w = fb_w, .fb_h = fb_h }; 376 var events: Events = .{ .pump = pump, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .cells = cells, .fb_w = fb_w, .fb_h = fb_h };
353 var last_phase: ?session_pump.Phase = null; 377 var last_phase: ?session_pump.Phase = null;
354 var bell_until: i64 = 0; 378 var bell_until: i64 = 0;
355 var visible_blink = false; 379 var visible_blink = false;
@@ -370,6 +394,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
370 if (!try events.handle(ev)) return 0; 394 if (!try events.handle(ev)) return 0;
371 } 395 }
372 } 396 }
397 try events.refreshGeometry();
373 // Clearing before painting lets a concurrent apply queue the next wake. 398 // Clearing before painting lets a concurrent apply queue the next wake.
374 if (wake.pending.swap(false, .acq_rel)) events.dirty = true; 399 if (wake.pending.swap(false, .acq_rel)) events.dirty = true;
375 if (usr1_seen.swap(false, .acq_rel)) report(&ring); 400 if (usr1_seen.swap(false, .acq_rel)) report(&ring);
@@ -488,3 +513,53 @@ test "drawable dimensions floor, clamp small windows, and cap wire columns" {
488 try std.testing.expect(parseHook("resize:0x400") == null); 513 try std.testing.expect(parseHook("resize:0x400") == null);
489 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text); 514 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text);
490 } 515 }
516
517 test "display-scale events rebuild physical font metrics and coalesce drawable resize" {
518 const alloc = std.testing.allocator;
519 var face = try font.Face.open(16);
520 defer face.deinit();
521 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(16), 256);
522 defer glyph_atlas.deinit(alloc);
523 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas };
524 defer cache.deinit();
525 const initial = cellsOf(960, 600, face.cell_w, face.cell_h);
526 const pump = try session_pump.Pump.start(alloc, .{ .target = .{ .via = "cat" }, .cols = initial.cols, .rows = initial.rows });
527 defer pump.stop();
528 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
529 var events: Events = .{ .pump = pump, .win = undefined, .wake = &wake, .hook = null, .cache = &cache, .base_font_px = 16, .cells = initial, .fb_w = 960, .fb_h = 600, .dirty = false };
530 var event = std.mem.zeroes(c.SDL_Event);
531 event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED;
532 try std.testing.expect(try events.handle(event));
533 try std.testing.expect(events.geometry_dirty);
534 event.type = c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED;
535 try std.testing.expect(try events.handle(event));
536 // A scale-only change must alter the cell claim even if the compositor
537 // has not changed the framebuffer size yet.
538 try events.updateGeometry(960, 600, 2);
539 try std.testing.expect(events.cells.cols < initial.cols);
540 try std.testing.expect(events.cells.rows < initial.rows);
541 try events.updateGeometry(1920, 1200, 2);
542 try std.testing.expectEqual(@as(u16, 32), face.pixels);
543 // Hinting rounds metrics to whole pixels at each size; doubling the
544 // raster and drawable preserves the grid within that small rounding gap.
545 try std.testing.expect(@abs(@as(i32, events.cells.cols) - initial.cols) <= @max(@as(u32, initial.cols) / 8, 1));
546 try std.testing.expect(@abs(@as(i32, events.cells.rows) - initial.rows) <= @max(@as(u32, initial.rows) / 8, 1));
547 try std.testing.expectEqual(cellsOf(1920, 1200, face.cell_w, face.cell_h), events.cells);
548 try std.testing.expect(events.dirty);
549 try std.testing.expect(!events.geometry_dirty);
550
551 // A drawable resize still updates the grid when rounding leaves the
552 // raster size unchanged, and it preserves the prepared glyph cache.
553 try cache.prepare("M", .regular);
554 try events.updateGeometry(1600, 1000, 2.001);
555 try std.testing.expectEqual(@as(u16, 32), face.pixels);
556 try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
557 try std.testing.expectEqual(cellsOf(1600, 1000, face.cell_w, face.cell_h), events.cells);
558 try events.updateGeometry(1200, 750, 1.25);
559 try std.testing.expectEqual(@as(u16, 20), face.pixels);
560 try std.testing.expectEqual(@as(usize, 0), cache.runs.count());
561 try std.testing.expectEqual(cellsOf(1200, 750, face.cell_w, face.cell_h), events.cells);
562 try events.updateGeometry(960, 600, 1);
563 try std.testing.expectEqual(@as(u16, 16), face.pixels);
564 try std.testing.expectEqual(initial, events.cells);
565 }