446f1a20
fix: render native fonts at the display pixel scale
a73x 2026-09-04 20:04
Commit message
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 | } | ||