diff options
| -rw-r--r-- | docs/macos.md | 58 | ||||
| -rw-r--r-- | docs/rendering-parity-design.md | 132 | ||||
| -rw-r--r-- | src/macos.zig | 21 | ||||
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 2 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 128 | ||||
| -rw-r--r-- | src/macos/pardes.h | 1 | ||||
| -rw-r--r-- | test/macos-snapshots/context.zig | 109 | ||||
| -rw-r--r-- | test/macos-snapshots/rendering-parity.golden | 14 | ||||
| -rw-r--r-- | test/macos-snapshots/rendering-parity.snap | 54 | ||||
| -rw-r--r-- | test/macos-snapshots/tag-ink.golden | 1 | ||||
| -rw-r--r-- | test/macos-snapshots/tag-ink.snap | 8 | ||||
| -rw-r--r-- | test/macos_e2e.swift | 185 |
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() } |
