// A stable, well-distributed color per profile name, shared by the menu // bar dots and the Manage window's profile list so the same name always // reads as the same color everywhere. import AppKit enum ProfileColor { // FNV-1a: a proper avalanching hash, not a preset-palette lookup — the // full hue wheel is available, and single-character name edits (e.g. // "personal" vs "personal2") land on unrelated hues instead of an // adjacent bucket the way a plain sum-of-scalars hash mod'd into a // short color list would. private static func fnv1aHash(_ s: String) -> UInt32 { var hash: UInt32 = 0x811c_9dc5 for byte in s.utf8 { hash ^= UInt32(byte) hash = hash &* 0x0100_0193 } return hash } // A brightness/saturation pair that reads clearly on its own // background — a color bright enough to pop on a dark menu would wash // out on a light one, and vice versa, so this picks per current // appearance rather than using one fixed value for both. private static func isDarkAppearance() -> Bool { NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } static func dotColor(for name: String) -> NSColor { let hue = CGFloat(fnv1aHash(name) % 360) / 360.0 let (saturation, brightness): (CGFloat, CGFloat) = isDarkAppearance() ? (0.90, 1.0) : (1.0, 0.55) return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) } // Light or dark text, whichever reads better on the given background. // Profile colours come off the full hue wheel (see `dotColor`), whose // luminance varies enormously — yellow against white text is barely // legible where indigo against black text is worse — so this can't be // one fixed choice. // // Chooses on WCAG relative luminance rather than raw brightness, // because the eye is roughly six times more sensitive to green than to // blue and an unweighted average badly misjudges both. 0.179 is where // contrast against white and against black come out equal, so it's the // threshold that maximises contrast either way. Near-white and // near-black rather than pure, which reads as less harsh at 10pt // without measurably costing contrast. static func contrastingTextColor(on background: NSColor) -> NSColor { guard let rgb = background.usingColorSpace(.sRGB) else { return .white } func linear(_ channel: CGFloat) -> CGFloat { channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) } let luminance = 0.2126 * linear(rgb.redComponent) + 0.7152 * linear(rgb.greenComponent) + 0.0722 * linear(rgb.blueComponent) return luminance > 0.179 ? NSColor(white: 0.10, alpha: 1) : NSColor(white: 0.98, alpha: 1) } // `dimmed` marks a profile that isn't actually running right now — the // colored dot still identifies it, but faded, so "open" vs "known but // closed" is visible without reading a tooltip. static func dotImage(for name: String, dimmed: Bool = false) -> NSImage { let size = NSSize(width: 10, height: 10) let image = NSImage(size: size) image.lockFocus() dotColor(for: name).withAlphaComponent(dimmed ? 0.3 : 1.0).setFill() NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill() image.unlockFocus() image.isTemplate = false return image } }