crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }