DecorationImages.swift (4851B)
1 import CoreGraphics 2 import ImageIO 3 import SwiftUI 4 5 /// Decodes `data=` decoration payloads into drawable images. 6 /// 7 /// Kept out of the draw path deliberately: `GridView.body` re-runs on every 8 /// keystroke and cursor move, so decoding there would repeat a base64 decode 9 /// and a PNG decode per tile per frame. Callers resolve this once per puzzle 10 /// and hold the result in view state. 11 /// 12 /// Keyed by payload rather than by position so identical tiles — a repeated 13 /// motif in grid art, say — decode once and share. 14 enum DecorationImages { 15 static func decode(_ decorations: [GridPosition: [Puzzle.Decoration]]) -> [String: Image] { 16 // Force the pixel decode to happen here rather than at first draw. 17 // 18 // ImageIO defers decoding by default — `kCGImageSourceShouldCacheImmediately` 19 // is documented as "the default value is kCFBooleanFalse (image decoding 20 // will happen at rendering time)". For after-solve tiles, "first draw" 21 // is the instant the puzzle completes and the reveal appears alongside 22 // the success banner, so the deferred work would land at the one moment 23 // worth keeping smooth. Decoding here moves it to puzzle load instead. 24 // 25 // The decoded form is then retained: `kCGImageSourceShouldCache` 26 // defaults to true on 64-bit, so this is a one-off cost, not a per-draw 27 // one. (Local rather than a static because `CFDictionary` isn't 28 // `Sendable`.) 29 let decodeOptions = [ 30 kCGImageSourceShouldCacheImmediately: kCFBooleanTrue 31 ] as CFDictionary 32 33 var result: [String: Image] = [:] 34 for layers in decorations.values { 35 for layer in layers { 36 guard case .data(let mimeType, let encoding, let payload) = layer.content else { 37 continue 38 } 39 // The format allows any mime type and encoding; we draw the one 40 // combination converters produce. Anything else is left undrawn 41 // rather than guessed at. 42 guard encoding.caseInsensitiveCompare("base64") == .orderedSame, 43 mimeType.lowercased().hasPrefix("image/"), 44 result[payload] == nil else { 45 continue 46 } 47 guard let data = Data(base64Encoded: payload), 48 let source = CGImageSourceCreateWithData(data as CFData, nil), 49 let image = CGImageSourceCreateImageAtIndex(source, 0, decodeOptions) else { 50 continue 51 } 52 result[payload] = Image(decorative: image, scale: 1) 53 } 54 } 55 return result 56 } 57 } 58 59 extension Puzzle.Decoration { 60 /// Whether this layer is visible given whether the puzzle has been solved. 61 /// `before` layers are always visible; `after` layers are the reveal. 62 func isVisible(solved: Bool) -> Bool { 63 switch phase { 64 case .before: return true 65 case .after: return solved 66 } 67 } 68 } 69 70 extension Puzzle.Decoration.Content { 71 /// Resolves a `#RGB` / `#RRGGBB` / `#RRGGBBAA` value. Returns nil for 72 /// anything else, so an unparseable colour is skipped rather than drawn as 73 /// an arbitrary fallback that would look deliberate. 74 static func color(fromHex value: String) -> Color? { 75 var hex = value.trimmingCharacters(in: .whitespaces) 76 if hex.hasPrefix("#") { hex.removeFirst() } 77 guard hex.allSatisfy(\.isHexDigit) else { return nil } 78 79 let components: [Double] 80 switch hex.count { 81 case 3: 82 components = hex.compactMap { UInt8(String($0), radix: 16) } 83 .map { Double($0) * 17 / 255 } 84 case 6, 8: 85 components = stride(from: 0, to: hex.count, by: 2).compactMap { offset in 86 let start = hex.index(hex.startIndex, offsetBy: offset) 87 let end = hex.index(start, offsetBy: 2) 88 return UInt8(hex[start..<end], radix: 16).map { Double($0) / 255 } 89 } 90 default: 91 return nil 92 } 93 guard components.count == max(hex.count / 2, 3) else { return nil } 94 return Color( 95 .sRGB, 96 red: components[0], 97 green: components[1], 98 blue: components[2], 99 opacity: components.count == 4 ? components[3] : 1 100 ) 101 } 102 } 103 104 extension Puzzle.Decoration.Content { 105 /// Resolves a `bg=` / `fg=` value for the current appearance. A layer with 106 /// no dark variant uses its single colour in both. 107 static func color( 108 light: String, 109 dark: String?, 110 for colorScheme: SwiftUI.ColorScheme 111 ) -> Color? { 112 let hex = (colorScheme == .dark ? dark : nil) ?? light 113 return color(fromHex: hex) 114 } 115 }