diff options
| -rw-r--r-- | src/builtins.zig | 2 | ||||
| -rw-r--r-- | src/macos.zig | 19 | ||||
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 18 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 82 | ||||
| -rw-r--r-- | src/macos/pardes.h | 9 | ||||
| -rw-r--r-- | test/macos-snapshots/opacity.golden | 29 | ||||
| -rw-r--r-- | test/macos-snapshots/opacity.snap | 34 | ||||
| -rw-r--r-- | test/macos_e2e.swift | 62 |
8 files changed, 231 insertions, 24 deletions
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..<values[0] { var cell = cells[col] let isCursor = !hideCursor && col == values[8] - let style = resolve(cell, block: isCursor && values[9] == 0, ground: UInt32(values[10]), clearGround: false) + let block = isCursor && values[9] == 0 + let style = resolve(cell, block: block, ground: UInt32(values[10]), clearGround: false) let x = left + CGFloat(col) * taglines.width if pass == 0 { - fill(ctx, CGRect(x: x, y: y, width: taglines.width, height: taglines.height), style.bg, 1) + fill(ctx, CGRect(x: x, y: y, width: taglines.width, height: taglines.height), + style.bg, block ? 1 : backgroundAlpha) continue } let text = withUnsafeBytes(of: &cell.text) { String(decoding: $0.prefix(Int(cell.len)), as: UTF8.self) } @@ -1320,7 +1362,7 @@ final class PardesView: NSView { height: CGFloat(value(6))*cellHeight) ctx.saveGState() ctx.clip(to: viewport) - fill(ctx, viewport, themeBG ?? pardesDefaultBG, 1) + fill(ctx, viewport, themeBG ?? pardesDefaultBG, backgroundAlpha) for row in 0..<rows { let compact = row < contexts let advance = compact ? taglines.width : cellWidth @@ -1328,16 +1370,18 @@ final class PardesView: NSView { let y = top + CGFloat(min(row, contexts))*taglines.height + CGFloat(max(0, row-contexts))*cellHeight if y >= 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..<cols { var cell = cells[row*cols+col] let isCursor = !hideCursor && col == value(8) && row == value(9) - let style = resolve(cell, block: isCursor && value(10) == 0, + let block = isCursor && value(10) == 0 + let style = resolve(cell, block: block, ground: themeBG ?? pardesDefaultBG, clearGround: false) let x = left + CGFloat(col)*advance if pass == 0 { - fill(ctx, CGRect(x: x, y: y, width: advance, height: height), style.bg, 1) + fill(ctx, CGRect(x: x, y: y, width: advance, height: height), + style.bg, block ? 1 : backgroundAlpha) continue } let text = withUnsafeBytes(of: &cell.text) { bytes in @@ -1372,7 +1416,7 @@ final class PardesView: NSView { // the next row's first one; keep the two shells on the same pixel. let thickness = taglines.contextBorderThickness fill(ctx, CGRect(x: left, y: top + CGFloat(row + 1)*taglines.height - thickness, - width: viewport.width, height: thickness), border, 1) + width: viewport.width, height: thickness), border, backgroundAlpha) } } ctx.restoreGState() diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 48f38e58..5f1ccf77 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -254,6 +254,15 @@ bool pardes_animating(void); // wear, so the host should go transparent and show its own backdrop. uint32_t pardes_theme_bg(void); +// The percentage the host must paint window BACKGROUNDS at: the ground behind +// the grid, cell and band backgrounds, and the chrome rules over them. Glyph +// ink and the block cursor are never scaled by it — see shaders/ui.frag.glsl, +// which states the same rule for the SDL shell. +// +// Read it once per pump beside pardes_theme_bg(): reading also acknowledges +// whatever `WindowOpacity` asked for. 100 is opaque and is the default. +uint8_t pardes_window_opacity(void); + // Pane taglines use this percentage of the current body font size. It is the // embedded default before init and the live core setting afterwards; the host // polls it and rebuilds only tagline glyph/metric state. diff --git a/test/macos-snapshots/opacity.golden b/test/macos-snapshots/opacity.golden new file mode 100644 index 00000000..b28c6e0c --- /dev/null +++ b/test/macos-snapshots/opacity.golden @@ -0,0 +1,29 @@ +== draw-opacity solid 680x396 alpha 255..255 +== snap translucent grid=80x24 cursor=4,3 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| New Tty Find Grep Joincol +|$ /private/tmp/pardes-macos-e2e/opacity/cwd Tty Save Del Mode Filter Collapse +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== draw-opacity translucent 680x396 alpha 153..255 +== draw-opacity clear 680x396 alpha 0..255 +== draw-opacity restored 680x396 alpha 255..255 diff --git a/test/macos-snapshots/opacity.snap b/test/macos-snapshots/opacity.snap new file mode 100644 index 00000000..0f73cb17 --- /dev/null +++ b/test/macos-snapshots/opacity.snap @@ -0,0 +1,34 @@ +# WindowOpacity, end to end: the builtin the core only offers to a shell that +# declared the capability (src/builtins.zig), through pardes_window_opacity, +# to the alpha CoreGraphics actually left in the framebuffer. +# +# The contract is two-sided and shaders/ui.frag.glsl states it for the SDL +# shell: BACKGROUNDS take the percentage, glyph ink never does. `draw-opacity` +# records both ends of the range so a regression that made the whole window +# translucent — text included — reads as a changed maximum, not as a pass. +start 24 80 +wait 8000 Newcol Joincol +wait 8000 $ +stable 700 20000 + +# Opaque is the default, and an opaque view caches without an alpha channel +# at all. +draw-opacity solid + +# Backgrounds drop to 60%; the prompt and the chrome text stay at 255. +command WindowOpacity 60 +stable 700 20000 +snap translucent +draw-opacity translucent + +# 0 is the extreme the block cursor exists to survive: every background is +# gone and the ink is still there. +command WindowOpacity 0 +stable 700 20000 +draw-opacity clear + +# ...and back, which must restore an opaque view rather than leave a window +# that is merely painting 100% alpha into a transparent surface. +command WindowOpacity 100 +stable 700 20000 +draw-opacity restored diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift index 58ad5206..fbfa0e3e 100644 --- a/test/macos_e2e.swift +++ b/test/macos_e2e.swift @@ -270,6 +270,15 @@ private final class Driver: PardesViewDelegate { if let view, let wanted = pardes_font_take(&fontSize) { view.adoptFont(path: String(cString: wanted), sizeHundredths: fontSize) } + // The app reads both of these every pump too (AppDelegate.applyTheme). + // There is no window here to dress, but the VIEW half is the same and + // the drawing depends on it: without this a `Theme` or a + // `WindowOpacity` in a script would move the core's settings and never + // reach a pixel, and the capture would record the old ground forever. + if let view { + view.adoptThemeBG(pardes_theme_bg()) + view.adoptWindowOpacity(pardes_window_opacity()) + } } /// Hand the run loop a slice. Main-queue work (anything the view defers) @@ -489,6 +498,51 @@ private final class Driver: PardesViewDelegate { output += "== draw \(label) \(w)x\(h) nonblank\n" } + /// The alpha range of a drawn frame, recorded into the golden. + /// + /// The contract WindowOpacity states (shaders/ui.frag.glsl, and + /// PardesView.backgroundAlpha for this shell) is two-sided, so one number + /// could not express it: BACKGROUNDS take the setting, and glyph ink stays + /// opaque. A minimum below 255 is the ground letting the desktop through; + /// a maximum of 255 is the ink still sitting on top of it. At + /// `WindowOpacity 100` both ends are 255 and the view is opaque again. + private func drawOpacity(_ label: String) throws { + let view = try live() + let bounds = view.bounds + guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { + throw ScriptError(message: "draw-opacity \(label): the view would not make a bitmap for \(bounds.size)") + } + view.cacheDisplay(in: bounds, to: rep) + guard let data = rep.bitmapData, rep.pixelsWide > 0, rep.pixelsHigh > 0 else { + throw ScriptError(message: "draw-opacity \(label): cacheDisplay produced no pixels") + } + guard rep.hasAlpha, rep.bitsPerPixel == 32 else { + // An opaque view caches into a rep with no alpha at all, which is + // itself the answer at WindowOpacity 100 — say so rather than + // reporting a range read out of the wrong byte. + output += "== draw-opacity \(label) \(Int(bounds.width.rounded()))x\(Int(bounds.height.rounded())) opaque\n" + return + } + // Where alpha sits depends on the rep's format, and only the two + // AppKit actually hands back here are worth decoding. + let alphaFirst = rep.bitmapFormat.contains(.alphaFirst) + let offset = alphaFirst ? 0 : 3 + let rowBytes = rep.bytesPerRow + var lowest: UInt8 = 255 + var highest: UInt8 = 0 + for y in 0..<rep.pixelsHigh { + let row = data + y * rowBytes + for x in 0..<rep.pixelsWide { + let alpha = row[x * 4 + offset] + if alpha < lowest { lowest = alpha } + if alpha > highest { highest = alpha } + } + } + let w = Int(bounds.width.rounded()) + let h = Int(bounds.height.rounded()) + output += "== draw-opacity \(label) \(w)x\(h) alpha \(lowest)..\(highest)\n" + } + // MARK: - the script func run(source: String) throws -> String { @@ -799,6 +853,14 @@ private final class Driver: PardesViewDelegate { guard !rest.isEmpty else { throw ScriptError(message: "draw needs a label") } try draw(rest) + // What WindowOpacity actually did to the pixels, which is the only + // place it can be checked: the core's cell buffer says nothing about + // alpha, and `draw` deliberately compares channels without knowing + // which one is which. + case "draw-opacity": + guard !rest.isEmpty else { throw ScriptError(message: "draw-opacity needs a label") } + try drawOpacity(rest) + case "draw-effect": let view = try live() guard !rest.isEmpty else { throw ScriptError(message: "draw-effect needs a label") } |
