From c9f887bacb6925e2dd859dc5d9f9c7eea9159392 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 18 Sep 2026 12:30:54 +0200 Subject: [PATCH] Added `InlineArray` pattern overloads to the library to optimize for macOS 26. --- .../Graphics/Structures/Pattern.swift | 29 +++++++++++++++++++ .../PlaydateKit/Sprite/Classes/Sprite.swift | 11 +++++++ Tests/PlaydateKit/MockPlaydate.swift | 6 ++++ Tests/PlaydateKit/WrapperTests.swift | 29 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/Sources/PlaydateKit/Graphics/Structures/Pattern.swift b/Sources/PlaydateKit/Graphics/Structures/Pattern.swift index 32b6815..a5838ad 100644 --- a/Sources/PlaydateKit/Graphics/Structures/Pattern.swift +++ b/Sources/PlaydateKit/Graphics/Structures/Pattern.swift @@ -19,3 +19,32 @@ extension Graphics { } } } + +// InlineArray needs macOS 26 on the host, so these conveniences are gated +// there while the tuple API keeps working on older systems. The device and +// Linux have no such restriction. Both representations are 16 contiguous +// bytes, so converting between them is a reinterpretation, not a copy. +@available(macOS 26, *) +extension Graphics.Pattern { + /// Creates a pattern from 8 rows of image data and 8 rows of mask. + public init(bytes: [16 of UInt8]) { + self.init(bytes: unsafeBitCast(bytes, to: Bytes.self)) + } + + /// Creates an opaque pattern from 8 rows of image data. + public init(rows: [8 of UInt8]) { + self.init(bytes: [16 of UInt8] { $0 < 8 ? rows[$0] : 0xff }) + } + + /// The pattern's bytes as an inline array: 8 rows of image data + /// followed by 8 rows of mask. + public var inlineBytes: [16 of UInt8] { + get { unsafeBitCast(bytes, to: [16 of UInt8].self) } + set { bytes = unsafeBitCast(newValue, to: Bytes.self) } + } +} + +extension Graphics.Pattern { + typealias Bytes = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) +} diff --git a/Sources/PlaydateKit/Sprite/Classes/Sprite.swift b/Sources/PlaydateKit/Sprite/Classes/Sprite.swift index 2089237..df551f9 100644 --- a/Sources/PlaydateKit/Sprite/Classes/Sprite.swift +++ b/Sources/PlaydateKit/Sprite/Classes/Sprite.swift @@ -244,6 +244,17 @@ public final class Sprite { } } + /// Sets an 8×8 stencil pattern (8 rows of image data). + @available(macOS 26, *) + public func setStencilPattern(_ rows: [8 of UInt8]) { + // The C side copies the pattern, so passing the array's storage + // directly is safe. + rows.span.withUnsafeBufferPointer { buffer in + spriteAPI.pointee.setStencilPattern.unsafelyUnwrapped( + pointer, UnsafeMutablePointer(mutating: buffer.baseAddress)) + } + } + public func clearStencil() { retainedStencil = nil spriteAPI.pointee.clearStencil.unsafelyUnwrapped(pointer) diff --git a/Tests/PlaydateKit/MockPlaydate.swift b/Tests/PlaydateKit/MockPlaydate.swift index f6b16e2..1c01b4a 100644 --- a/Tests/PlaydateKit/MockPlaydate.swift +++ b/Tests/PlaydateKit/MockPlaydate.swift @@ -36,6 +36,8 @@ enum Mock { nonisolated(unsafe) static var buttonState: (current: UInt32, pushed: UInt32, released: UInt32) = (0, 0, 0) /// The 16 bytes behind the last pattern `LCDColor` seen by a stub. nonisolated(unsafe) static var patternBytes: [UInt8] = [] + /// The 8 rows last handed to `setStencilPattern`. + nonisolated(unsafe) static var stencilRows: [UInt8] = [] /// Caps the bytes a file read returns (0 = end of file, negative = /// error); `nil` fills the whole request. nonisolated(unsafe) static var fileReadLimit: Int32? @@ -90,6 +92,7 @@ enum Mock { events = [] buttonState = (0, 0, 0) patternBytes = [] + stencilRows = [] fileReadLimit = nil tilesPointer = nil tilesCount = 0 @@ -246,6 +249,9 @@ enum Mock { Mock.record("newSprite") return Mock.fakePointer() } + spriteAPI.pointee.setStencilPattern = { _, pattern in + Mock.stencilRows = Array(UnsafeBufferPointer(start: pattern, count: 8)) + } spriteAPI.pointee.freeSprite = { _ in Mock.record("freeSprite") } diff --git a/Tests/PlaydateKit/WrapperTests.swift b/Tests/PlaydateKit/WrapperTests.swift index 6a19984..bcd409d 100644 --- a/Tests/PlaydateKit/WrapperTests.swift +++ b/Tests/PlaydateKit/WrapperTests.swift @@ -64,6 +64,24 @@ struct WrapperTests { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) } + @Test func inlineArrayPatternMatchesTheTupleOne() throws { + guard #available(macOS 26, *) else { return } + // An array literal picks the InlineArray overload; a tuple literal + // still picks the tuple one. + let inline = Graphics.Pattern(rows: [1, 2, 3, 4, 5, 6, 7, 8]) + let tuple = Graphics.Pattern(rows: (1, 2, 3, 4, 5, 6, 7, 8)) + #expect(withUnsafeBytes(of: inline.bytes, Array.init) + == withUnsafeBytes(of: tuple.bytes, Array.init)) + + Graphics.fillRect(x: 0, y: 0, width: 8, height: 8, color: .pattern(inline)) + #expect(Mock.patternBytes == [1, 2, 3, 4, 5, 6, 7, 8, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) + + var pattern = inline + pattern.inlineBytes[8] = 0x0f + #expect(pattern.bytes.8 == 0x0f) + } + @Test func drawTextSendsUTF8BytesAndLength() { let width = Graphics.drawText("Hëllo", x: 4, y: 6) #expect(Mock.events == ["drawText(Hëllo,enc:\(kUTF8Encoding.rawValue),4,6)"]) @@ -121,6 +139,17 @@ struct WrapperTests { #expect(updated == [ObjectIdentifier(sprite)]) } + @Test func spriteStencilPatternOverloadsSendTheSameRows() { + let sprite = Sprite() + sprite.setStencilPattern((1, 2, 3, 4, 5, 6, 7, 8)) + #expect(Mock.stencilRows == [1, 2, 3, 4, 5, 6, 7, 8]) + + guard #available(macOS 26, *) else { return } + Mock.stencilRows = [] + sprite.setStencilPattern([1, 2, 3, 4, 5, 6, 7, 8]) + #expect(Mock.stencilRows == [1, 2, 3, 4, 5, 6, 7, 8]) + } + @Test func spriteIsFreedOnDeinitAndNotWhileReferenced() { var sprite: Sprite? = Sprite() _ = sprite