gitbay/Issues/IssueLabel.swift

v1.0.7
gitbay-ios/gitbay/Issues/IssueLabel.swift history · blame · raw

114 lines · 4376 bytes

  1import SwiftUI
  2
  3/// One row of `label list`: the label, the colour it was set to, and how
  4/// many issues carry it.
  5///
  6/// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is
  7/// used throughout this module and a bare `Label` here would shadow it.
  8nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable {
  9    let name: String
 10    /// `#rrggbb` when set, absent otherwise — `label set` normalises to
 11    /// lowercase with the hash, so any other shape never reached the store.
 12    let color: String?
 13    let issues: Int
 14
 15    var id: String { name }
 16}
 17
 18/// Chip colours for labels, matching what the web paints
 19/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
 20/// label reads the same on both surfaces.
 21nonisolated enum LabelPalette {
 22
 23    /// Mid-tone hues that stay legible as text on both grounds. Every
 24    /// entry already sits inside the window `clamped` holds, so a derived
 25    /// colour is a plain lookup and only a stored one is ever adjusted.
 26    static let palette = [
 27        "#0969da", "#1a7f37", "#9a6700", "#cf222e",
 28        "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
 29    ]
 30
 31    /// The colour the web would paint for this label: the stored one when
 32    /// it is a hex colour, otherwise one picked from the palette by name.
 33    static func hex(name: String, stored: String?) -> String {
 34        guard let stored, let parsed = parse(stored) else {
 35            return palette[Int(fnv1a(name) % UInt32(palette.count))]
 36        }
 37        return clamped(parsed)
 38    }
 39
 40    /// `#rrggbb` to its three channels, or nil for anything else.
 41    private static func parse(_ hex: String) -> (Double, Double, Double)? {
 42        guard hex.count == 7, hex.hasPrefix("#") else { return nil }
 43        let digits = hex.dropFirst()
 44        guard digits.allSatisfy(\.isHexDigit),
 45              let value = UInt32(digits, radix: 16) else { return nil }
 46        return (
 47            Double((value >> 16) & 0xFF) / 255,
 48            Double((value >> 8) & 0xFF) / 255,
 49            Double(value & 0xFF) / 255
 50        )
 51    }
 52
 53    /// Hold relative luminance between 0.12 and 0.28, where a chip clears
 54    /// 3:1 against white and against the dark ground alike. The hue is
 55    /// kept; the channels are scaled in linear light.
 56    private static func clamped(_ rgb: (Double, Double, Double)) -> String {
 57        let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
 58        let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
 59        let lo = 0.12, hi = 0.28
 60        if y >= lo, y <= hi { return format(r, g, b) }
 61        let target = y < lo ? lo : hi
 62        guard y != 0 else { return format(target, target, target) }
 63        let k = target / y
 64        return format(min(1, r * k), min(1, g * k), min(1, b * k))
 65    }
 66
 67    private static func linear(_ c: Double) -> Double {
 68        c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
 69    }
 70
 71    private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
 72        func encode(_ v: Double) -> Int {
 73            let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
 74            return Int((s * 255).rounded())
 75        }
 76        return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
 77    }
 78
 79    /// FNV-1a, 32-bit. The web hashes the name with it to index the
 80    /// palette, so this has to agree bit for bit.
 81    private static func fnv1a(_ text: String) -> UInt32 {
 82        var hash: UInt32 = 2_166_136_261
 83        for byte in text.utf8 {
 84            hash ^= UInt32(byte)
 85            hash &*= 16_777_619
 86        }
 87        return hash
 88    }
 89}
 90
 91/// The chip colour for every label in one repository. Built from one
 92/// `label list` read; a label it has never heard of still derives a
 93/// colour from its name rather than falling back to grey.
 94nonisolated struct LabelColors: Sendable, Hashable {
 95
 96    private let stored: [String: String]
 97
 98    init(_ labels: [IssueLabel] = []) {
 99        stored = labels.reduce(into: [:]) { map, label in
100            map[label.name] = label.color ?? ""
101        }
102    }
103
104    func hex(_ name: String) -> String {
105        LabelPalette.hex(name: name, stored: stored[name])
106    }
107
108    func color(_ name: String) -> Color {
109        guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
110            return .secondary
111        }
112        return Color(UIColor(hex: value))
113    }
114}