summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/macos.md58
-rw-r--r--docs/rendering-parity-design.md132
-rw-r--r--src/macos.zig21
-rw-r--r--src/macos/Sources/AppDelegate.swift2
-rw-r--r--src/macos/Sources/PardesView.swift128
-rw-r--r--src/macos/pardes.h1
-rw-r--r--test/macos-snapshots/context.zig109
-rw-r--r--test/macos-snapshots/rendering-parity.golden14
-rw-r--r--test/macos-snapshots/rendering-parity.snap54
-rw-r--r--test/macos-snapshots/tag-ink.golden1
-rw-r--r--test/macos-snapshots/tag-ink.snap8
-rw-r--r--test/macos_e2e.swift185
12 files changed, 631 insertions, 82 deletions
diff --git a/docs/macos.md b/docs/macos.md
index 3c02de8a..3fdf399f 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -14,6 +14,10 @@ plumbing actually requires, and which parts of it are distribution machinery
rather than integration. Read that for the alternatives; this file is what was
built and why.
+Future layout/compositing ownership and the regression baseline are discussed in
+[Shared rendering contract](rendering-parity-design.md). That proposal is design
+groundwork; the native drawing implementation remains in place.
+
## Why not ghostty's split
Ghostty was read carefully before this was written, and this backend
@@ -496,33 +500,22 @@ different kinds of assertion because a snapshot is the core's cell buffer and
the core has no font: `font Menlo-Regular` asks the view what it is actually
wearing, and the snapshots catch the grid moving when the cell changes size.
-### The tagline band follows the core's rule, not this shell's
+### Compact tags and context rows
-A pane tag is drawn at `gui_tagline_font_percent` of the body face
-(`TaglineSize <percent>` changes it live) and the band it sits on shrinks with
-it, while the grid row stays body-sized. Where that shorter band sits inside its
-row is no longer this shell's arithmetic: `pardes_tagline_band_offset` and
-`pardes_topbar_pane_border_px` answer out of `src/pardes.zig`, and that is the
-same rule `src/gui/gui.zig` draws with.
+`TaglineSize <percent>` scales the tag face, pitch and glyph band. The tag's
+background still fills a whole body-grid row, as in the SDL renderer. Painting
+only the compact band leaves dark strips between tags. Glyph offsets come from
+`pardes_tagline_band_offset`; all measurements cross the ABI in physical pixels.
-It is shared because it drifted. This shell centred every band in its own row,
-and centring two reduced-height bands is precisely the case
-`config.gui_topbar_pane_border_px` exists to prevent: the topbar's unused
-half-band meets the first pane tag's unused half-band and the window background
-shows through the seam. The strip is as wide as the bands are short — on a
-20-pixel cell, 4 physical pixels at the default 82%, 10 at 50%, 14 at 30% — so it
-read as "the tagline is wrong on the mac" rather than as one missing rule. Row
-zero is bottom-aligned now, the first pane-tag row top-aligned, the two joined by
-`gui_topbar_pane_border_px` in the theme's scrollbar-track colour
-(`pardes_topbar_pane_border_rgb`, or a compiled override), every row between
-centred, and a `Tagbottom` band on the final row bottom-aligned against the
-window edge — with the sub-cell strip below it painted in that band's own colour,
-because the core grid holds only whole cells and a window is any height it likes.
+The chrome overlay runs after the compact layers. It draws the topbar rule,
+the column rule and the pane's tag/body boundary using the theme's border
+colour. The pane rule moves above a bottom-positioned tag. Tag-layer fields 11
+and 12 preserve that placement and colour in frozen transition frames.
-The offsets cross as PHYSICAL PIXELS. The host multiplies its points by the
-backing scale going in and divides coming out, which is the snapping `Metrics`
-already does for the cell, and is what keeps a one-pixel rule one pixel instead
-of a two-pixel smear.
+Tree-sitter context rows use the compact height and pitch, with full-width row
+backgrounds. Their one-physical-pixel separators use the same border colour as
+SDL, after discontinuous declarations and after the final context row. The
+remaining body starts immediately after the compact rows.
### The cell is snapped to device pixels, not to points
@@ -713,6 +706,20 @@ The blur is a **sibling** of the grid inside a plain container, never its
parent. Hiding a superview hides its subviews, so a nested backdrop drew a
blank window for every opaque theme the moment it was hidden.
+`WindowOpacity <0..100>` sets one background coverage throughout the frame.
+Background fills replace existing coverage, so overlapping ground, cell, tag
+and context layers cannot increase the requested opacity. The window itself
+stays clear below 100%; a second tinted window backdrop would compound it.
+Text and cursor ink stay opaque. PDF and image pixels follow the SDL image
+pipeline: remove the destination by source coverage, then add the image at the
+requested opacity. This also preserves the background under transparent image
+pixels. Theme colours and attachment rasters are interpreted as sRGB.
+
+`test/macos-snapshots/rendering-parity.snap` checks background colour and alpha,
+regular and bottom tags, compact context separators, and PDF fit, tint and
+scrolling. Its native-metrics mode uses backing pixels like the shipping app;
+the older grid-only tests keep their display-independent point metrics.
+
## Threading
One core, touched only from the main thread, plus one pty reader task per pane
@@ -955,8 +962,7 @@ select scripts or directories instead. The executable stays in the build cache.
`test/macos_e2e.swift` links the same Swift sources the app does, minus
`main.swift`, into a second binary — test scaffolding does not ship inside the
-product. Scripts are `test/macos-snapshots/*.snap` (seven of them: boot, cwd,
-drop, font, keys, rotate, trackpad) and speak the tty suite's
+product. Scripts are `test/macos-snapshots/*.snap` and speak the tty suite's
vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`, `command`,
`mouse`, `click`, `wheel`, `resize`, `draw`) plus what only
exists here: `fingers <n> <col> <row>`, `force <col> <row>`,
diff --git a/docs/rendering-parity-design.md b/docs/rendering-parity-design.md
new file mode 100644
index 00000000..b6036543
--- /dev/null
+++ b/docs/rendering-parity-design.md
@@ -0,0 +1,132 @@
+# Shared rendering contract: design groundwork
+
+Status: proposed direction, not an implemented renderer abstraction. The current
+change fixes the native macOS backend and adds regression coverage. It does not
+move layout or drawing into a new shared module.
+
+## Why the backends diverged
+
+The core shares editor state, cells, tag layers, body layers, and PDF placements.
+It does not yet specify every visual operation needed to present them. SDL and
+AppKit therefore independently reconstruct tag backgrounds, compact text bands,
+context separators, overlay order, and opacity. A correct cell snapshot can
+coexist with an incorrect rendered frame.
+
+The macOS/Linux comparison exposed concrete gaps in this contract:
+
+- Reduced tag glyph bands were also used as background bounds on macOS; SDL
+ fills the entire row. Column and pane rules were missing or painted before
+ an overlapping layer.
+- Tree-sitter context rows had matching compact geometry, but the separator
+ used the scroll-track color instead of the border color.
+- Repeated source-over background fills compounded window opacity. At 71%,
+ two overlapping fills yield about 92%, and three about 98%. The window's own
+ colored background added another layer behind the view.
+- CoreText fallback runs did not inherit the selected foreground color.
+- PDF/image composition ignored window opacity on macOS, while SDL applies it
+ to raster content. Theme colors also need an explicit sRGB interpretation.
+
+These are duplicated policy decisions, rather than a reason to replace CoreText
+or require identical fonts on every host.
+
+## Proposed ownership boundary
+
+A shared scene builder should translate the existing surface and its layers
+into an ordered list of positioned drawing operations. Platform code should
+consume that list without deciding which rows are compact, which edge has a
+separator, or which objects participate in window opacity.
+
+The builder would own:
+
+- Tag and context geometry, background extents, separator positions and colors,
+ clipping, and the order of backgrounds, text, images, cursors, and overlays.
+- Coordinate conversion rules: logical layout units, supplied font metrics,
+ device scale, and the rounding policy for one-device-pixel rules.
+- Color and compositing semantics, including clear backgrounds, replacement
+ background fills, image coverage, selection, and opaque native text/cursors.
+- Stable image references and a defined frame lifetime, including snapshots
+ retained for transitions and postprocessing.
+
+Backends would continue to own native font selection, shaping/rasterization,
+image upload/cache management, and presentation. Native event handling, window
+management, and the text-only terminal frontend are outside this proposal.
+
+A small 2D operation vocabulary could include solid rectangles, positioned text
+runs, images with source/destination rectangles, and explicit clips. Operations
+must carry semantic blend behavior and a documented color space. Wrapping
+`fillRect` and `drawText` while leaving each backend to build the scene would
+preserve most of the duplication that caused these bugs.
+
+Do not freeze a new ABI before extracting one small path and checking its needs.
+Font metrics must enter the shared layout explicitly; changing fonts can still
+change line capacity and glyph appearance. Pixel equality across different
+fonts, rasterizers, and display scales is not the acceptance criterion.
+
+## Compositing contract to preserve
+
+Let p be WindowOpacity in [0, 1]. An ordinary background region has alpha p,
+regardless of how many logical layers cover it. Clear background regions remain
+clear. Native text and cursors retain their own opacity.
+
+For an image sample with coverage a and straight RGB c, the existing SDL
+background-layer behavior produces premultiplied output:
+
+```
+out.rgb = p * a * c + (1 - a) * dst.rgb
+out.a = p * a + (1 - a) * dst.a
+```
+
+Thus PDF text is part of a raster page and fades with the page; native text does
+not. Transparent image pixels preserve the destination. Ordinary source-over
+with source alpha p*a is not equivalent. Any future operation API needs to
+express this distinction directly and specify sampling and premultiplication.
+
+## Tests available before extraction
+
+`test/macos-snapshots/tag-ink.snap` checks actual rendered tag foreground pixels,
+covering failures that a cell-grid golden cannot detect.
+
+`test/macos-snapshots/rendering-parity.snap` uses native backing-scale metrics
+and checks rendered tag backgrounds and rules, top/bottom tags, opacity 100/71/0,
+invalid opacity rejection, and nested multiline Tree-sitter context separators.
+It also checks PDF placement and sampled page alpha at those opacity values,
+height fit, tint modes, and scrolling. `context.zig` is the nested context fixture;
+`docs/9p.pdf` is the existing PDF fixture. Optional PNG output is enabled with
+`PARDES_TEST_CAPTURE_DIR`.
+
+The Zig test named `mac tag layer ABI preserves logical capacity and physical
+grip` covers tag-bottom and border-color metadata as well as existing capacity
+and grip behavior. The existing Linux `test/window_opacity.py` checks background
+changes, native glyph preservation, zero opacity, invalid input, and restoration.
+
+Run on macOS:
+
+```sh
+zig build unit-test -Dplatform=macos '-Dtest-filter=mac tag layer ABI'
+zig build macos-e2e -Dplatform=macos
+zig build -Dplatform=macos
+```
+
+These are baseline regressions, not a comprehensive cross-backend image oracle.
+PDF alpha samples do not establish exact raster color or interpolation equality;
+offscreen AppKit captures do not test the desktop compositor. Live comparison
+still matters for window transparency and display color management.
+
+## Suggested later extraction and acceptance gates
+
+1. Add shared geometry/scene assertions for tags and context rows using supplied
+ metrics at several scales. Include clipping, partial rows, bottom tags, and
+ final separator order; expected values should come from the contract rather
+ than a copy of either backend's implementation.
+2. Extract that scene construction once and adapt both native renderers. Keep
+ current backend pixel checks running without regenerating goldens merely to
+ accept differences. Compare matching fixtures and settings on both systems.
+3. Add synthetic RGBA image coverage cases before extracting image composition:
+ opaque, transparent, and partial-alpha pixels over a known destination at
+ p=0, intermediate p, and p=1. Include fit, crop, and clip boundaries.
+4. Extend the contract to the remaining overlays and retained transition frames.
+ Measure frame time and allocations before broadening the abstraction.
+
+The desired result is one definition of visual policy with small native drawing
+adapters. A new GPU engine, shared font rasterizer, or wholesale backend rewrite
+is not required to reach that boundary.
diff --git a/src/macos.zig b/src/macos.zig
index 4b37255e..bbfb8c08 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -931,7 +931,7 @@ export fn pardes_grid_col_at(x: f32, row: u16, body_w: f32, tagline_w: f32) u16
export fn pardes_topbar_pane_border_rgb() u32 {
const rgb = pardes.config.gui_topbar_pane_border_rgb orelse fromTheme: {
const st = state orelse return color_default;
- break :fromTheme st.core.chromeTheme().scroll_track;
+ break :fromTheme st.core.chromeTheme().border;
};
return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2];
}
@@ -1979,6 +1979,11 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 {
8 => if (layer.cursor) |c| c.col else 65535,
9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0,
10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2],
+ 11 => @intFromBool(layer.kind == .pane and st.core.settings.tag_bottom),
+ 12 => rgb: {
+ const border = st.core.chromeTheme().border;
+ break :rgb @as(u32, border[0]) << 16 | @as(u32, border[1]) << 8 | border[2];
+ },
else => 0,
};
}
@@ -2914,6 +2919,20 @@ test "mac tag layer ABI preserves logical capacity and physical grip" {
defer core.surface.tag_layers[0] = .{};
try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0));
try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10));
+ try std.testing.expectEqual(@as(u32, 0), pardes_tag_layer_value(0, 11));
+ core.settings.tag_bottom = true;
+ try std.testing.expectEqual(@as(u32, 1), pardes_tag_layer_value(0, 11));
+ core.surface.tag_layers[0].kind = .column;
+ try std.testing.expectEqual(@as(u32, 0), pardes_tag_layer_value(0, 11));
+ core.surface.tag_layers[0].kind = .pane;
+ const border = core.chromeTheme().border;
+ const border_rgb = @as(u32, border[0]) << 16 | @as(u32, border[1]) << 8 | border[2];
+ try std.testing.expectEqual(border_rgb, pardes_tag_layer_value(0, 12));
+ try std.testing.expectEqual(pardes.config.gui_topbar_pane_border_rgb orelse border, .{
+ @as(u8, @intCast(pardes_topbar_pane_border_rgb() >> 16)),
+ @as(u8, @truncate(pardes_topbar_pane_border_rgb() >> 8)),
+ @as(u8, @truncate(pardes_topbar_pane_border_rgb())),
+ });
try std.testing.expectEqual(@as(u32, 11), pardes_tag_layer_value(0, 8));
try std.testing.expectEqual(@as(u8, 'Z'), pardes_tag_layer_cells(0).?[11].text[0]);
var canonical: [24]Cell = @splat(encodeCell(.{ .text = .{'G'} ++ .{0} ** 6, .len = 1, .default = false }));
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index 107c40fd..4e91e407 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -478,7 +478,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
if let rgb = view.themeBG {
backdrop.isHidden = true
window.isOpaque = view.backgroundAlpha >= 1
- window.backgroundColor = NSColor(
+ window.backgroundColor = view.backgroundAlpha < 1 ? .clear : NSColor(
srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
green: CGFloat((rgb >> 8) & 0xFF) / 255,
blue: CGFloat(rgb & 0xFF) / 255,
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index ba3677e7..7c537055 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -1006,7 +1006,7 @@ final class PardesView: NSView {
// 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 !clearGround { fillBackground(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()
@@ -1060,7 +1060,7 @@ final class PardesView: NSView {
if color != bgClear {
let bandY = tagline ? y + bandTop : y
let bandHeight = tagline ? taglines.height : cellHeight
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
+ fillBackground(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY,
width: CGFloat(col - start) * cellWidth, height: bandHeight),
color, block ? 1 : backgroundAlpha)
}
@@ -1071,29 +1071,6 @@ final class PardesView: NSView {
}
}
- // The rule joining the topbar band to the first pane-tag band. Both were
- // centred in their own rows until now, and centring two reduced-height
- // bands is exactly what leaves their unused half-bands touching with a
- // strip of window background showing between them — the case
- // config.gui_topbar_pane_border_px exists to close. Drawn only when that
- // first pane row really carries a band, so a session whose second row is
- // pane BODY gets no stray line across it.
- let ruleThickness = taglines.borderThickness
- let ruleColor = pardes_topbar_pane_border_rgb()
- if ruleThickness > 0, ruleColor != UInt32(PARDES_COLOR_DEFAULT),
- rows > Int(PARDES_TOPBAR_H) {
- let base = Int(PARDES_TOPBAR_H) * cols
- let joins = (0..<cols).contains { col in
- let flags = cells[base + col].flags
- return flags & UInt8(PARDES_CELL_DEFAULT) == 0
- && flags & UInt8(PARDES_CELL_TAGLINE) != 0
- }
- if joins {
- fill(ctx, CGRect(x: 0, y: taglines.borderTop, width: bounds.width,
- height: ruleThickness), ruleColor, backgroundAlpha)
- }
- }
-
// The core grid contains only complete cells, so any window height that
// is not a whole multiple of the cell leaves a strip below the last row.
// A Tagbottom band there is bottom-aligned against the window edge, and
@@ -1108,7 +1085,7 @@ final class PardesView: NSView {
let bg = resolve(cell, block: false, ground: ground,
clearGround: clearGround).bg
if bg != bgClear {
- fill(ctx, CGRect(x: 0, y: gridBottom, width: bounds.width,
+ fillBackground(ctx, CGRect(x: 0, y: gridBottom, width: bounds.width,
height: canvasHeight - gridBottom), bg, backgroundAlpha)
}
break
@@ -1211,6 +1188,7 @@ final class PardesView: NSView {
if let base = cells.baseAddress { drawBodyLayer(ctx, values:layer.values, cells:base, hideCursor:true) }
}
}
+ drawTagRules(ctx, layers: presentedTagLayers.map { $0.values })
}
}
@@ -1219,7 +1197,7 @@ final class PardesView: NSView {
private func snapshotTagLayers() -> [FrozenBodyLayer] {
var result: [FrozenBodyLayer] = []
for index in 0..<pardes_tag_layer_limit() {
- let values = (0...10).map { Int(pardes_tag_layer_value(index, UInt32($0))) }
+ let values = (0...12).map { Int(pardes_tag_layer_value(index, UInt32($0))) }
if values[0] == 0 { continue }
if let cells = pardes_tag_layer_cells(index) {
result.append(FrozenBodyLayer(values: values, cells: Array(UnsafeBufferPointer(start: cells, count: values[0]))))
@@ -1229,12 +1207,35 @@ final class PardesView: NSView {
}
private func drawTagLayers(_ ctx: CGContext, hideCursor: Bool, canonical: UnsafePointer<pardes_cell_s>, cols: Int, rows: Int) {
+ var layers: [[Int]] = []
for index in 0..<pardes_tag_layer_limit() {
- let values = (0...10).map { Int(pardes_tag_layer_value(index, UInt32($0))) }
+ let values = (0...12).map { Int(pardes_tag_layer_value(index, UInt32($0))) }
if values[0] == 0 { continue }
+ layers.append(values)
drawTagGrip(ctx, values:values, canonical:canonical, cols:cols, rows:rows)
if let cells = pardes_tag_layer_cells(index) { drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor) }
}
+ drawTagRules(ctx, layers: layers)
+ }
+
+ /// SDL's chrome overlay is drawn after its compact layers. Keep these
+ /// one-pixel rules above the full-row tag backgrounds as well.
+ private func drawTagRules(_ ctx: CGContext, layers: [[Int]]) {
+ let pixel = 1 / metricsScale
+ for values in layers where values[1] != 0 {
+ let kind = values[1]
+ let bottom = kind == 2 && values[11] != 0
+ let left = CGFloat(max(0, values[4] - Int(pardes_tag_text_inset()))) * cellWidth
+ let right = CGFloat(values[4] + values[6]) * cellWidth
+ let y = CGFloat(values[5] + (bottom ? 0 : 1)) * cellHeight - (bottom ? 0 : pixel)
+ fillBackground(ctx, CGRect(x: left, y: y, width: right-left, height: pixel),
+ UInt32(values[12]), backgroundAlpha)
+ }
+ if layers.contains(where: { $0[5] == Int(PARDES_TOPBAR_H) }), taglines.borderThickness > 0 {
+ fillBackground(ctx, CGRect(x: 0, y: taglines.borderTop, width: bounds.width,
+ height: taglines.borderThickness),
+ pardes_topbar_pane_border_rgb(), backgroundAlpha)
+ }
}
private func gripGlyph(_ text: String, face: Face) -> GripGlyph? {
@@ -1288,10 +1289,13 @@ final class PardesView: NSView {
private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer<pardes_cell_s>, hideCursor: Bool) {
let left = CGFloat(values[4]) * cellWidth
let y = CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height)
- let viewport = CGRect(x: left, y: y, width: CGFloat(values[6]) * cellWidth, height: taglines.height)
+ // SDL gives the tag a full body-row background; only its glyphs
+ // and per-cell highlights use the compact band.
+ let viewport = CGRect(x: left, y: CGFloat(values[5]) * cellHeight,
+ width: CGFloat(values[6]) * cellWidth, height: cellHeight)
ctx.saveGState()
ctx.clip(to: viewport)
- fill(ctx, viewport, UInt32(values[10]), backgroundAlpha)
+ fillBackground(ctx, viewport, UInt32(values[10]), backgroundAlpha)
for pass in 0...1 {
for col in 0..<values[0] {
var cell = cells[col]
@@ -1300,7 +1304,7 @@ final class PardesView: NSView {
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),
+ fillBackground(ctx, CGRect(x: x, y: y, width: taglines.width, height: taglines.height),
style.bg, block ? 1 : backgroundAlpha)
continue
}
@@ -1314,7 +1318,8 @@ final class PardesView: NSView {
drawRules(ctx, cell, style, x: x, width: taglines.width, baseline: baseline)
setFill(ctx, style.fg, style.alpha)
ctx.textPosition = CGPoint(x: x, y: baseline)
- let line = NSAttributedString(string: text, attributes: [fontAttribute: taglines.fonts[face.rawValue]])
+ let line = NSAttributedString(string: text, attributes: [fontAttribute: taglines.fonts[face.rawValue],
+ NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true])
CTLineDraw(CTLineCreateWithAttributedString(line as CFAttributedString), ctx)
ctx.restoreGState()
}
@@ -1362,25 +1367,25 @@ final class PardesView: NSView {
height: CGFloat(value(6))*cellHeight)
ctx.saveGState()
ctx.clip(to: viewport)
- fill(ctx, viewport, themeBG ?? pardesDefaultBG, backgroundAlpha)
+ fillBackground(ctx, viewport, themeBG ?? bgClear, backgroundAlpha)
for row in 0..<rows {
let compact = row < contexts
let advance = compact ? taglines.width : cellWidth
let height = compact ? taglines.height : cellHeight
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, backgroundAlpha)
+ let rowStyle = resolve(cells[row*cols], block: false, ground: themeBG ?? pardesDefaultBG, clearGround: themeBG == nil)
+ fillBackground(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 block = isCursor && value(10) == 0
let style = resolve(cell, block: block,
- ground: themeBG ?? pardesDefaultBG, clearGround: false)
+ ground: themeBG ?? pardesDefaultBG, clearGround: themeBG == nil)
let x = left + CGFloat(col)*advance
if pass == 0 {
- fill(ctx, CGRect(x: x, y: y, width: advance, height: height),
+ fillBackground(ctx, CGRect(x: x, y: y, width: advance, height: height),
style.bg, block ? 1 : backgroundAlpha)
continue
}
@@ -1398,7 +1403,8 @@ final class PardesView: NSView {
drawRules(ctx, cell, style, x: x, width: advance, baseline: baseline)
setFill(ctx, style.fg, style.alpha)
ctx.textPosition = CGPoint(x: x, y: baseline)
- let line = NSAttributedString(string: text, attributes: [fontAttribute: font])
+ let line = NSAttributedString(string: text, attributes: [fontAttribute: font,
+ NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true])
CTLineDraw(CTLineCreateWithAttributedString(line as CFAttributedString), ctx)
ctx.restoreGState()
}
@@ -1415,7 +1421,7 @@ final class PardesView: NSView {
// The SDL shell paints this rule over the row's LAST pixel, not
// 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,
+ fillBackground(ctx, CGRect(x: left, y: top + CGFloat(row + 1)*taglines.height - thickness,
width: viewport.width, height: thickness), border, backgroundAlpha)
}
}
@@ -1542,10 +1548,18 @@ final class PardesView: NSView {
ctx.translateBy(x: placed.destination.minX, y: placed.destination.maxY)
ctx.scaleBy(x: 1, y: -1)
ctx.interpolationQuality = .high
- ctx.draw(placed.image, in: CGRect(
- x: 0, y: 0,
- width: placed.destination.width,
- height: placed.destination.height))
+ let rect = CGRect(x: 0, y: 0,
+ width: placed.destination.width, height: placed.destination.height)
+ // Match image.frag.glsl/backgroundLayerBlend: source coverage removes
+ // the destination independently of WindowOpacity, then the source is
+ // added at that opacity. Ordinary source-over would compound alpha;
+ // copy would discard the background under transparent image pixels.
+ ctx.setAlpha(1)
+ ctx.setBlendMode(.destinationOut)
+ ctx.draw(placed.image, in: rect)
+ ctx.setAlpha(backgroundAlpha)
+ ctx.setBlendMode(.plusLighter)
+ ctx.draw(placed.image, in: rect)
ctx.restoreGState()
}
@@ -1680,7 +1694,8 @@ final class PardesView: NSView {
flush()
setFill(ctx, style.fg, style.alpha)
let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue]
- let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font,
+ NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true])
ctx.textPosition = CGPoint(x: x, y: cellBaseline)
CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
// CTLineDraw leaves the text position at the END of what it drew,
@@ -1750,10 +1765,27 @@ final class PardesView: NSView {
}
private func setFill(_ ctx: CGContext, _ rgb: UInt32, _ alpha: CGFloat) {
- ctx.setFillColor(red: CGFloat((rgb >> 16) & 0xFF) / 255,
- green: CGFloat((rgb >> 8) & 0xFF) / 255,
- blue: CGFloat(rgb & 0xFF) / 255,
- alpha: alpha)
+ // Theme channels, like PDF raster bytes, are sRGB. Device RGB can
+ // brighten them when AppKit's backing bitmap uses another profile.
+ ctx.setFillColor(CGColor(srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
+ blue: CGFloat(rgb & 0xFF) / 255,
+ alpha: alpha))
+ }
+
+ /// One surface pixel has one background opacity, even when a compact
+ /// layer covers a canonical cell. Source-over would turn 71% into 92%,
+ /// then 98%, as the ground, row and cell are painted over each other.
+ private func fillBackground(_ ctx: CGContext, _ rect: CGRect, _ rgb: UInt32, _ alpha: CGFloat) {
+ ctx.saveGState()
+ ctx.setShouldAntialias(false)
+ if rgb == bgClear {
+ ctx.clear(rect)
+ } else {
+ ctx.setBlendMode(.copy)
+ fill(ctx, rect, rgb, alpha)
+ }
+ ctx.restoreGState()
}
private func fill(_ ctx: CGContext, _ rect: CGRect, _ rgb: UInt32, _ alpha: CGFloat) {
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index a2d13b1f..a2e34f53 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -353,6 +353,7 @@ void pardes_row_metrics(uint16_t body_w, uint16_t body_h, uint16_t tagline_w, ui
uint32_t pardes_tag_text_inset(void);
uint32_t pardes_grip_columns(void);
uint32_t pardes_tag_layer_limit(void);
+// Tag layer fields 11 and 12 carry tag-bottom placement and chrome border RGB.
uint32_t pardes_tag_layer_value(uint32_t index, uint32_t field);
const pardes_cell_s *pardes_tag_layer_cells(uint32_t index);
uint32_t pardes_body_layer_limit(void);
diff --git a/test/macos-snapshots/context.zig b/test/macos-snapshots/context.zig
new file mode 100644
index 00000000..ff8f8553
--- /dev/null
+++ b/test/macos-snapshots/context.zig
@@ -0,0 +1,109 @@
+const Workspace = struct {
+ const enabled = true;
+
+ fn draw(
+ value: u32,
+ ) u32 {
+ var result = value;
+ result +%= 1;
+ result +%= 2;
+ result +%= 3;
+ result +%= 4;
+ result +%= 5;
+ result +%= 6;
+ result +%= 7;
+ result +%= 8;
+ result +%= 9;
+ result +%= 10;
+ result +%= 11;
+ result +%= 12;
+ result +%= 13;
+ result +%= 14;
+ result +%= 15;
+ result +%= 16;
+ result +%= 17;
+ result +%= 18;
+ result +%= 19;
+ result +%= 20;
+ result +%= 21;
+ result +%= 22;
+ result +%= 23;
+ result +%= 24;
+ result +%= 25;
+ result +%= 26;
+ result +%= 27;
+ result +%= 28;
+ result +%= 29;
+ result +%= 30;
+ result +%= 31;
+ result +%= 32;
+ result +%= 33;
+ result +%= 34;
+ result +%= 35;
+ result +%= 36;
+ result +%= 37;
+ result +%= 38;
+ result +%= 39;
+ result +%= 40;
+ result +%= 41;
+ result +%= 42;
+ result +%= 43;
+ result +%= 44;
+ result +%= 45;
+ result +%= 46;
+ result +%= 47;
+ result +%= 48;
+ result +%= 49;
+ result +%= 50;
+ result +%= 51;
+ result +%= 52;
+ result +%= 53;
+ result +%= 54;
+ result +%= 55;
+ result +%= 56;
+ result +%= 57;
+ result +%= 58;
+ result +%= 59;
+ result +%= 60;
+ result +%= 61;
+ result +%= 62;
+ result +%= 63;
+ result +%= 64;
+ result +%= 65;
+ result +%= 66;
+ result +%= 67;
+ result +%= 68;
+ result +%= 69;
+ result +%= 70;
+ result +%= 71;
+ result +%= 72;
+ result +%= 73;
+ result +%= 74;
+ result +%= 75;
+ result +%= 76;
+ result +%= 77;
+ result +%= 78;
+ result +%= 79;
+ result +%= 80;
+ result +%= 81;
+ result +%= 82;
+ result +%= 83;
+ result +%= 84;
+ result +%= 85;
+ result +%= 86;
+ result +%= 87;
+ result +%= 88;
+ result +%= 89;
+ result +%= 90;
+ result +%= 91;
+ result +%= 92;
+ result +%= 93;
+ result +%= 94;
+ result +%= 95;
+ result +%= 96;
+ result +%= 97;
+ result +%= 98;
+ result +%= 99;
+ return result;
+ }
+};
diff --git a/test/macos-snapshots/rendering-parity.golden b/test/macos-snapshots/rendering-parity.golden
new file mode 100644
index 00000000..536f6800
--- /dev/null
+++ b/test/macos-snapshots/rendering-parity.golden
@@ -0,0 +1,14 @@
+== backgrounds tags-100 uniform
+== backgrounds tags-bottom uniform
+== backgrounds tags-71 uniform
+== backgrounds tags-0 uniform
+== backgrounds invalid-stays-0 uniform
+== context context-100 compact separators verified
+== context context-71 compact separators verified
+== pdf pdf-100 native page opacity verified
+== pdf pdf-0 native page opacity verified
+== pdf pdf-71 native page opacity verified
+== pdf pdf-fit-height native page opacity verified
+== pdf pdf-full-tint native page opacity verified
+== pdf pdf-original-colors native page opacity verified
+== pdf pdf-scrolled native page opacity verified
diff --git a/test/macos-snapshots/rendering-parity.snap b/test/macos-snapshots/rendering-parity.snap
new file mode 100644
index 00000000..cdd940c8
--- /dev/null
+++ b/test/macos-snapshots/rendering-parity.snap
@@ -0,0 +1,54 @@
+start 40 110
+native-metrics
+wait 8000 $
+command Theme forge
+command TaglineSize 70
+stable 700 20000
+check-backgrounds tags-100
+command Tagbottom
+stable 700 20000
+check-backgrounds tags-bottom
+command Tagbottom
+stable 700 20000
+command WindowOpacity 71
+stable 700 20000
+check-backgrounds tags-71
+command WindowOpacity 0
+stable 700 20000
+check-backgrounds tags-0
+command WindowOpacity 101
+stable 700 20000
+check-backgrounds invalid-stays-0
+command WindowOpacity 100
+open-fixture context.zig:55
+command TreeContext on
+stable 700 20000
+capture context-initial
+check-context context-100
+command WindowOpacity 71
+stable 700 20000
+check-context context-71
+command WindowOpacity 100
+open-fixture ../../docs/9p.pdf
+stable 1000 20000
+check-pdf pdf-100
+command WindowOpacity 0
+stable 700 20000
+check-pdf pdf-0
+command WindowOpacity 71
+stable 700 20000
+check-pdf pdf-71
+command WindowOpacity 100
+command PdfFit
+stable 700 20000
+check-pdf pdf-fit-height
+command PdfTint
+stable 700 20000
+check-pdf pdf-full-tint
+command PdfTint
+stable 700 20000
+check-pdf pdf-original-colors
+command PdfFit
+scroll 6 10 30
+stable 700 20000
+check-pdf pdf-scrolled
diff --git a/test/macos-snapshots/tag-ink.golden b/test/macos-snapshots/tag-ink.golden
new file mode 100644
index 00000000..dfb0704d
--- /dev/null
+++ b/test/macos-snapshots/tag-ink.golden
@@ -0,0 +1 @@
+== tag-ink forge-tags visible
diff --git a/test/macos-snapshots/tag-ink.snap b/test/macos-snapshots/tag-ink.snap
new file mode 100644
index 00000000..e9e4c8e2
--- /dev/null
+++ b/test/macos-snapshots/tag-ink.snap
@@ -0,0 +1,8 @@
+start 24 80
+wait 8000 Newcol Joincol
+wait 8000 $
+stable 700 20000
+command Theme forge
+command TaglineSize 70
+stable 700 20000
+draw-tag-ink forge-tags
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
index 43627a7e..5427c3ad 100644
--- a/test/macos_e2e.swift
+++ b/test/macos_e2e.swift
@@ -92,6 +92,8 @@ private final class Driver: PardesViewDelegate {
private var window: NSWindow?
private var view: PardesView?
private var booted = false
+ private var nativeMetrics = false
+ private let scriptDirectory: String
private var observer: NSObjectProtocol?
private var output = ""
private var nextAnimationFrame: Date?
@@ -101,7 +103,8 @@ private final class Driver: PardesViewDelegate {
/// The script's own file name, for error messages.
private let scriptName: String
- init(scriptName: String) {
+ init(scriptName: String, scriptDirectory: String) {
+ self.scriptDirectory = scriptDirectory
self.scriptName = scriptName
// The app answers this notification with pump(); so does the harness,
// and for the same reason — the view calls into the core and never
@@ -232,11 +235,17 @@ private final class Driver: PardesViewDelegate {
let grid = view.gridSize
// Points, not backing-store pixels. The app passes physical pixels
// because the native PDF placement path measures in them; a golden must
- // not change when the same script runs on a Retina display, and nothing
- // here places a PDF.
- let cellW = UInt16(max(1, min(view.cellWidth.rounded(), CGFloat(UInt16.max))))
- let cellH = UInt16(max(1, min(view.cellHeight.rounded(), CGFloat(UInt16.max))))
+ // not change when the same grid script runs on a Retina display. Pixel
+ // parity scripts opt into native metrics below, including PDF placement.
+ let scale: CGFloat = nativeMetrics ? (window?.backingScaleFactor ?? 1) : 1
+ let cellW = UInt16(max(1, min((view.cellWidth * scale).rounded(), CGFloat(UInt16.max))))
+ let cellH = UInt16(max(1, min((view.cellHeight * scale).rounded(), CGFloat(UInt16.max))))
pardes_resize(grid.cols, grid.rows, cellW, cellH)
+ if nativeMetrics {
+ pardes_row_metrics(cellW, cellH,
+ UInt16(max(1, (view.taglineCellWidth * scale).rounded())),
+ UInt16(max(1, (view.taglineBandHeight * scale).rounded())))
+ }
pump()
}
@@ -498,6 +507,34 @@ private final class Driver: PardesViewDelegate {
output += "== draw \(label) \(w)x\(h) nonblank\n"
}
+ /// Forge's topbar must contain light ink, not CoreText's default black.
+ /// Restrict the sample to text, excluding grips, pane bodies and cursors.
+ private func drawTagInk(_ label: String) throws {
+ let view = try live()
+ let bounds = view.bounds
+ guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
+ throw ScriptError(message: "draw-tag-ink: no bitmap")
+ }
+ view.cacheDisplay(in: bounds, to: rep)
+ let scale = CGFloat(rep.pixelsWide) / bounds.width
+ var brightest: CGFloat = 0
+ for y in 0..<Int(view.cellHeight * scale) {
+ for x in Int(3 * view.cellWidth * scale)..<Int(25 * view.cellWidth * scale) {
+ if let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) {
+ brightest = max(brightest, min(color.redComponent, color.greenComponent, color.blueComponent))
+ }
+ }
+ }
+ guard brightest > 0.5 else {
+ throw ScriptError(message: "draw-tag-ink: Forge topbar has no light foreground ink")
+ }
+ if let directory = ProcessInfo.processInfo.environment["PARDES_TEST_CAPTURE_DIR"],
+ let png = rep.representation(using: .png, properties: [:]) {
+ try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent(label + ".png"))
+ }
+ output += "== tag-ink \(label) visible\n"
+ }
+
/// The alpha range of a drawn frame, recorded into the golden.
///
/// The contract WindowOpacity states (shaders/ui.frag.glsl, and
@@ -543,6 +580,122 @@ private final class Driver: PardesViewDelegate {
output += "== draw-opacity \(label) \(w)x\(h) alpha \(lowest)..\(highest)\n"
}
+ private func renderedCapture(_ label: String) throws -> NSBitmapImageRep {
+ let view = try live()
+ guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else {
+ throw ScriptError(message: "capture: no bitmap")
+ }
+ view.cacheDisplay(in: view.bounds, to: rep)
+ if let directory = ProcessInfo.processInfo.environment["PARDES_TEST_CAPTURE_DIR"],
+ let png = rep.representation(using: .png, properties: [:]) {
+ try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent(label + ".png"))
+ }
+ return rep
+ }
+
+ // colorAt labels components as calibrated RGB even for a Display P3
+ // bitmap. Reattach the bitmap's actual profile before comparing sRGB.
+ private func sample(_ rep: NSBitmapImageRep, x: Int, y: Int) -> NSColor? {
+ guard let raw = rep.colorAt(x: x, y: y) else { return nil }
+ let components = [raw.redComponent, raw.greenComponent, raw.blueComponent, raw.alphaComponent]
+ return NSColor(colorSpace: rep.colorSpace, components: components, count: 4).usingColorSpace(.sRGB)
+ }
+
+ private func checkBackgrounds(_ label: String) throws {
+ let view = try live(), rep = try renderedCapture(label)
+ let scale = CGFloat(rep.pixelsWide) / view.bounds.width
+ func check(x: CGFloat, y: CGFloat, rgb: UInt32, name: String) throws {
+ guard let c = sample(rep, x: Int(x * scale), y: Int(y * scale)) else {
+ throw ScriptError(message: "\(name): no pixel")
+ }
+ guard abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 else {
+ throw ScriptError(message: "\(name): background alpha \(c.alphaComponent), expected \(view.backgroundAlpha)")
+ }
+ if view.backgroundAlpha > 0 {
+ let actual = [c.redComponent, c.greenComponent, c.blueComponent]
+ let expected = [CGFloat((rgb >> 16) & 255), CGFloat((rgb >> 8) & 255), CGFloat(rgb & 255)]
+ guard zip(actual, expected).allSatisfy({ abs($0 * 255 - $1) < 4 }) else {
+ throw ScriptError(message: "\(name): tag background does not cover the full row: \(actual) vs \(expected)")
+ }
+ }
+ }
+ for i in 0..<pardes_tag_layer_limit() {
+ guard pardes_tag_layer_value(i, 0) > 0 else { continue }
+ let x = CGFloat(pardes_tag_layer_value(i, 4) + pardes_tag_layer_value(i, 6)) * view.cellWidth - 2 / scale
+ let top = CGFloat(pardes_tag_layer_value(i, 5)) * view.cellHeight
+ let height = Int(view.cellHeight * scale)
+ let kind = pardes_tag_layer_value(i, 1)
+ let bottom = kind == 2 && pardes_tag_layer_value(i, 11) != 0
+ for pixel in 0..<height {
+ var rgb = pardes_tag_layer_value(i, 10)
+ if kind != 0 && pixel == (bottom ? 0 : height - 1) { rgb = 0x475263 }
+ if pardes_tag_layer_value(i, 5) == UInt32(PARDES_TOPBAR_H) && pixel == 0 { rgb = 0x475263 }
+ try check(x: x, y: top + (CGFloat(pixel) + 0.5) / scale,
+ rgb: rgb, name: "tag \(i), pixel \(pixel)")
+ }
+ }
+ try check(x: view.bounds.width - 2 * view.cellWidth,
+ y: view.bounds.height - 2 * view.cellHeight,
+ rgb: view.themeBG ?? pardesDefaultBG, name: "body")
+ output += "== backgrounds \(label) uniform\n"
+ }
+
+ private func checkContext(_ label: String) throws {
+ let view = try live(), rep = try renderedCapture(label)
+ let scale = CGFloat(rep.pixelsWide) / view.bounds.width
+ var headers = 0, separators = 0
+ for i in 0..<pardes_body_layer_limit() {
+ let count = Int(pardes_body_layer_value(i, 2))
+ guard count > 0 else { continue }
+ headers += count
+ let x = CGFloat(pardes_body_layer_value(i, 3) + pardes_body_layer_value(i, 5)) * view.cellWidth - 2 / scale
+ let top = CGFloat(pardes_body_layer_value(i, 4)) * view.cellHeight
+ for row in 0..<count where pardes_body_layer_value(i, UInt32(11 + row)) != 0 {
+ separators += 1
+ let y = top + CGFloat(row + 1) * view.taglineBandHeight - 0.5 / scale
+ guard let c = sample(rep, x: Int(x * scale), y: Int(y * scale)) else {
+ throw ScriptError(message: "context: no separator pixel")
+ }
+ let rgb: UInt32 = 0x475263 // Forge border, also used by the Linux chrome overlay.
+ guard abs(c.redComponent * 255 - CGFloat((rgb >> 16) & 255)) < 4,
+ abs(c.greenComponent * 255 - CGFloat((rgb >> 8) & 255)) < 4,
+ abs(c.blueComponent * 255 - CGFloat(rgb & 255)) < 4,
+ abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 else {
+ throw ScriptError(message: "context: separator is misplaced or has accumulated opacity")
+ }
+ }
+ }
+ guard headers >= 2, separators > 0 else {
+ throw ScriptError(message: "context: expected nested compact declarations, got \(headers) rows")
+ }
+ output += "== context \(label) compact separators verified\n"
+ }
+
+ private func checkPDF(_ label: String) throws {
+ let view = try live(), rep = try renderedCapture(label)
+ guard nativeMetrics, pardes_frame_images() > 0, let images = pardes_frame_image_list() else {
+ throw ScriptError(message: "PDF: no native image placements")
+ }
+ let scale = window?.backingScaleFactor ?? 1
+ let image = images[0]
+ let x = CGFloat(image.cell_x) * view.cellWidth + CGFloat(image.dst_x) / scale
+ let y = CGFloat(image.cell_y) * view.cellHeight + (CGFloat(image.dst_y) + CGFloat(image.offset_y)) / scale
+ let width = CGFloat(image.dst_w) / scale, height = CGFloat(image.dst_h) / scale
+ guard width > view.cellWidth * 4, height > view.cellHeight * 2,
+ y >= CGFloat(image.cell_y) * view.cellHeight - 1,
+ x + width <= view.bounds.width + 1, y + height <= view.bounds.height + 1 else {
+ throw ScriptError(message: "PDF: placement is outside its physical-pixel viewport")
+ }
+ let backing = CGFloat(rep.pixelsWide) / view.bounds.width
+ var matched = 0
+ for fraction in [CGFloat(0.2), 0.4, 0.6, 0.8] {
+ if let c = rep.colorAt(x: Int((x + width * fraction) * backing),
+ y: Int((y + height * fraction) * backing)), abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 { matched += 1 }
+ }
+ guard matched == 4 else { throw ScriptError(message: "PDF: raster opacity does not match WindowOpacity") }
+ output += "== pdf \(label) native page opacity verified\n"
+ }
+
// MARK: - the script
func run(source: String) throws -> String {
@@ -569,6 +722,23 @@ private final class Driver: PardesViewDelegate {
let args = rest.split(separator: " ").map(String.init)
switch cmd {
+ case "native-metrics":
+ nativeMetrics = true
+ pardesViewDidResize(try live())
+ case "open-fixture":
+ _ = try live()
+ let path = URL(fileURLWithPath: scriptDirectory).appendingPathComponent(rest).standardized.path
+ let command = "Look " + path
+ pardes_command(command, command.utf8.count)
+ pump()
+ case "check-backgrounds":
+ try checkBackgrounds(rest)
+ case "check-context":
+ try checkContext(rest)
+ case "check-pdf":
+ try checkPDF(rest)
+ case "capture":
+ _ = try renderedCapture(rest)
case "start":
let rows = try int(args, 0, "start rows")
let cols = try int(args, 1, "start cols")
@@ -882,6 +1052,9 @@ private final class Driver: PardesViewDelegate {
// 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-tag-ink":
+ try drawTagInk(rest)
+
case "draw-opacity":
guard !rest.isEmpty else { throw ScriptError(message: "draw-opacity needs a label") }
try drawOpacity(rest)
@@ -1125,7 +1298,7 @@ private func runOne(scriptPath: String, update: Bool) -> Bool {
let goldenPath = String(scriptPath.dropLast(".snap".count)) + ".golden"
let actualPath = String(scriptPath.dropLast(".snap".count)) + ".actual"
- let driver = Driver(scriptName: name)
+ let driver = Driver(scriptName: name, scriptDirectory: (scriptPath as NSString).deletingLastPathComponent)
// pardes_deinit is mandatory, not tidiness: the ABI is a singleton, so a
// core left standing makes the NEXT script's pardes_init return 1.
defer { driver.shutdown() }