diff --git a/README.md b/README.md index 5771545..53e03df 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,18 @@ The CodeMirror decoration and statistics interaction patterns are adapted from [ `DisplayList.description` prints identity effects, empty property effects, and platform effects with the same empty `(effect …)` form, while `minimalDescription` encodes them differently. Because the source text has already discarded that distinction, the converter renders an empty effect as identity and calls out the ambiguity in the interface. +### Mask input subtrees + +SwiftUI prints the mask input DisplayList recursively in `description`, but prints only `M` +for the mask effect in `minimalDescription`. The items that follow `M` inside `E` are the masked +content, not the mask input. The converter follows this format. For example, identities repeated +only inside two mask inputs will not appear in the compact output or its item statistics. + +The `M` source mapping covers the full `(mask …)` expression, including its input subtree. +Inspect that source range to check mask identities. Reverse conversion uses `(mask *)`, because +the compact encoding does not contain the input tree. Shared links also retain only the compact +encoding, so use the full description when investigating repeated identities. + ## Local development Prerequisites: diff --git a/Sources/DisplayListDescription/EncodingReference.swift b/Sources/DisplayListDescription/EncodingReference.swift index 3e53626..dd7d945 100644 --- a/Sources/DisplayListDescription/EncodingReference.swift +++ b/Sources/DisplayListDescription/EncodingReference.swift @@ -49,7 +49,7 @@ public struct DisplayListEncodingReference: Equatable, Sendable { .init(id: "effect.opacity", token: "O", name: "Opacity", detail: "An opacity effect; the numeric value is omitted.", category: .effect), .init(id: "effect.blend-mode", token: "B", name: "Blend mode", detail: "A blend-mode effect. B is context-dependent: inside E it is not backdrop content.", category: .effect), .init(id: "effect.clip", token: "C", name: "Clip", detail: "A clipping path and style. C is context-dependent: inside E it is not color content.", category: .effect), - .init(id: "effect.mask", token: "M", name: "Mask", detail: "A mask effect.", category: .effect), + .init(id: "effect.mask", token: "M", name: "Mask", detail: "The mask input subtree and its item identities are omitted. Following items in E are the masked content. Inspect the full source to check mask identities.", category: .effect), .init(id: "effect.transform", token: "T", name: "Transform", detail: "A geometry transform. T is context-dependent: inside E it is not text content.", category: .effect), .init(id: "effect.filter", token: "F", name: "Graphics filter", detail: "Any graphics filter; the specific filter parameters are omitted.", category: .effect), .init(id: "effect.animation", token: "AN", name: "Animation", detail: "An animation effect.", category: .effect), diff --git a/Tests/DisplayListDescriptionTests/DisplayListDescriptionConverterTests.swift b/Tests/DisplayListDescriptionTests/DisplayListDescriptionConverterTests.swift index 4926d0b..b77b129 100644 --- a/Tests/DisplayListDescriptionTests/DisplayListDescriptionConverterTests.swift +++ b/Tests/DisplayListDescriptionTests/DisplayListDescriptionConverterTests.swift @@ -146,6 +146,58 @@ final class DisplayListDescriptionConverterTests: XCTestCase { XCTAssertTrue(result.usedEncodingIDs.contains("effect.view")) } + func testMaskInputsAreOmittedButRemainInSourceMappings() throws { + // The full printer can place options before or after the mask input items. + for optionsFirst in [true, false] { + let options = "(options ClipOptions(rawValue: 0))" + func maskedItem(identity: Int, maskIdentity: Int, contentIdentity: Int) -> String { + """ + (item #:identity \(identity) #:version 1 + (effect + (mask + \(optionsFirst ? options : "") + (item #:identity \(maskIdentity) #:version 1 + (effect + (item #:identity 815 #:version 1 + (effect #:blend-mode normal + (item #:identity 814 #:version 1 (content-seed 1) (shape *)))) + (item #:identity 816 #:version 1 + (effect (transform *) + (item #:identity 817 #:version 1 + (effect (transform *) + (item #:identity 819 #:version 1 + (effect #:blend-mode normal + (item #:identity 818 #:version 1 (content-seed 1) (shape *)))))))))) + \(optionsFirst ? "" : options)) + (item #:identity \(contentIdentity) #:version 1 (content-seed 1) (color *)))) + """ + } + let description = """ + (display-list + \(maskedItem(identity: 823, maskIdentity: 820, contentIdentity: 821)) + \(maskedItem(identity: 834, maskIdentity: 831, contentIdentity: 832))) + """ + + let result = try DisplayListDescriptionConverter.convert(description) + + XCTAssertEqual(result.minimalDescription, "(DL(I:823(E M(I:821 C)))(I:834(E M(I:832 C))))") + XCTAssertEqual(result.spans.filter { $0.encodingID == "structure.item" }.count, 4) + let masks = result.spans.filter { $0.encodingID == "effect.mask" } + XCTAssertEqual(masks.count, 2) + for mask in masks { + let source = utf16Slice(description, mask.sourceStart, mask.sourceEnd) + XCTAssertTrue(source.hasPrefix("(mask")) + XCTAssertTrue(source.contains(options)) + for identity in 814...819 { + XCTAssertTrue(source.contains("#:identity \(identity) ")) + } + XCTAssertFalse(source.contains("(color")) + XCTAssertEqual(utf16Slice(result.minimalDescription, mask.outputStart, mask.outputEnd), "M") + } + XCTAssertEqual(Set(masks.map(\.occurrenceID)).count, 2) + } + } + func testConvertsStates() throws { let description = """ (display-list diff --git a/Tests/DisplayListDescriptionTests/DisplayListMinimalDescriptionConverterTests.swift b/Tests/DisplayListDescriptionTests/DisplayListMinimalDescriptionConverterTests.swift index b698409..fa7db41 100644 --- a/Tests/DisplayListDescriptionTests/DisplayListMinimalDescriptionConverterTests.swift +++ b/Tests/DisplayListDescriptionTests/DisplayListMinimalDescriptionConverterTests.swift @@ -63,6 +63,21 @@ final class DisplayListMinimalDescriptionConverterTests: XCTestCase { ) } + func testReconstructsMaskInputAsUnknownAndPreservesMaskedContent() throws { + let minimalDescription = "(DL(I:823(E M(I:821 C)))(I:834(E M(I:832 C))))" + + let result = try DisplayListMinimalDescriptionConverter.convert(minimalDescription) + + XCTAssertEqual(result.description.components(separatedBy: "(mask *)").count - 1, 2) + XCTAssertEqual(result.description.components(separatedBy: "(item ").count - 1, 4) + XCTAssertTrue(result.description.contains("#:identity 821 ")) + XCTAssertTrue(result.description.contains("#:identity 832 ")) + XCTAssertEqual( + try DisplayListDescriptionConverter.convert(result.description).minimalDescription, + minimalDescription + ) + } + func testRejectsNonMinimalDescriptionRoot() { XCTAssertThrowsError(try DisplayListMinimalDescriptionConverter.convert("(display-list)")) { error in XCTAssertEqual(