summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/builtins.zig2
-rw-r--r--src/macos.zig19
-rw-r--r--src/macos/Sources/AppDelegate.swift18
-rw-r--r--src/macos/Sources/PardesView.swift82
-rw-r--r--src/macos/pardes.h9
-rw-r--r--test/macos-snapshots/opacity.golden29
-rw-r--r--test/macos-snapshots/opacity.snap34
-rw-r--r--test/macos_e2e.swift62
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") }