~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
149 lines · 5.8 KB Raw History
//
//  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
        )
    }
}