From 848ad99fa597387a85f75e752dc4c9e10f8c24f4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 28 Aug 2026 16:12:01 -0300 Subject: macos fix --- shaders/crt.frag.glsl | 27 ++++++-- shaders/prebuilt/crt.frag.glsl | 27 ++++++-- shaders/prebuilt/crt.frag.spv | Bin 13348 -> 13832 bytes shaders/prebuilt/ui.frag.glsl | 20 ++++-- shaders/prebuilt/ui.frag.spv | Bin 3908 -> 4076 bytes shaders/prebuilt/ui.vert.glsl | 7 +- shaders/prebuilt/ui.vert.spv | Bin 5468 -> 5468 bytes shaders/ui.frag.glsl | 20 ++++-- shaders/ui.vert.glsl | 7 +- src/config.zig | 26 +++++++ src/gui/gui.zig | 152 ++++++++++++++++++++++++++++++++--------- src/main.zig | 20 ++++++ 12 files changed, 245 insertions(+), 61 deletions(-) diff --git a/shaders/crt.frag.glsl b/shaders/crt.frag.glsl index e43d5b86..b0b0b93b 100644 --- a/shaders/crt.frag.glsl +++ b/shaders/crt.frag.glsl @@ -81,24 +81,29 @@ vec2 sceneUv(vec2 uv) { return uv; } -vec3 sceneSrgb(vec2 uv) { +// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives +// premultiplied and partly empty, and a pass that dropped .a would turn the +// desktop behind a `*_transparent` theme into opaque black the moment Crt, +// Ripple or Glitch came on. +vec4 sceneSrgb(vec2 uv) { vec2 warped = sceneUv(uv); - vec3 center = texture(u_scene, warped).rgb; + vec4 center = texture(u_scene, warped); if (u_crt.effects.z <= 0.5) return center; float band = floor(warped.y * 96.0); float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); float tear = smoothstep(0.955, 1.0, seed); vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0); - return vec3( + return vec4( texture(u_scene, warped + chroma).r, center.g, - texture(u_scene, warped - chroma).b + texture(u_scene, warped - chroma).b, + center.a ); } vec3 effectSceneLinear(vec2 uv) { - return srgbToLinear(sceneSrgb(uv)); + return srgbToLinear(sceneSrgb(uv).rgb); } void main() { @@ -113,11 +118,16 @@ void main() { if (u_crt.effects.x <= 0.5) { // The scene texture is already sRGB. Avoid a needless transfer-curve // round trip so a coordinate-only effect preserves theme bytes. - o_col = vec4(clamp(sceneSrgb(uv), 0.0, 1.0), 1.0); + vec4 s = sceneSrgb(uv); + o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0)); return; } vec3 col = vec3(0.0); + // Outside the tube nothing is drawn, so nothing is covered either: the + // old opaque black there would have punched a rectangle out of the + // compositor's backdrop. + float alpha = 0.0; float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); if (edge > 0.0) { vec3 center = effectSceneLinear(uv); @@ -153,10 +163,13 @@ void main() { float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU); col *= tube * vignette * hum * EXPOSURE; + // The tube's edge falloff dims coverage as well as colour; everything + // else here is a light adjustment and leaves it alone. + alpha = sceneSrgb(uv).a * tube; float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0); col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0)); } - o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), 1.0); + o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0)); } diff --git a/shaders/prebuilt/crt.frag.glsl b/shaders/prebuilt/crt.frag.glsl index e43d5b86..b0b0b93b 100644 --- a/shaders/prebuilt/crt.frag.glsl +++ b/shaders/prebuilt/crt.frag.glsl @@ -81,24 +81,29 @@ vec2 sceneUv(vec2 uv) { return uv; } -vec3 sceneSrgb(vec2 uv) { +// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives +// premultiplied and partly empty, and a pass that dropped .a would turn the +// desktop behind a `*_transparent` theme into opaque black the moment Crt, +// Ripple or Glitch came on. +vec4 sceneSrgb(vec2 uv) { vec2 warped = sceneUv(uv); - vec3 center = texture(u_scene, warped).rgb; + vec4 center = texture(u_scene, warped); if (u_crt.effects.z <= 0.5) return center; float band = floor(warped.y * 96.0); float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); float tear = smoothstep(0.955, 1.0, seed); vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0); - return vec3( + return vec4( texture(u_scene, warped + chroma).r, center.g, - texture(u_scene, warped - chroma).b + texture(u_scene, warped - chroma).b, + center.a ); } vec3 effectSceneLinear(vec2 uv) { - return srgbToLinear(sceneSrgb(uv)); + return srgbToLinear(sceneSrgb(uv).rgb); } void main() { @@ -113,11 +118,16 @@ void main() { if (u_crt.effects.x <= 0.5) { // The scene texture is already sRGB. Avoid a needless transfer-curve // round trip so a coordinate-only effect preserves theme bytes. - o_col = vec4(clamp(sceneSrgb(uv), 0.0, 1.0), 1.0); + vec4 s = sceneSrgb(uv); + o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0)); return; } vec3 col = vec3(0.0); + // Outside the tube nothing is drawn, so nothing is covered either: the + // old opaque black there would have punched a rectangle out of the + // compositor's backdrop. + float alpha = 0.0; float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); if (edge > 0.0) { vec3 center = effectSceneLinear(uv); @@ -153,10 +163,13 @@ void main() { float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU); col *= tube * vignette * hum * EXPOSURE; + // The tube's edge falloff dims coverage as well as colour; everything + // else here is a light adjustment and leaves it alone. + alpha = sceneSrgb(uv).a * tube; float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0); col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0)); } - o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), 1.0); + o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0)); } diff --git a/shaders/prebuilt/crt.frag.spv b/shaders/prebuilt/crt.frag.spv index 057f0e06..f0e14627 100644 Binary files a/shaders/prebuilt/crt.frag.spv and b/shaders/prebuilt/crt.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index ddc1cde0..69cb63b9 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -1,7 +1,10 @@ #version 450 // Sample the glyph atlas (R = coverage 0..1) and mix the cell's bg up to its fg -// by that coverage. Fully opaque: the swapchain is SDR, no blending needed. +// by that coverage. Output is PREMULTIPLIED: an opaque cell is unchanged, and a +// cell whose background is the see-through ground (config.gui_transparent, a +// theme with no bg of its own) contributes only its ink, at the ink's own +// coverage, so the compositor keeps the rest. layout(set = 2, binding = 0) uniform sampler2D u_atlas; @@ -26,13 +29,20 @@ float cellNoise(uint serial, uint col, uint row) { void main() { float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; - uint effect = v_effect.x & 0x7fffffffu; + // 0 where the cell's background IS the ground and the ground is nothing. + // src/gui/gui.zig sets that bit; the two flags above it are why the effect + // id is masked with 0x3fffffff here and in ui.vert.glsl. + float bgA = (v_effect.x & 0x40000000u) != 0u ? 0.0 : 1.0; + uint effect = v_effect.x & 0x3fffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); uint cellCol = v_effect.w & 0xffffu; uint cellRow = v_effect.w >> 16; float noise = cellNoise(v_effect.z, cellCol, cellRow); - vec3 cell = mix(v_bg, v_fg, a); + // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is + // mix() with the ground already premultiplied. At bgA = 1 that is exactly + // the mix(v_bg, v_fg, a) at alpha 1 this shader always wrote. + vec4 cell = vec4(mix(v_bg * bgA, v_fg, a), mix(bgA, 1.0, a)); // 3 = data dissolve. Changed cells retain the frozen old cell until their // stable threshold, then atomically become the new cell. Exact endpoints @@ -40,11 +50,11 @@ void main() { if (effect == 3u) { bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress); if (oldLayer ? reveal : !reveal) discard; - o_col = vec4(cell, 1.0); + o_col = cell; return; } // FreeType's hinted grayscale bitmap is already the intended coverage. // Preserve it exactly; weight-changing gamma hacks blur small stems. - o_col = vec4(cell, 1.0); + o_col = cell; } diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index 6b8a88e0..4eb9993e 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl index 08627dc0..5ec596fe 100644 --- a/shaders/prebuilt/ui.vert.glsl +++ b/shaders/prebuilt/ui.vert.glsl @@ -14,8 +14,9 @@ layout(location = 3) in vec3 a_bg; layout(location = 4) in vec4 a_panel_rect; layout(location = 5) in vec4 a_present_rect; // effect id, eased-progress float bits, pane serial, local row/column packed -// into one uint. The high effect bit marks the frozen-old cell layer; integer -// transport preserves the exact cross-backend hash. +// into one uint. The TWO high effect bits are flags — the frozen-old cell +// layer and a see-through cell background — so the id is what is left below +// them; integer transport preserves the exact cross-backend hash. layout(location = 6) in uvec4 a_effect; layout(location = 0) out vec2 v_uv; @@ -54,7 +55,7 @@ void main() { v_cell_uv = vec2(0.0, 1.0); } - uint effect = a_effect.x & 0x7fffffffu; + uint effect = a_effect.x & 0x3fffffffu; if (effect == 1u || effect == 2u || effect == 5u) { // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y // delta. Preserve that sign or every local y is mirrored/outside. diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv index f0d5f653..08e81e42 100644 Binary files a/shaders/prebuilt/ui.vert.spv and b/shaders/prebuilt/ui.vert.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index ddc1cde0..69cb63b9 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -1,7 +1,10 @@ #version 450 // Sample the glyph atlas (R = coverage 0..1) and mix the cell's bg up to its fg -// by that coverage. Fully opaque: the swapchain is SDR, no blending needed. +// by that coverage. Output is PREMULTIPLIED: an opaque cell is unchanged, and a +// cell whose background is the see-through ground (config.gui_transparent, a +// theme with no bg of its own) contributes only its ink, at the ink's own +// coverage, so the compositor keeps the rest. layout(set = 2, binding = 0) uniform sampler2D u_atlas; @@ -26,13 +29,20 @@ float cellNoise(uint serial, uint col, uint row) { void main() { float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; - uint effect = v_effect.x & 0x7fffffffu; + // 0 where the cell's background IS the ground and the ground is nothing. + // src/gui/gui.zig sets that bit; the two flags above it are why the effect + // id is masked with 0x3fffffff here and in ui.vert.glsl. + float bgA = (v_effect.x & 0x40000000u) != 0u ? 0.0 : 1.0; + uint effect = v_effect.x & 0x3fffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); uint cellCol = v_effect.w & 0xffffu; uint cellRow = v_effect.w >> 16; float noise = cellNoise(v_effect.z, cellCol, cellRow); - vec3 cell = mix(v_bg, v_fg, a); + // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is + // mix() with the ground already premultiplied. At bgA = 1 that is exactly + // the mix(v_bg, v_fg, a) at alpha 1 this shader always wrote. + vec4 cell = vec4(mix(v_bg * bgA, v_fg, a), mix(bgA, 1.0, a)); // 3 = data dissolve. Changed cells retain the frozen old cell until their // stable threshold, then atomically become the new cell. Exact endpoints @@ -40,11 +50,11 @@ void main() { if (effect == 3u) { bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress); if (oldLayer ? reveal : !reveal) discard; - o_col = vec4(cell, 1.0); + o_col = cell; return; } // FreeType's hinted grayscale bitmap is already the intended coverage. // Preserve it exactly; weight-changing gamma hacks blur small stems. - o_col = vec4(cell, 1.0); + o_col = cell; } diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index 08627dc0..5ec596fe 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -14,8 +14,9 @@ layout(location = 3) in vec3 a_bg; layout(location = 4) in vec4 a_panel_rect; layout(location = 5) in vec4 a_present_rect; // effect id, eased-progress float bits, pane serial, local row/column packed -// into one uint. The high effect bit marks the frozen-old cell layer; integer -// transport preserves the exact cross-backend hash. +// into one uint. The TWO high effect bits are flags — the frozen-old cell +// layer and a see-through cell background — so the id is what is left below +// them; integer transport preserves the exact cross-backend hash. layout(location = 6) in uvec4 a_effect; layout(location = 0) out vec2 v_uv; @@ -54,7 +55,7 @@ void main() { v_cell_uv = vec2(0.0, 1.0); } - uint effect = a_effect.x & 0x7fffffffu; + uint effect = a_effect.x & 0x3fffffffu; if (effect == 1u || effect == 2u || effect == 5u) { // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y // delta. Preserve that sign or every local y is mirrored/outside. diff --git a/src/config.zig b/src/config.zig index cbe75dea..1a3de86c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -566,6 +566,32 @@ pub const gui_topbar_pane_border_px: u8 = 1; /// build that wants a deliberate accent can pin one here. pub const gui_topbar_pane_border_rgb: ?[3]u8 = null; +/// Open the SDL window with a transparent buffer, so that a theme declaring NO +/// background of its own (the curated `dark`, every vendored `*_transparent`) +/// shows the desktop through the grid instead of a colour this shell had to +/// invent. That is what the AppKit shell does over its NSVisualEffectView, and +/// on linux the compositor supplies the backdrop — a niri `background-effect +/// { blur true }` window rule, picom, whatever is running. +/// +/// OFF by default because it is not free, and the cost is structural rather +/// than ours: `SDL_ClaimWindowForGPUDevice` refuses a transparent window +/// outright ("The GPU API doesn't support transparent windows", SDL_gpu.c, +/// still upstream), because D3D12 has no transparent swapchain and the API +/// says no everywhere rather than only where it must. A transparent window +/// therefore has no swapchain to render into, and the frame reaches the screen +/// down the same readback-and-blit path a compositor that cannot back a Vulkan +/// swapchain already uses (`soft_present` in `src/gui/gui.zig`): one +/// GPU->CPU download plus one upload per PAINTED frame, measured at 1.2 ms for +/// 2240x1440 and 3.0 ms for 3840x2160 on an RTX 3050. Idle frames cost +/// nothing — this shell only paints when something changed — but an animation +/// at 60 Hz spends that every frame. +/// +/// With a theme that DOES bring a background this changes nothing visible: the +/// ground is painted at full alpha, exactly as an opaque window would. It +/// still pays the readback, because window transparency is fixed at creation +/// and a `Theme` command may reach a transparent theme later. +pub const gui_transparent: bool = false; + /// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be /// vertical carries a little sideways drift, and the pad faithfully turns that /// drift into wheel_left/wheel_right — so a plain scroll slides the view 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); diff --git a/src/main.zig b/src/main.zig index 004d59cb..1fcc2770 100644 --- a/src/main.zig +++ b/src/main.zig @@ -160,6 +160,26 @@ fn webMain() !void { } fn nativeMain(init: std.process.Init) !void { + // FIRST, before anything can log or panic. std's start code hands the + // debug/log stderr writer the process environ exactly as the kernel laid + // it out beside argv, and `std.debug.lockStderr` scans that block once, + // lazily, for NO_COLOR and CLICOLOR_FORCE. Lazily is the problem: libc + // may have rewritten the block by then. SDL_CreateWindow does, every + // time — `Wayland_ShowWindow` unsets XDG_ACTIVATION_TOKEN, and glibc's + // unsetenv compacts `environ` in place and leaves a null in the slot the + // captured length still counts. `Environ.scan` unwraps that null, so the + // FIRST log line or panic in the gui shell panicked inside the scan, and + // then the panic handler took the same path and deadlocked on the stderr + // lock it was already holding: "attempt to use null value" and a hang, + // with no trace and no message of its own. Every SDL_GetError report in + // gui.zig was that, which is to say unreachable. + // + // Locking and immediately unlocking here does the scan while the block is + // still the one std was given, and memoizes it. Nothing is written. + var stderr_probe: [64]u8 = undefined; + _ = std.debug.lockStderr(&stderr_probe); + std.debug.unlockStderr(); + var opts: pardes.Options = .{}; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); -- cgit v1.3