gitbay/Issues/IssueLabel.swift
137 lines · 5247 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 /// True for a label the repository sees through its org (v1.19.0).
14 /// The repository cannot recolour or remove it; `org label` does.
15 /// Absent on the wire for a repository row and on older servers.
16 let org: Bool
17 let issues: Int
18
19 var id: String { name }
20
21 init(name: String, color: String?, org: Bool = false, issues: Int) {
22 self.name = name
23 self.color = color
24 self.org = org
25 self.issues = issues
26 }
27
28 private enum CodingKeys: String, CodingKey {
29 case name, color, org, issues
30 }
31
32 init(from decoder: Decoder) throws {
33 let c = try decoder.container(keyedBy: CodingKeys.self)
34 name = try c.decode(String.self, forKey: .name)
35 color = try c.decodeIfPresent(String.self, forKey: .color)
36 org = try c.decodeIfPresent(Bool.self, forKey: .org) ?? false
37 issues = try c.decode(Int.self, forKey: .issues)
38 }
39}
40
41/// Chip colours for labels, matching what the web paints
42/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
43/// label reads the same on both surfaces.
44nonisolated enum LabelPalette {
45
46 /// Mid-tone hues that stay legible as text on both grounds. Every
47 /// entry already sits inside the window `clamped` holds, so a derived
48 /// colour is a plain lookup and only a stored one is ever adjusted.
49 static let palette = [
50 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
51 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
52 ]
53
54 /// The colour the web would paint for this label: the stored one when
55 /// it is a hex colour, otherwise one picked from the palette by name.
56 static func hex(name: String, stored: String?) -> String {
57 guard let stored, let parsed = parse(stored) else {
58 return palette[Int(fnv1a(name) % UInt32(palette.count))]
59 }
60 return clamped(parsed)
61 }
62
63 /// `#rrggbb` to its three channels, or nil for anything else.
64 private static func parse(_ hex: String) -> (Double, Double, Double)? {
65 guard hex.count == 7, hex.hasPrefix("#") else { return nil }
66 let digits = hex.dropFirst()
67 guard digits.allSatisfy(\.isHexDigit),
68 let value = UInt32(digits, radix: 16) else { return nil }
69 return (
70 Double((value >> 16) & 0xFF) / 255,
71 Double((value >> 8) & 0xFF) / 255,
72 Double(value & 0xFF) / 255
73 )
74 }
75
76 /// Hold relative luminance between 0.12 and 0.28, where a chip clears
77 /// 3:1 against white and against the dark ground alike. The hue is
78 /// kept; the channels are scaled in linear light.
79 private static func clamped(_ rgb: (Double, Double, Double)) -> String {
80 let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
81 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
82 let lo = 0.12, hi = 0.28
83 if y >= lo, y <= hi { return format(r, g, b) }
84 let target = y < lo ? lo : hi
85 guard y != 0 else { return format(target, target, target) }
86 let k = target / y
87 return format(min(1, r * k), min(1, g * k), min(1, b * k))
88 }
89
90 private static func linear(_ c: Double) -> Double {
91 c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
92 }
93
94 private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
95 func encode(_ v: Double) -> Int {
96 let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
97 return Int((s * 255).rounded())
98 }
99 return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
100 }
101
102 /// FNV-1a, 32-bit. The web hashes the name with it to index the
103 /// palette, so this has to agree bit for bit.
104 private static func fnv1a(_ text: String) -> UInt32 {
105 var hash: UInt32 = 2_166_136_261
106 for byte in text.utf8 {
107 hash ^= UInt32(byte)
108 hash &*= 16_777_619
109 }
110 return hash
111 }
112}
113
114/// The chip colour for every label in one repository. Built from one
115/// `label list` read; a label it has never heard of still derives a
116/// colour from its name rather than falling back to grey.
117nonisolated struct LabelColors: Sendable, Hashable {
118
119 private let stored: [String: String]
120
121 init(_ labels: [IssueLabel] = []) {
122 stored = labels.reduce(into: [:]) { map, label in
123 map[label.name] = label.color ?? ""
124 }
125 }
126
127 func hex(_ name: String) -> String {
128 LabelPalette.hex(name: name, stored: stored[name])
129 }
130
131 func color(_ name: String) -> Color {
132 guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
133 return .secondary
134 }
135 return Color(UIColor(hex: value))
136 }
137}