From 848ad99fa597387a85f75e752dc4c9e10f8c24f4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 28 Aug 2026 16:12:01 -0300 Subject: macos fix --- src/gui/gui.zig | 152 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 121 insertions(+), 31 deletions(-) (limited to 'src/gui') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index bfd02ff9..91380e46 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -89,28 +89,45 @@ const crt_frag_spv = @embedFile("crt.frag.spv"); /// inside every cell the core left at its default background. The FALLBACK /// for it, that is: the ground itself is the active theme's own background, /// read off the core once a frame (see `ground`), and this is what a theme -/// with none of its own gets. +/// with none of its own gets in an OPAQUE window. /// /// A theme declares no background (the curated `dark`, every vendored /// `*_transparent`) to mean "wear whatever the terminal is wearing", and a -/// window has nothing to wear. The AppKit shell answers that by going -/// see-through over an NSVisualEffectView — pardes_theme_bg, docs/macos.md — -/// and this shell cannot follow it there: SDL's GPU API refuses to claim a -/// SDL_WINDOW_TRANSPARENT window at all ("The GPU API doesn't support -/// transparent windows", SDL_gpu.c), because D3D12 has no transparent -/// swapchain and the API says no everywhere rather than only where it must. -/// So a themeless window keeps the terminal-native dark it always wore. +/// window has nothing to wear unless it is see-through. The AppKit shell +/// answers that by going transparent over an NSVisualEffectView +/// (pardes_theme_bg, docs/macos.md); this shell answers it with +/// `config.gui_transparent`, which asks SDL for a transparent window and lets +/// the compositor be the backdrop. That knob is off by default and costs a +/// readback per painted frame when it is on — the whole reason is written +/// where it is declared. Without it a themeless window keeps the +/// terminal-native dark it always wore. const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; +/// The ground under this frame, and whether it is a colour at all. +/// +/// `clear` is the themeless case in a transparent window: nothing is painted +/// there, the desktop is. `rgb` still carries the fallback colour because a +/// reverse-video cell puts the ground in its FOREGROUND, where it is a real +/// colour that paints — the same rule PardesView.styleFor states on macOS. +const Ground = struct { + rgb: [3]u8, + clear: bool, + + fn opaqueRgb(rgb: [3]u8) Ground { + return .{ .rgb = rgb, .clear = false }; + } +}; + /// The ground under this frame. The theme's OWN background and not the /// animated chrome colour: taglines fade between themes over a handful of /// frames, document backgrounds switch the instant the theme does, and this is /// one of those. Asked per frame, so a `Theme` command takes hold without a /// relaunch — and asked at all because a hand-agreed constant was a black line /// along the two edges of every light-themed window. -fn ground(theme_bg: ?[3]u8) [3]u8 { - return theme_bg orelse bg_default; +fn ground(theme_bg: ?[3]u8, transparent: bool) Ground { + if (theme_bg) |rgb| return .opaqueRgb(rgb); + return .{ .rgb = bg_default, .clear = transparent }; } // the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ @@ -544,7 +561,14 @@ const ImageInstance = extern struct { }; const initial_image_capacity: u32 = pardes.MAX_PANES; +/// `CellInstance.effect` is an effect id in its low bits and flags in its top +/// two. Both shaders that read the field mask the id off with `0x3fffffff`; +/// widening this pair means widening that mask with it. const old_layer_bit: u32 = 0x8000_0000; +/// This cell's background IS the see-through ground: emit the glyph and let +/// the compositor keep the rest. Only ever set when `Ground.clear` holds, so +/// an opaque window never reaches the branch. +const clear_bg_bit: u32 = 0x4000_0000; /// Image placement retained across pane destruction. Deliberately does not /// contain ImagePlace.rgba: the producer owns those bytes, while the renderer @@ -1370,7 +1394,15 @@ const Gui = struct { // not support presenting"). The GPU still renders, so render offscreen // exactly like capture does and blit the readback through SDL_Renderer, // which goes out over wl_shm. + // + // `config.gui_transparent` takes the same path deliberately rather than by + // failure: SDL's GPU API refuses to claim a transparent window at all, and + // SDL_Renderer is the presenter that does honour one. soft_present: bool = false, + /// The window was created with SDL_WINDOW_TRANSPARENT, so a themeless + /// ground is nothing at all instead of `bg_default`. Implies + /// `soft_present`; read per frame by `ground`. + transparent: bool = false, soft_renderer: ?*c.SDL_Renderer = null, soft_texture: ?*c.SDL_Texture = null, soft_tex_w: u32 = 0, @@ -1737,6 +1769,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; + // A see-through buffer, so a theme with no background of its own shows the + // compositor's backdrop instead of `bg_default`. Asked for at CREATION + // because that is the only time it can be: X11 picks the 32-bit visual + // here, and the Wayland backend decides here whether to keep an opaque + // region on the surface. + if (config.gui_transparent) win_flags |= c.SDL_WINDOW_TRANSPARENT; const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); return error.SdlInit; @@ -1758,14 +1796,24 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u // the device and present the readback through SDL_Renderer instead. // PARDES_SOFT_PRESENT=1 takes that path on a compositor that could present, // which is how the path is exercised without a remote display. + // + // A transparent window does not even attempt the claim. It is not a + // compositor's shortcoming and there is nothing to retry: SDL_gpu.c fails + // SDL_ClaimWindowForGPUDevice for SDL_WINDOW_TRANSPARENT unconditionally, + // because D3D12 has no transparent swapchain and the API says no + // everywhere rather than only where it must. SDL_Renderer's own vulkan and + // opengl backends do honour one, and that is the presenter this path + // already had. var soft_present = false; var soft_renderer: ?*c.SDL_Renderer = null; const force_soft = if (env.get("PARDES_SOFT_PRESENT")) |raw| !(std.mem.eql(u8, raw, "0") or raw.len == 0) else false; - if (force_soft or !c.SDL_ClaimWindowForGPUDevice(device, window)) { - if (force_soft) + if (config.gui_transparent or force_soft or !c.SDL_ClaimWindowForGPUDevice(device, window)) { + if (config.gui_transparent) + log.info("transparent window: the GPU API will not claim one, presenting through SDL_Renderer", .{}) + else if (force_soft) log.info("PARDES_SOFT_PRESENT set: presenting in software", .{}) else log.warn("ClaimWindowForGPUDevice: {s}; presenting in software", .{c.SDL_GetError()}); @@ -1912,6 +1960,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .capture = test_mode, .capture_dir = capture_dir orelse "", .soft_present = soft_present, + .transparent = config.gui_transparent, .soft_renderer = soft_renderer, }; setGuiFontName(&g, "Adwaita Mono"); @@ -4325,7 +4374,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { /// to the body, which is the whole of the clipping — the shell draws the grid /// in one flat pass, so without it the overhanging rows would land on the /// pane's own tag and on whatever is below it. -fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: [3]u8) u32 { +fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; for (surface.panelTracks()) |track| @@ -4959,17 +5008,22 @@ fn renderFrame( const cells: u32 = @as(u32, surface.cols) * surface.rows; - const page = ground(theme_bg); + const page = ground(theme_bg, g.transparent); const tagline_base = pardes.Cell{ .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline }, .default = false, }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; - color_target.clear_color = .{ - .r = @as(f32, @floatFromInt(page[0])) / 255.0, - .g = @as(f32, @floatFromInt(page[1])) / 255.0, - .b = @as(f32, @floatFromInt(page[2])) / 255.0, + // Premultiplied, because that is what both a wl_surface and an X11 ARGB + // visual are composited as, and the glyph pass writes premultiplied for + // the same reason. A see-through ground is therefore all four channels + // zero and not `page.rgb` at alpha zero — the leftover colour would tint + // every glyph edge that blends against it. + color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ + .r = @as(f32, @floatFromInt(page.rgb[0])) / 255.0, + .g = @as(f32, @floatFromInt(page.rgb[1])) / 255.0, + .b = @as(f32, @floatFromInt(page.rgb[2])) / 255.0, .a = 1.0, }; color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; @@ -5259,6 +5313,9 @@ const ResolvedCell = struct { fg: [3]u8, bg: [3]u8, role: pardes.FontRole, + /// `bg` is the see-through ground rather than a colour: paint the glyph + /// and leave the rest of the cell to the compositor. + clear_bg: bool = false, }; fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { @@ -5310,10 +5367,18 @@ test "tagline bands face the topbar rule and Tagbottom faces the window edge" { try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h)); } -fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: [3]u8) ResolvedCell { +fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { var fg = fg_default; - var bg = page; + var bg = page.rgb; + // Only an UNREVERSED default background is the ground; every branch below + // that names a real colour clears this, and the reverse at the end clears + // it because a reverse puts the TEXT colour there. + var clear_bg = page.clear; var reverse = is_cursor; + // An invisible cell over a clear ground has nothing left to draw: `fg = bg` + // hides a glyph by painting it in the background, and a background that is + // not painted at all would let the ink through as a coloured silhouette. + var blank = false; if (!cell.default) { const st = cell.style; fg = switch (st.fg) { @@ -5321,25 +5386,38 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs .index => |i| palColor(i), .rgb => |rgb| rgb, }; - bg = switch (st.bg) { - .default => page, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; + switch (st.bg) { + .default => {}, + .index => |i| { + bg = palColor(i); + clear_bg = false; + }, + .rgb => |rgb| { + bg = rgb; + clear_bg = false; + }, + } if (st.reverse) reverse = !reverse; - if (st.invisible) fg = bg; + if (st.invisible) { + fg = bg; + blank = clear_bg; + } if (st.dim) for (&fg) |*ch| { ch.* = @intCast(@as(u16, ch.*) * 6 / 10); }; } - if (reverse) std.mem.swap([3]u8, &fg, &bg); + if (reverse) { + std.mem.swap([3]u8, &fg, &bg); + clear_bg = false; + } - const cp = cellCodepoint(cell); + const cp = if (blank) ' ' else cellCodepoint(cell); return .{ .slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role), .fg = fg, .bg = bg, .role = role, + .clear_bg = clear_bg, }; } @@ -5401,7 +5479,7 @@ fn emitSurfaceCell( tagline_base: *const pardes.Cell, old_layer: bool, is_cursor: bool, - page: [3]u8, + page: Ground, ) void { const role = drawnFontRole(core, cell, row); if (role == .tagline) { @@ -5437,8 +5515,9 @@ fn emitInstance( /// change retains the correct band height on both sides of the diff. old_layer: bool, is_cursor: bool, - /// the ground this frame: what a default background resolves to - page: [3]u8, + /// the ground this frame: what a default background resolves to, and + /// whether that is a colour at all + page: Ground, ) void { const resolved = resolveCell(g, cell, role, is_cursor, page); @@ -5499,6 +5578,7 @@ fn emitInstance( if (track) |active| panelCellCoord(active, col, row) else 0, ); if (old_layer) instances[idx].effect |= old_layer_bit; + if (resolved.clear_bg) instances[idx].effect |= clear_bg_bit; } // EFFECT_CODE_CELL_INSTANCE_END @@ -5946,6 +6026,11 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { @intCast(height), ) orelse return error.GpuCreate; _ = c.SDL_SetTextureScaleMode(next, c.SDL_SCALEMODE_NEAREST); + // The readback IS the frame, alpha included and already premultiplied. + // SDL's default for an alpha format is BLENDMODE_BLEND, which would blend + // it a second time against the cleared window and darken every glyph edge + // over a see-through ground. + _ = c.SDL_SetTextureBlendMode(next, c.SDL_BLENDMODE_NONE); g.soft_texture = next; g.soft_tex_w = width; g.soft_tex_h = height; @@ -5989,6 +6074,11 @@ fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTex const texture = try ensureSoftTexture(g, sw, sh); const renderer = g.soft_renderer orelse return error.GpuCreate; if (!c.SDL_UpdateTexture(texture, null, mapped, @intCast(sw * bpp))) return error.GpuMap; + // Clear to nothing rather than to opaque black: on a transparent window + // this is the pixel the compositor keeps wherever the frame does not + // cover, and BLENDMODE_NONE below writes the frame over it verbatim. + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); + _ = c.SDL_SetRenderDrawColor(renderer, 0, 0, 0, if (g.transparent) 0 else 255); _ = c.SDL_RenderClear(renderer); _ = c.SDL_RenderTexture(renderer, texture, null, null); _ = c.SDL_RenderPresent(renderer); -- cgit v1.3