import SwiftUI /// One row of `label list`: the label, the colour it was set to, and how /// many issues carry it. /// /// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is /// used throughout this module and a bare `Label` here would shadow it. nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable { let name: String /// `#rrggbb` when set, absent otherwise — `label set` normalises to /// lowercase with the hash, so any other shape never reached the store. let color: String? let issues: Int var id: String { name } } /// Chip colours for labels, matching what the web paints /// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same /// label reads the same on both surfaces. nonisolated enum LabelPalette { /// Mid-tone hues that stay legible as text on both grounds. Every /// entry already sits inside the window `clamped` holds, so a derived /// colour is a plain lookup and only a stored one is ever adjusted. static let palette = [ "#0969da", "#1a7f37", "#9a6700", "#cf222e", "#8250df", "#b93a86", "#0b6c80", "#bf5b16", ] /// The colour the web would paint for this label: the stored one when /// it is a hex colour, otherwise one picked from the palette by name. static func hex(name: String, stored: String?) -> String { guard let stored, let parsed = parse(stored) else { return palette[Int(fnv1a(name) % UInt32(palette.count))] } return clamped(parsed) } /// `#rrggbb` to its three channels, or nil for anything else. private static func parse(_ hex: String) -> (Double, Double, Double)? { guard hex.count == 7, hex.hasPrefix("#") else { return nil } let digits = hex.dropFirst() guard digits.allSatisfy(\.isHexDigit), let value = UInt32(digits, radix: 16) else { return nil } return ( Double((value >> 16) & 0xFF) / 255, Double((value >> 8) & 0xFF) / 255, Double(value & 0xFF) / 255 ) } /// Hold relative luminance between 0.12 and 0.28, where a chip clears /// 3:1 against white and against the dark ground alike. The hue is /// kept; the channels are scaled in linear light. private static func clamped(_ rgb: (Double, Double, Double)) -> String { let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2)) let y = 0.2126 * r + 0.7152 * g + 0.0722 * b let lo = 0.12, hi = 0.28 if y >= lo, y <= hi { return format(r, g, b) } let target = y < lo ? lo : hi guard y != 0 else { return format(target, target, target) } let k = target / y return format(min(1, r * k), min(1, g * k), min(1, b * k)) } private static func linear(_ c: Double) -> Double { c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) } private static func format(_ r: Double, _ g: Double, _ b: Double) -> String { func encode(_ v: Double) -> Int { let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055 return Int((s * 255).rounded()) } return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b)) } /// FNV-1a, 32-bit. The web hashes the name with it to index the /// palette, so this has to agree bit for bit. private static func fnv1a(_ text: String) -> UInt32 { var hash: UInt32 = 2_166_136_261 for byte in text.utf8 { hash ^= UInt32(byte) hash &*= 16_777_619 } return hash } } /// The chip colour for every label in one repository. Built from one /// `label list` read; a label it has never heard of still derives a /// colour from its name rather than falling back to grey. nonisolated struct LabelColors: Sendable, Hashable { private let stored: [String: String] init(_ labels: [IssueLabel] = []) { stored = labels.reduce(into: [:]) { map, label in map[label.name] = label.color ?? "" } } func hex(_ name: String) -> String { LabelPalette.hex(name: name, stored: stored[name]) } func color(_ name: String) -> Color { guard let value = UInt32(hex(name).dropFirst(), radix: 16) else { return .secondary } return Color(UIColor(hex: value)) } }