1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
//
// NickColor.swift
// AT-IRC
//
// Deterministic per-nick colors, following WeeChat's approach: the client
// hashes the nickname and picks a color from a fixed palette, so a nick is
// drawn in the same color everywhere it appears.
//
import SwiftUI
import UIKit
enum NickColor {
/// Maximum number of nicknames remembered by the color cache. Colors are
/// deterministic, so an evicted nick is simply recomputed on its next
/// appearance — eviction never changes a nick's color.
static let cacheLimit = 2000
/// The color for a nickname. Equal (case-insensitively) nicks, including
/// ones that differ only by a suffix such as "nick_away", share a color.
static func color(for nick: String) -> Color {
let key = normalizedNick(nick)
guard !key.isEmpty else { return .primary }
if let cached = cache[key] {
return cached
}
let color = palette[Int(hash(key) % UInt64(palette.count))]
store(color, for: key)
return color
}
/// Palette slot for a nick, or nil when there is nothing to hash.
/// Exposed for tests; `color(for:)` is the normal entry point.
static func paletteIndex(for nick: String) -> Int? {
let key = normalizedNick(nick)
guard !key.isEmpty else { return nil }
return Int(hash(key) % UInt64(palette.count))
}
// MARK: - Normalization and hashing
/// Characters that end the part of a nick used for hashing, matching
/// WeeChat's default `weechat.look.nick_color_stop_chars` (`_`, `|`, `[`).
/// The nick is cut at the first stop character seen after at least one
/// other character, so "nick_123" hashes as "nick" while a leading "|"
/// (as in "|nick|away") is kept.
private static let stopCharacters: Set<Character> = ["_", "|", "["]
static func normalizedNick(_ nick: String) -> String {
var truncated = ""
var sawNonStopCharacter = false
for character in nick {
if stopCharacters.contains(character) {
if sawNonStopCharacter { break }
} else {
sawNonStopCharacter = true
}
truncated.append(character)
}
return truncated.lowercased()
}
/// WeeChat's default `djb2` hash variant, computed over Unicode code
/// points with wrapping 64-bit arithmetic.
static func hash(_ normalizedNick: String) -> UInt64 {
var hash: UInt64 = 5381
for scalar in normalizedNick.unicodeScalars {
hash ^= (hash << 5) &+ (hash >> 2) &+ UInt64(scalar.value)
}
return hash
}
// MARK: - Bounded cache
/// Number of nicks currently cached. Exposed for tests.
static var cachedNickCount: Int { cache.count }
private static var cache: [String: Color] = [:]
/// Insertion order as a ring buffer, so both inserts and evictions stay
/// O(1) even when every incoming message introduces a new nick.
private static var insertionOrder: [String] = []
private static var nextEvictionSlot = 0
private static func store(_ color: Color, for key: String) {
cache[key] = color
if insertionOrder.count < cacheLimit {
insertionOrder.append(key)
} else {
cache.removeValue(forKey: insertionOrder[nextEvictionSlot])
insertionOrder[nextEvictionSlot] = key
nextEvictionSlot = (nextEvictionSlot + 1) % cacheLimit
}
}
// MARK: - Palette
/// WeeChat's default nick colors (`weechat.color.chat_nick_colors`), in
/// the same order so a nick maps to the same palette slot as in WeeChat:
/// red, green, brown, blue, magenta, cyan, lightred, lightblue,
/// lightmagenta, then xterm colors 20, 28, 52, 57, 58, 61, 63, 88, 94,
/// 128, 166 and 202. Each entry is given a light and a dark variant so
/// nick text stays legible on either background.
private static let palette: [Color] = [
adaptive(light: 0xC0392B, dark: 0xFF6B5E), // red
adaptive(light: 0x1E7A34, dark: 0x5BD75B), // green
adaptive(light: 0x8A6D00, dark: 0xD4B106), // brown
adaptive(light: 0x1F5FBF, dark: 0x5B9DFF), // blue
adaptive(light: 0xA21CAF, dark: 0xE36BF0), // magenta
adaptive(light: 0x0E7490, dark: 0x4DD0E1), // cyan
adaptive(light: 0xC43D3D, dark: 0xFF8A80), // lightred
adaptive(light: 0x2563EB, dark: 0x82B1FF), // lightblue
adaptive(light: 0xC026D3, dark: 0xF08CFF), // lightmagenta
adaptive(light: 0x1D4ED8, dark: 0x6D8BFF), // 20 (blue)
adaptive(light: 0x15803D, dark: 0x57D98A), // 28 (green)
adaptive(light: 0x9B1C1C, dark: 0xE57373), // 52 (dark red)
adaptive(light: 0x6D28D9, dark: 0xB388FF), // 57 (violet)
adaptive(light: 0x6B7A00, dark: 0xC6D93B), // 58 (olive)
adaptive(light: 0x4B6A88, dark: 0x8FB3D9), // 61 (blue-gray)
adaptive(light: 0x4338CA, dark: 0x9A8CFF), // 63 (blue-violet)
adaptive(light: 0x7F1D1D, dark: 0xD98C8C), // 88 (maroon)
adaptive(light: 0x92400E, dark: 0xE0A458), // 94 (brown)
adaptive(light: 0x7E22CE, dark: 0xC77DFF), // 128 (purple)
adaptive(light: 0xC2410C, dark: 0xFF9F45), // 166 (orange)
adaptive(light: 0xCC3311, dark: 0xFF6D3B), // 202 (orange-red)
]
/// A color that follows the system appearance without losing its hue.
private static func adaptive(light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in
UIColor(hex: traits.userInterfaceStyle == .dark ? dark : light)
})
}
}
private extension UIColor {
convenience init(hex: UInt32) {
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: 1
)
}
}