Fix Retina scaling in design mode annotation crops (#8831)
* test(browser): reproduce Retina-scaled screenshot crops * fix(browser): crop screenshots at native pixel scale * test: update Ghostty surface config ABI lock * fix: remove redundant diagnostic log await * test(browser): require transparent annotation outlines * fix(browser): keep drawn annotations over live page * test(browser): verify cropped annotation pixels --------- Co-authored-by: cmux reload-cloud <[email protected]>
This commit is contained in:
co-authored by
cmux reload-cloud
parent
22e4173725
commit
caf70dfe38
+13
-23
@@ -95,7 +95,7 @@
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62 ? "rgba(0, 0, 0, 0.88)" : "white";
|
||||
};
|
||||
|
||||
// Captured annotation cards: one immutable context artifact per stroke.
|
||||
// Captured annotation regions: one immutable context artifact per stroke.
|
||||
const regionReferences = [];
|
||||
const marqueeThresholdPixels = 5;
|
||||
let pendingPointer = null;
|
||||
@@ -629,7 +629,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Annotation snapshots translate page-anchored cards to current viewport
|
||||
// Annotation snapshots translate page-anchored regions to current viewport
|
||||
// coordinates while keeping a stable identity across scrolling.
|
||||
const regionSnapshotFor = (region) => {
|
||||
const x = region.pageX - (globalThis.scrollX || 0);
|
||||
@@ -1012,8 +1012,8 @@
|
||||
background: "rgba(10, 132, 255, 0.07)",
|
||||
});
|
||||
|
||||
// Freehand ink is the only visible feedback until native capture returns
|
||||
// the context-rich composited card.
|
||||
// Freehand ink is the only visible feedback while native captures context;
|
||||
// completion replaces it with a transparent region outline.
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const strokeSvg = document.createElementNS(svgNS, "svg");
|
||||
Object.assign(strokeSvg.style, {
|
||||
@@ -1072,7 +1072,7 @@
|
||||
return element;
|
||||
};
|
||||
|
||||
const annotationCard = () => {
|
||||
const annotationOutline = () => {
|
||||
const element = document.createElement("div");
|
||||
Object.assign(element.style, {
|
||||
display: "none",
|
||||
@@ -1081,12 +1081,8 @@
|
||||
boxSizing: "border-box",
|
||||
border: "1.5px dashed rgb(10, 132, 255)",
|
||||
borderRadius: "14px",
|
||||
backgroundColor: "white",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "100% 100%",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.18)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "transparent",
|
||||
boxShadow: "none",
|
||||
});
|
||||
return element;
|
||||
};
|
||||
@@ -1120,7 +1116,7 @@
|
||||
const refreshRegionOutlines = () => {
|
||||
if (!overlay) return;
|
||||
while (overlay.regionOutlines.length < regionReferences.length) {
|
||||
const outline = annotationCard();
|
||||
const outline = annotationOutline();
|
||||
overlay.regionOutlines.push(outline);
|
||||
overlay.selectionLayer.append(outline);
|
||||
}
|
||||
@@ -1135,9 +1131,8 @@
|
||||
const isHovered = hoveredSelectionIndex === selectedReferences.length + index;
|
||||
outline.style.borderColor = tint;
|
||||
outline.style.boxShadow = isHovered
|
||||
? `0 0 0 4px ${colorWithAlpha(tint, 0.55)}, 0 8px 24px rgba(0, 0, 0, 0.18)`
|
||||
: "0 8px 24px rgba(0, 0, 0, 0.18)";
|
||||
outline.style.backgroundImage = `url("${region.imageURL}")`;
|
||||
? `0 0 0 4px ${colorWithAlpha(tint, 0.55)}`
|
||||
: "none";
|
||||
place(outline, {
|
||||
x: region.pageX - (globalThis.scrollX || 0),
|
||||
y: region.pageY - (globalThis.scrollY || 0),
|
||||
@@ -2060,7 +2055,6 @@
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
imageURL,
|
||||
expectedScrollX,
|
||||
expectedScrollY,
|
||||
expectedViewportWidth,
|
||||
@@ -2074,8 +2068,7 @@
|
||||
|| descriptor.scroll_x !== expectedScrollX
|
||||
|| descriptor.scroll_y !== expectedScrollY
|
||||
|| descriptor.viewport.width !== expectedViewportWidth
|
||||
|| descriptor.viewport.height !== expectedViewportHeight
|
||||
|| !String(imageURL || "").startsWith("data:image/png;base64,")) {
|
||||
|| descriptor.viewport.height !== expectedViewportHeight) {
|
||||
return null;
|
||||
}
|
||||
regionReferences.push({
|
||||
@@ -2084,12 +2077,9 @@
|
||||
pageY: y + expectedScrollY,
|
||||
width,
|
||||
height,
|
||||
imageURL: String(imageURL),
|
||||
colorIndex: pendingAnnotation.colorIndex,
|
||||
});
|
||||
// Each card retains screenshot-sized encoded and decoded image data.
|
||||
// Keep a useful multi-stroke stack while evicting the oldest card so a
|
||||
// long drawing session has a fixed memory ceiling.
|
||||
// Keep the prompt and overlay bounded during a long drawing session.
|
||||
if (regionReferences.length > maxAnnotationReferences) {
|
||||
regionReferences.splice(0, regionReferences.length - maxAnnotationReferences);
|
||||
hoveredSelectionIndex = null;
|
||||
@@ -2104,7 +2094,7 @@
|
||||
revision += 1;
|
||||
// Native capture completion is the authoritative phase transition.
|
||||
// Reconcile it directly so a frame request paused during WebKit's
|
||||
// snapshot cannot strand the card behind an outstanding frame token.
|
||||
// snapshot cannot strand the outline behind an outstanding frame token.
|
||||
refreshOverlay();
|
||||
return emit();
|
||||
},
|
||||
|
||||
+18
-16
@@ -4,24 +4,26 @@ import Testing
|
||||
@Suite
|
||||
struct GhosttySurfaceConfigABITests {
|
||||
@Test func publicConfigLayoutRemainsStable() {
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.size == 120)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.stride == 120)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.size == 168)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.stride == 168)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.alignment == 8)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.platform_tag) == 0)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.platform) == 8)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.userdata) == 16)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.scale_factor) == 24)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.font_size) == 32)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.working_directory) == 40)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.command) == 48)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.env_vars) == 56)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.env_var_count) == 64)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.initial_input) == 72)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.wait_after_command) == 80)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.context) == 84)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.io_mode) == 88)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.io_write_cb) == 96)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.io_write_userdata) == 104)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.renderer_event_cb) == 112)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.userdata) == 48)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.scale_factor) == 56)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.font_size) == 64)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.working_directory) == 72)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.command) == 80)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.env_vars) == 88)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.env_var_count) == 96)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.initial_input) == 104)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.wait_after_command) == 112)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.context) == 116)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.io_mode) == 120)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.io_write_cb) == 128)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.io_write_userdata) == 136)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.renderer_event_cb) == 144)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.pty_tee_cb) == 152)
|
||||
#expect(MemoryLayout<ghostty_surface_config_s>.offset(of: \.pty_tee_userdata) == 160)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ extension BrowserDesignModeController {
|
||||
let value = try await evaluate(
|
||||
"""
|
||||
return globalThis.__cmuxDesignMode?.completeAnnotationCapture(
|
||||
id, x, y, width, height, imageURL,
|
||||
id, x, y, width, height,
|
||||
scrollX, scrollY, viewportWidth, viewportHeight
|
||||
);
|
||||
""",
|
||||
@@ -133,7 +133,6 @@ extension BrowserDesignModeController {
|
||||
"y": capture.contextRect.y,
|
||||
"width": capture.contextRect.width,
|
||||
"height": capture.contextRect.height,
|
||||
"imageURL": "data:image/png;base64,\(pngData.base64EncodedString())",
|
||||
"scrollX": capture.descriptor.scrollX,
|
||||
"scrollY": capture.descriptor.scrollY,
|
||||
"viewportWidth": capture.descriptor.viewport.width,
|
||||
|
||||
@@ -113,6 +113,7 @@ enum BrowserScreenshotCrop {
|
||||
return clamp(imageRect, to: NSRect(origin: .zero, size: imageSize))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func croppedImage(
|
||||
from image: NSImage,
|
||||
selectionInView selection: NSRect,
|
||||
@@ -127,15 +128,37 @@ enum BrowserScreenshotCrop {
|
||||
throw BrowserScreenshotError.invalidSelection
|
||||
}
|
||||
|
||||
let cropped = NSImage(size: cropRect.size)
|
||||
cropped.lockFocus()
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(cropRect.width),
|
||||
pixelsHigh: Int(cropRect.height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .calibratedRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
), let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
|
||||
throw BrowserScreenshotError.invalidImageRepresentation
|
||||
}
|
||||
|
||||
bitmap.size = cropRect.size
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
context.imageInterpolation = .high
|
||||
image.draw(
|
||||
in: NSRect(origin: .zero, size: cropRect.size),
|
||||
from: cropRect,
|
||||
operation: .copy,
|
||||
fraction: 1.0
|
||||
fraction: 1.0,
|
||||
respectFlipped: false,
|
||||
hints: nil
|
||||
)
|
||||
cropped.unlockFocus()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
let cropped = NSImage(size: cropRect.size)
|
||||
cropped.addRepresentation(bitmap)
|
||||
return cropped
|
||||
}
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
|
||||
BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */; };
|
||||
8478A0010000000000000001 /* BrowserSameDocumentNavigationMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8478A0010000000000000002 /* BrowserSameDocumentNavigationMessageHandler.swift */; };
|
||||
4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0014472B0014472B001 /* BrowserScreenshot.swift */; };
|
||||
8827C0010000000000000001 /* BrowserScreenshotCropTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8827D0010000000000000001 /* BrowserScreenshotCropTests.swift */; };
|
||||
4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */; };
|
||||
4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */; };
|
||||
80560000000000000000000B /* BrowserScreenshotTilePlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80560000000000000000000C /* BrowserScreenshotTilePlacement.swift */; };
|
||||
@@ -2610,6 +2611,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRemoteWorkspaceStatus.swift; sourceTree = "<group>"; };
|
||||
8478A0010000000000000002 /* BrowserSameDocumentNavigationMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSameDocumentNavigationMessageHandler.swift; sourceTree = "<group>"; };
|
||||
4472B0014472B0014472B001 /* BrowserScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshot.swift; sourceTree = "<group>"; };
|
||||
8827D0010000000000000001 /* BrowserScreenshotCropTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserScreenshotCropTests.swift; sourceTree = "<group>"; };
|
||||
4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotPipeline.swift; sourceTree = "<group>"; };
|
||||
4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotSnapshotter.swift; sourceTree = "<group>"; };
|
||||
80560000000000000000000C /* BrowserScreenshotTilePlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotTilePlacement.swift; sourceTree = "<group>"; };
|
||||
@@ -6311,6 +6313,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */,
|
||||
D35D00000000000000000002 /* BrowserDesignModeComposerHostingViewTests.swift */,
|
||||
D35D00000000000000000001 /* BrowserDesignModeScreenshotEvaluatorTests.swift */,
|
||||
8827D0010000000000000001 /* BrowserScreenshotCropTests.swift */,
|
||||
B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */,
|
||||
B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */,
|
||||
8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */,
|
||||
@@ -8801,6 +8804,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
C42660050000000000000001 /* BrowserPDFPreviewActionUnitTests.swift in Sources */,
|
||||
C0DE78950000000000000001 /* BrowserPortalFirstRevealScrollTests.swift in Sources */,
|
||||
B6585001B6585001B658PW01 /* BrowserPrewarmedWebViewPoolTests.swift in Sources */,
|
||||
8827C0010000000000000001 /* BrowserScreenshotCropTests.swift in Sources */,
|
||||
C2035A060000000000000001 /* BrowserSSLTrustBypassStateTests.swift in Sources */,
|
||||
C7B800062D4202A13C962D2D /* BrowserSystemProxyMirrorTests.swift in Sources */,
|
||||
805500000000000000000005 /* BrowserValueTextFormatter.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import AppKit
|
||||
import ObjectiveC.runtime
|
||||
import Testing
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct BrowserScreenshotCropTests {
|
||||
private typealias FocusImplementation = @convention(c) (AnyObject, Selector) -> Void
|
||||
private static let imageFocusBackingScaleKey =
|
||||
"cmux.browserScreenshotCropTests.imageFocusBackingScale"
|
||||
private static let imageFocusOverrideInstalled: Bool = {
|
||||
let lockSelector = #selector(NSImage.lockFocus)
|
||||
let unlockSelector = #selector(NSImage.unlockFocus)
|
||||
guard let lockMethod = class_getInstanceMethod(NSImage.self, lockSelector),
|
||||
let unlockMethod = class_getInstanceMethod(NSImage.self, unlockSelector) else {
|
||||
return false
|
||||
}
|
||||
let originalLock = unsafeBitCast(
|
||||
method_getImplementation(lockMethod),
|
||||
to: FocusImplementation.self
|
||||
)
|
||||
let originalUnlock = unsafeBitCast(
|
||||
method_getImplementation(unlockMethod),
|
||||
to: FocusImplementation.self
|
||||
)
|
||||
let lockBlock: @convention(block) (NSImage) -> Void = { image in
|
||||
guard let scale = Thread.current.threadDictionary[imageFocusBackingScaleKey] as? Int else {
|
||||
originalLock(image, lockSelector)
|
||||
return
|
||||
}
|
||||
let size = image.size
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(size.width) * scale,
|
||||
pixelsHigh: Int(size.height) * scale,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
), let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
|
||||
Issue.record("Could not create the controlled image-focus context")
|
||||
originalLock(image, lockSelector)
|
||||
return
|
||||
}
|
||||
bitmap.size = size
|
||||
image.addRepresentation(bitmap)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
}
|
||||
let unlockBlock: @convention(block) (NSImage) -> Void = { image in
|
||||
guard Thread.current.threadDictionary[imageFocusBackingScaleKey] is Int else {
|
||||
originalUnlock(image, unlockSelector)
|
||||
return
|
||||
}
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
}
|
||||
// Keep the replacement IMPs alive for the test process: parallel test
|
||||
// threads may already be executing the forwarding path.
|
||||
method_setImplementation(lockMethod, imp_implementationWithBlock(lockBlock))
|
||||
method_setImplementation(unlockMethod, imp_implementationWithBlock(unlockBlock))
|
||||
return true
|
||||
}()
|
||||
|
||||
@Test
|
||||
func encodedCropUsesOnePixelPerSnapshotCoordinate() throws {
|
||||
let source = try makePatternedBitmapImage()
|
||||
|
||||
let cropped = try withImageFocusBackingScale(2) {
|
||||
try BrowserScreenshotCrop.croppedImage(
|
||||
from: source,
|
||||
selectionInView: NSRect(x: 50, y: 25, width: 100, height: 50),
|
||||
viewBounds: NSRect(x: 0, y: 0, width: 200, height: 150)
|
||||
)
|
||||
}
|
||||
let pngData = try BrowserScreenshotPasteboardWriter.pngData(for: cropped)
|
||||
let bitmap = try #require(NSBitmapImageRep(data: pngData))
|
||||
|
||||
#expect(bitmap.pixelsWide == 200)
|
||||
#expect(bitmap.pixelsHigh == 100)
|
||||
try expectColor(.red, atX: 25, y: 25, in: bitmap)
|
||||
try expectColor(.green, atX: 175, y: 25, in: bitmap)
|
||||
try expectColor(.blue, atX: 25, y: 75, in: bitmap)
|
||||
try expectColor(.yellow, atX: 175, y: 75, in: bitmap)
|
||||
}
|
||||
|
||||
/// Makes the legacy `NSImage.lockFocus()` path deterministically rasterize
|
||||
/// at Retina scale while forwarding unrelated threads to AppKit unchanged.
|
||||
private func withImageFocusBackingScale<T>(
|
||||
_ scale: Int,
|
||||
operation: () throws -> T
|
||||
) throws -> T {
|
||||
guard Self.imageFocusOverrideInstalled else {
|
||||
Issue.record("Could not install the controlled image-focus context")
|
||||
return try operation()
|
||||
}
|
||||
Thread.current.threadDictionary[Self.imageFocusBackingScaleKey] = scale
|
||||
defer {
|
||||
Thread.current.threadDictionary.removeObject(
|
||||
forKey: Self.imageFocusBackingScaleKey
|
||||
)
|
||||
}
|
||||
|
||||
return try operation()
|
||||
}
|
||||
|
||||
private func makePatternedBitmapImage() throws -> NSImage {
|
||||
let width = 400
|
||||
let height = 300
|
||||
let bitmap = try #require(NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: width,
|
||||
pixelsHigh: height,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
))
|
||||
let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = context
|
||||
NSColor.magenta.setFill()
|
||||
NSRect(x: 0, y: 0, width: width, height: height).fill()
|
||||
NSColor.red.setFill()
|
||||
NSRect(x: 100, y: 50, width: 100, height: 50).fill()
|
||||
NSColor.green.setFill()
|
||||
NSRect(x: 200, y: 50, width: 100, height: 50).fill()
|
||||
NSColor.blue.setFill()
|
||||
NSRect(x: 100, y: 100, width: 100, height: 50).fill()
|
||||
NSColor.yellow.setFill()
|
||||
NSRect(x: 200, y: 100, width: 100, height: 50).fill()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
let size = NSSize(width: width, height: height)
|
||||
bitmap.size = size
|
||||
let image = NSImage(size: size)
|
||||
image.addRepresentation(bitmap)
|
||||
return image
|
||||
}
|
||||
|
||||
private func expectColor(
|
||||
_ expected: NSColor,
|
||||
atX x: Int,
|
||||
y: Int,
|
||||
in bitmap: NSBitmapImageRep
|
||||
) throws {
|
||||
let actualRGB = try #require(
|
||||
bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB)
|
||||
)
|
||||
let expectedRGB = try #require(expected.usingColorSpace(.deviceRGB))
|
||||
let tolerance = 0.02
|
||||
|
||||
#expect(abs(actualRGB.redComponent - expectedRGB.redComponent) < tolerance)
|
||||
#expect(abs(actualRGB.greenComponent - expectedRGB.greenComponent) < tolerance)
|
||||
#expect(abs(actualRGB.blueComponent - expectedRGB.blueComponent) < tolerance)
|
||||
#expect(abs(actualRGB.alphaComponent - expectedRGB.alphaComponent) < tolerance)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,6 @@ type DesignRuntime = {
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
imageURL: string,
|
||||
expectedScrollX: number,
|
||||
expectedScrollY: number,
|
||||
expectedViewportWidth: number,
|
||||
@@ -709,7 +708,7 @@ describe("browser design-mode runtime", () => {
|
||||
expect(dom.window.document.querySelector(selector)).toBe(second);
|
||||
});
|
||||
|
||||
test("freehand ink becomes a captured context card only after native capture completes", () => {
|
||||
test("freehand ink becomes a captured context outline only after native capture completes", () => {
|
||||
const { dom, messages, runtime } = fixture(`<main><button id="b">B</button></main>`);
|
||||
const doc = dom.window.document;
|
||||
const at = (name: string, x: number, y: number) => doc.dispatchEvent(
|
||||
@@ -727,8 +726,8 @@ describe("browser design-mode runtime", () => {
|
||||
at("pointermove", 210, 340);
|
||||
at("pointerup", 110, 140);
|
||||
|
||||
// Completion is ink-only: no region token/card exists until the native
|
||||
// screenshot callback returns the exact composited artifact.
|
||||
// Completion is ink-only: no region token/outline exists until the native
|
||||
// screenshot callback returns the captured context artifact.
|
||||
const inkOnly = runtime.composerState();
|
||||
expect(inkOnly.annotation_phase).toBe("ink_only");
|
||||
expect(inkOnly.selection_count).toBe(0);
|
||||
@@ -748,7 +747,6 @@ describe("browser design-mode runtime", () => {
|
||||
0,
|
||||
258,
|
||||
408,
|
||||
"data:image/png;base64,Y2FyZA==",
|
||||
descriptor?.scroll_x ?? 0,
|
||||
descriptor?.scroll_y ?? 0,
|
||||
descriptor?.viewport.width ?? 0,
|
||||
@@ -768,7 +766,7 @@ describe("browser design-mode runtime", () => {
|
||||
expect(runtime.composerState().selection_count).toBe(1);
|
||||
|
||||
// One stroke is one immutable context artifact. A later stroke stacks a
|
||||
// new request/card rather than merging into or replacing the first.
|
||||
// new request/outline rather than merging into or replacing the first.
|
||||
// A deliberate one-axis stroke is still an annotation; it does not need
|
||||
// circle-like width and height to become a context artifact.
|
||||
at("pointerdown", 300, 300);
|
||||
@@ -786,7 +784,6 @@ describe("browser design-mode runtime", () => {
|
||||
252,
|
||||
156,
|
||||
96,
|
||||
"data:image/png;base64,Y2FyZDI=",
|
||||
secondDescriptor?.scroll_x ?? 0,
|
||||
secondDescriptor?.scroll_y ?? 0,
|
||||
secondDescriptor?.viewport.width ?? 0,
|
||||
@@ -804,7 +801,7 @@ describe("browser design-mode runtime", () => {
|
||||
expect(runtime.composerState().selection_count).toBe(0);
|
||||
});
|
||||
|
||||
test("native annotation completion presents the card when an earlier animation frame is stalled", () => {
|
||||
test("native annotation completion presents only an outline over the live page", () => {
|
||||
const { dom, messages, overlayShadowRoot, runtime } = fixture(
|
||||
`<main><button id="b">B</button></main>`,
|
||||
{ stallAnimationFrames: true },
|
||||
@@ -828,32 +825,33 @@ describe("browser design-mode runtime", () => {
|
||||
12,
|
||||
196,
|
||||
196,
|
||||
"data:image/png;base64,Y2FyZA==",
|
||||
descriptor?.scroll_x ?? 0,
|
||||
descriptor?.scroll_y ?? 0,
|
||||
descriptor?.viewport.width ?? 0,
|
||||
descriptor?.viewport.height ?? 0,
|
||||
);
|
||||
|
||||
const card = Array.from(overlayShadowRoot()?.querySelectorAll("div") ?? []).find(
|
||||
(element) => element.style.backgroundImage.includes("Y2FyZA=="),
|
||||
const outline = Array.from(overlayShadowRoot()?.querySelectorAll("div") ?? []).find(
|
||||
(element) => element.style.left === "2px"
|
||||
&& element.style.top === "12px"
|
||||
&& element.style.width === "196px"
|
||||
&& element.style.height === "196px",
|
||||
);
|
||||
expect(card).toBeDefined();
|
||||
expect(card?.style.display).toBe("block");
|
||||
expect(card?.style.left).toBe("2px");
|
||||
expect(card?.style.top).toBe("12px");
|
||||
expect(card?.style.width).toBe("196px");
|
||||
expect(card?.style.height).toBe("196px");
|
||||
expect(card?.style.borderStyle).toBe("dashed");
|
||||
expect(card?.style.borderColor).toBe("rgb(10, 132, 255)");
|
||||
expect(outline).toBeDefined();
|
||||
expect(outline?.style.display).toBe("block");
|
||||
expect(outline?.style.borderStyle).toBe("dashed");
|
||||
expect(outline?.style.borderColor).toBe("rgb(10, 132, 255)");
|
||||
expect(outline?.style.backgroundColor).toBe("transparent");
|
||||
expect(outline?.style.backgroundImage).toBe("");
|
||||
expect(outline?.style.boxShadow).toBe("none");
|
||||
|
||||
runtime.setSelectionHover(completed.selections?.[0]?.selector ?? "");
|
||||
expect(card?.style.boxShadow).toContain("rgba(10, 132, 255, 0.55)");
|
||||
expect(outline?.style.boxShadow).toContain("rgba(10, 132, 255, 0.55)");
|
||||
runtime.setSelectionHover(null);
|
||||
expect(card?.style.boxShadow).not.toContain("rgba(10, 132, 255, 0.55)");
|
||||
expect(outline?.style.boxShadow).toBe("none");
|
||||
});
|
||||
|
||||
test("annotation cards evict the oldest retained image after the bounded stack fills", () => {
|
||||
test("annotation outlines evict the oldest region after the bounded stack fills", () => {
|
||||
const { dom, messages, runtime } = fixture(`<main><button>B</button></main>`);
|
||||
const doc = dom.window.document;
|
||||
const at = (name: string, x: number, y: number) => doc.dispatchEvent(
|
||||
@@ -878,7 +876,6 @@ describe("browser design-mode runtime", () => {
|
||||
0,
|
||||
128,
|
||||
128,
|
||||
`data:image/png;base64,Y2FyZC0${index}`,
|
||||
descriptor?.scroll_x ?? 0,
|
||||
descriptor?.scroll_y ?? 0,
|
||||
descriptor?.viewport.width ?? 0,
|
||||
@@ -942,7 +939,6 @@ describe("browser design-mode runtime", () => {
|
||||
0,
|
||||
188,
|
||||
188,
|
||||
"data:image/png;base64,Y2FyZA==",
|
||||
descriptor?.scroll_x ?? 0,
|
||||
descriptor?.scroll_y ?? 0,
|
||||
descriptor?.viewport.width ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user