// // 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 = ["_", "|", "["] 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 ) } }