From 556c5b50bbfd4a6aaae192377d7810f610fc7d9e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 16 Sep 2026 12:00:52 -0300 Subject: Let WindowOpacity through on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builtin was gated on the SDL platform, so the macOS shell never registered it and the setting had nowhere to land. macOS already had transparency, but only the binary kind a theme decides — a theme with no background of its own drops the ground and the blur shows through. WindowOpacity is the graded, theme-independent version, and the window had no way to hear about it. pardes_window_opacity reports the percentage and acknowledges the request in one read, beside pardes_theme_bg: an SDL surface can arrive without an alpha channel and has to be able to refuse, while an AppKit window always composites per pixel, so there is nothing here to refuse and no rollback to perform. The view then paints the rule shaders/ui.frag.glsl states for the SDL shell: backgrounds — ground, cell and band fills, and the chrome rules over them — take the alpha, glyph ink never does, and the block cursor is exempt because it is foreground chrome that happens to be carried in a cell background. That exemption is why the cursor joins the colour in the background run key; it can no longer share a fill with the cells beside it. Two things the harness was missing fall out of testing it: it never adopted the theme background or the opacity, so a Theme or WindowOpacity in a script moved the core and never reached a pixel. draw-opacity records both ends of the alpha range, which is what makes the two-sided contract assertable — 60% reads 153..255, and 0% reads 0..255 with the ink still standing. Co-Authored-By: Claude Opus 5 (1M context) --- src/builtins.zig | 2 +- src/macos.zig | 19 +++++++++ src/macos/Sources/AppDelegate.swift | 18 ++++++-- src/macos/Sources/PardesView.swift | 82 ++++++++++++++++++++++++++++--------- src/macos/pardes.h | 9 ++++ 5 files changed, 106 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/builtins.zig b/src/builtins.zig index a21a20c4..1cd10a99 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -17,7 +17,7 @@ pub const capabilities: config.Runtime.Capabilities = .{ // The tty's font belongs to its emulator, and the P4 firmware's belongs to // whatever terminal is on the other end of the serial line. .tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4, - .window_opacity = pardes.platform == .gui, + .window_opacity = pardes.platform == .gui or pardes.platform == .macos, }; pub const Ctx = struct { diff --git a/src/macos.zig b/src/macos.zig index 3003fd46..67a5ec5b 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -875,6 +875,24 @@ export fn pardes_theme_bg() u32 { return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]; } +/// The percentage the shell must paint window backgrounds at, read once per +/// pump beside pardes_theme_bg(). +/// +/// Reading it also ACKNOWLEDGES whatever `WindowOpacity` asked for. The SDL +/// shell has to answer that request because an SDL surface can arrive without +/// an alpha channel and the core must not claim a value the compositor +/// refused; an AppKit window always composites per pixel, so there is nothing +/// here to refuse and no rollback for the core to perform. +/// +/// The theme's own decision stays separate: a theme with no background of its +/// own (pardes_theme_bg == PARDES_COLOR_DEFAULT) drops the ground entirely, +/// whatever percentage is set here. +export fn pardes_window_opacity() u8 { + const st = &(state orelse return 100); + st.core.settings.window_opacity_pending = false; + return st.core.settings.window_opacity; +} + fn taglineFontPercent(core: ?*const pardes.Pardes) u8 { return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent; } @@ -2437,6 +2455,7 @@ test "pardes.h declares every export the way it is defined" { try expectSameAbi(@TypeOf(c.pardes_active_path), @TypeOf(pardes_active_path)); try expectSameAbi(@TypeOf(c.pardes_active_dirty), @TypeOf(pardes_active_dirty)); try expectSameAbi(@TypeOf(c.pardes_theme_bg), @TypeOf(pardes_theme_bg)); + try expectSameAbi(@TypeOf(c.pardes_window_opacity), @TypeOf(pardes_window_opacity)); } test "pardes.h matches the Zig boundary" { diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 96f4a184..107c40fd 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -462,17 +462,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { private var dressed = false private func applyTheme() { - let changed = view.adoptThemeBG(pardes_theme_bg()) - guard changed || !dressed else { return } + // Both reads happen every pump, and neither may be short-circuited by + // the other: `changed` has to be the OR of two adoptions, not the first + // one that answered true, or a Theme and a WindowOpacity in the same + // tick would leave one of them unworn. + let themeChanged = view.adoptThemeBG(pardes_theme_bg()) + let opacityChanged = view.adoptWindowOpacity(pardes_window_opacity()) + guard themeChanged || opacityChanged || !dressed else { return } dressed = true + // A ground short of solid needs the same non-opaque window a + // background-less theme does — but not the blur behind it. The backdrop + // answers "this theme brings no background"; WindowOpacity says how + // much of the one it brings to paint, and what shows through the rest + // is the desktop, which is what asking for a translucent window means. if let rgb = view.themeBG { backdrop.isHidden = true - window.isOpaque = true + window.isOpaque = view.backgroundAlpha >= 1 window.backgroundColor = NSColor( srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, - alpha: 1) + alpha: view.backgroundAlpha) // ...and follow the theme into light mode, so the titlebar, the // traffic lights and the resize cursor stop belonging to a // different application than the grid under them. Luminance off diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index cd3ec980..5df1fd73 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -626,6 +626,32 @@ final class PardesView: NSView { return true } + /// What `WindowOpacity` asked for, as the alpha every BACKGROUND is painted + /// at: the ground, cell and band backgrounds, and the chrome rules over + /// them. Glyph ink is never scaled by it, and neither is the block cursor — + /// it is foreground chrome that merely happens to be carried in a cell's + /// background, and it has to stay visible at `WindowOpacity 0`. + /// + /// The rule is stated once for the SDL shell in shaders/ui.frag.glsl; this + /// is the same rule in CoreGraphics, where painting the ground at this + /// alpha and the glyph over it at its own coverage composites to what that + /// shader writes premultiplied in one pass. + /// + /// Orthogonal to `themeBG`: a theme with no background of its own drops the + /// ground whatever this says, and an opaque theme still honours it. + private(set) var backgroundAlpha: CGFloat = 1 + + /// Adopt the core's percentage. Returns whether anything moved, on the same + /// contract as adoptThemeBG: the host redresses the window only when it has. + @discardableResult + func adoptWindowOpacity(_ percent: UInt8) -> Bool { + let wanted = CGFloat(min(percent, 100)) / 100 + guard wanted != backgroundAlpha else { return false } + backgroundAlpha = wanted + needsDisplay = true + return true + } + // The button a left-stream click actually started with. mouseDown decides // it from the fingers on the trackpad, and mouseDragged/mouseUp must use // the same one: a press of right followed by a release of left leaves the @@ -792,10 +818,11 @@ final class PardesView: NSView { // Row 0 at the top, so the drawing arithmetic reads like the grid it is. override var isFlipped: Bool { true } - // Opaque only while the theme brings its own background. A transparent - // theme has none, and an opaque view over a visual-effect backdrop is a - // grey rectangle where the blur should be. - override var isOpaque: Bool { themeBG != nil } + // Opaque only while the theme brings its own background AND that background + // is painted solid. A transparent theme has none, and an opaque view over a + // visual-effect backdrop is a grey rectangle where the blur should be; + // a WindowOpacity below 100 has the same problem against the desktop. + override var isOpaque: Bool { themeBG != nil && backgroundAlpha >= 1 } override var acceptsFirstResponder: Bool { true } // A click that focuses the window should also land in the grid: this is a // text surface, and having to click twice after switching apps is the kind @@ -974,7 +1001,12 @@ final class PardesView: NSView { // otherwise pile up on themselves. let ground = themeBG ?? pardesDefaultBG let clearGround = themeBG == nil - if clearGround { ctx.clear(bounds) } else { fill(ctx, bounds, ground, 1) } + // AppKit does not blank a non-opaque view, so anything that leaves the + // ground short of solid has to clear first or last frame's pixels pile + // up on themselves — a translucent ground would darken toward opaque + // over a few frames otherwise. + if clearGround || backgroundAlpha < 1 { ctx.clear(bounds) } + if !clearGround { fill(ctx, bounds, ground, backgroundAlpha) } if frozenImages == nil { frameImages.removeAll(keepingCapacity: true) } guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = suppliedCells ?? pardes_frame_cells() @@ -1004,30 +1036,38 @@ final class PardesView: NSView { // cell in the row, and it is a call across the ABI. let bandTop = taglines.top(row: row, canvasHeight: canvasHeight) var start = 0 - var color = resolve(cells[base], block: blockY == row && blockX == 0, + var block = blockY == row && blockX == 0 + var color = resolve(cells[base], block: block, ground: ground, clearGround: clearGround).bg var tagline = cells[base].flags & UInt8(PARDES_CELL_TAGLINE) != 0 for col in 1...cols { // A real color is 24 bits, so .max is a sentinel that cannot // compare equal and therefore always flushes the last run. + let nextBlock = col < cols && blockY == row && blockX == col let next: UInt32 = col == cols ? .max - : resolve(cells[base + col], block: blockY == row && blockX == col, + : resolve(cells[base + col], block: nextBlock, ground: ground, clearGround: clearGround).bg let nextTagline = col < cols && cells[base + col].flags & UInt8(PARDES_CELL_TAGLINE) != 0 - if next == color && nextTagline == tagline { continue } + // The cursor is in the run key as well as the colour: it is the + // one background that stays solid under WindowOpacity, so it + // cannot share a fill with the cells beside it even when the + // reverse happened to land on their colour. + if next == color && nextTagline == tagline && nextBlock == block { continue } // bgClear runs are the ground showing through, and the ground is // already clear — painting them would be painting the hole shut. if color != bgClear { let bandY = tagline ? y + bandTop : y let bandHeight = tagline ? taglines.height : cellHeight fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY, - width: CGFloat(col - start) * cellWidth, height: bandHeight), color, 1) + width: CGFloat(col - start) * cellWidth, height: bandHeight), + color, block ? 1 : backgroundAlpha) } start = col color = next tagline = nextTagline + block = nextBlock } } @@ -1050,7 +1090,7 @@ final class PardesView: NSView { } if joins { fill(ctx, CGRect(x: 0, y: taglines.borderTop, width: bounds.width, - height: ruleThickness), ruleColor, 1) + height: ruleThickness), ruleColor, backgroundAlpha) } } @@ -1069,7 +1109,7 @@ final class PardesView: NSView { clearGround: clearGround).bg if bg != bgClear { fill(ctx, CGRect(x: 0, y: gridBottom, width: bounds.width, - height: canvasHeight - gridBottom), bg, 1) + height: canvasHeight - gridBottom), bg, backgroundAlpha) } break } @@ -1251,15 +1291,17 @@ final class PardesView: NSView { let viewport = CGRect(x: left, y: y, width: CGFloat(values[6]) * cellWidth, height: taglines.height) ctx.saveGState() ctx.clip(to: viewport) - fill(ctx, viewport, UInt32(values[10]), 1) + fill(ctx, viewport, UInt32(values[10]), backgroundAlpha) for pass in 0...1 { for col in 0..= viewport.maxY { break } let rowStyle = resolve(cells[row*cols], block: false, ground: themeBG ?? pardesDefaultBG, clearGround: false) - fill(ctx, CGRect(x: left, y: y, width: viewport.width, height: height), rowStyle.bg, 1) + fill(ctx, CGRect(x: left, y: y, width: viewport.width, height: height), rowStyle.bg, backgroundAlpha) for pass in 0...1 { for col in 0..