Commit 60019a10fc
Verified · cmc
Layout: unified · split
gitbay/Issues/IssueLabel.swift added +114
| @@ -0,0 +1,114 @@ | |||
| 1 | import 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. | ||
| 8 | nonisolated 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. | ||
| 21 | nonisolated 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. | ||
| 94 | nonisolated 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 | } | ||
gitbayTests/LabelTests.swift added +100
| @@ -0,0 +1,100 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | struct LabelPaletteTests { | ||
| 6 | |||
| 7 | // Golden values computed from the web's own algorithm: FNV-1a 32 of | ||
| 8 | // the name, modulo the eight-colour palette. If these drift, the two | ||
| 9 | // surfaces paint the same label differently. | ||
| 10 | @Test func derivesAStableColourFromTheName() { | ||
| 11 | #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86") | ||
| 12 | #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37") | ||
| 13 | #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700") | ||
| 14 | #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e") | ||
| 15 | #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16") | ||
| 16 | } | ||
| 17 | |||
| 18 | @Test func theSameNameAlwaysDerivesTheSameColour() { | ||
| 19 | #expect(LabelPalette.hex(name: "bug", stored: nil) | ||
| 20 | == LabelPalette.hex(name: "bug", stored: nil)) | ||
| 21 | } | ||
| 22 | |||
| 23 | /// A stored colour already inside the luminance window is used as-is. | ||
| 24 | @Test func keepsAStoredColourThatIsAlreadyLegible() { | ||
| 25 | #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000") | ||
| 26 | #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da") | ||
| 27 | } | ||
| 28 | |||
| 29 | /// Too dark or too bright is scaled back into the window, so a chip | ||
| 30 | /// stays readable as text on both grounds. | ||
| 31 | @Test func clampsAStoredColourOutsideTheWindow() { | ||
| 32 | #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161") | ||
| 33 | #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090") | ||
| 34 | } | ||
| 35 | |||
| 36 | @Test func upperCaseStoredColoursNormaliseDown() { | ||
| 37 | #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000") | ||
| 38 | } | ||
| 39 | |||
| 40 | /// `label set` stores `#rrggbb`. Anything else — an empty string, a | ||
| 41 | /// bare six digits, a name — never reached the store as a colour, so | ||
| 42 | /// it is treated as unset and the name derives one. | ||
| 43 | @Test func anythingThatIsNotAHexColourFallsBackToTheName() { | ||
| 44 | let derived = LabelPalette.hex(name: "bug", stored: nil) | ||
| 45 | #expect(LabelPalette.hex(name: "bug", stored: "") == derived) | ||
| 46 | #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived) | ||
| 47 | #expect(LabelPalette.hex(name: "bug", stored: "red") == derived) | ||
| 48 | #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived) | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func everyPaletteEntryIsAlreadyLegible() { | ||
| 52 | // No palette colour is ever clamped, so a derived colour is a | ||
| 53 | // plain lookup. If this fails the palette drifted from the web's. | ||
| 54 | for hex in LabelPalette.palette { | ||
| 55 | #expect(LabelPalette.hex(name: "x", stored: hex) == hex) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | struct IssueLabelDecodingTests { | ||
| 61 | |||
| 62 | @Test func decodesAListRow() throws { | ||
| 63 | let json = Data(""" | ||
| 64 | [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}] | ||
| 65 | """.utf8) | ||
| 66 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) | ||
| 67 | |||
| 68 | #expect(labels.count == 2) | ||
| 69 | #expect(labels[0].name == "bug") | ||
| 70 | #expect(labels[0].color == "#cf222e") | ||
| 71 | #expect(labels[0].issues == 18) | ||
| 72 | // `color` is omitempty on the wire — absent, not null, when unset. | ||
| 73 | #expect(labels[1].color == nil) | ||
| 74 | #expect(labels[1].issues == 11) | ||
| 75 | } | ||
| 76 | |||
| 77 | @Test func theIdIsTheName() throws { | ||
| 78 | let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) | ||
| 79 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) | ||
| 80 | #expect(labels[0].id == "bug") | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | struct LabelColorsTests { | ||
| 85 | |||
| 86 | @Test func looksUpAStoredColour() { | ||
| 87 | let colors = LabelColors([ | ||
| 88 | IssueLabel(name: "bug", color: "#cf222e", issues: 1), | ||
| 89 | IssueLabel(name: "ci", color: nil, issues: 2), | ||
| 90 | ]) | ||
| 91 | #expect(colors.hex("bug") == "#cf222e") | ||
| 92 | #expect(colors.hex("ci") == "#1a7f37") | ||
| 93 | } | ||
| 94 | |||
| 95 | /// An issue can carry a label the map has not loaded — a stale list, or | ||
| 96 | /// a label added since. It still derives a colour rather than going grey. | ||
| 97 | @Test func anUnknownLabelStillDerivesItsColour() { | ||
| 98 | #expect(LabelColors().hex("docs") == "#9a6700") | ||
| 99 | } | ||
| 100 | } | ||