Files
TUIkit/Sources/TUIkit/Styling/Color.swift
T
phranck e214215610 Refactor: Replace MainActor.assumeIsolated with @preconcurrency Equatable
- Migrate 20 Equatable conformances across 17 files from
  nonisolated + MainActor.assumeIsolated to @preconcurrency Equatable (SE-0423)
- Remove unnecessary import Foundation from 29 source files
- Extract TextFieldHandler clipboard ops into TextFieldHandler+Clipboard.swift
- Extract RenderContext into RenderContext.swift (Renderable.swift 553 -> 279 lines)
- Extract ANSIColor enum into ANSIColor.swift (Color.swift 600 -> 533 lines)
- Add deprecation timeline note for progressBarStyle(_:)
- Migrate test usages from progressBarStyle to trackStyle
2026-02-14 02:10:26 +01:00

534 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 🖥 TUIKit Terminal UI Kit for Swift
// Color.swift
//
// Created by LAYERED.work
// License: MIT
/// A color for use in TUIkit views.
///
/// `Color` represents standard ANSI colors as well as
/// extended 256-color palette and True Color (24-bit RGB).
///
/// # Standard Colors
///
/// ```swift
/// Text("Red").foregroundStyle(.red)
/// Text("Green").foregroundStyle(.green)
/// Text("Blue").foregroundStyle(.blue)
/// ```
///
/// # RGB Colors
///
/// ```swift
/// Text("Custom").foregroundStyle(.rgb(255, 128, 0))
/// ```
public struct Color: Sendable, Equatable {
/// The internal color value.
let value: ColorValue
/// Internal enum for different color types.
enum ColorValue: Sendable, Equatable {
case standard(ANSIColor)
case bright(ANSIColor)
case palette256(UInt8)
case rgb(red: UInt8, green: UInt8, blue: UInt8)
case semantic(SemanticColor)
}
// MARK: - Standard ANSI Colors
/// Black (ANSI 30/40)
public static let black = Self(value: .standard(.black))
/// Red (ANSI 31/41)
public static let red = Self(value: .standard(.red))
/// Green (ANSI 32/42)
public static let green = Self(value: .standard(.green))
/// Yellow (ANSI 33/43)
public static let yellow = Self(value: .standard(.yellow))
/// Blue (ANSI 34/44)
public static let blue = Self(value: .standard(.blue))
/// Magenta (ANSI 35/45)
public static let magenta = Self(value: .standard(.magenta))
/// Cyan (ANSI 36/46)
public static let cyan = Self(value: .standard(.cyan))
/// White (ANSI 37/47)
public static let white = Self(value: .standard(.white))
/// Default color (terminal default)
public static let `default` = Self(value: .standard(.`default`))
// MARK: - Bright ANSI Colors
/// Bright black (gray)
public static let brightBlack = Self(value: .bright(.black))
/// Bright red
public static let brightRed = Self(value: .bright(.red))
/// Bright green
public static let brightGreen = Self(value: .bright(.green))
/// Bright yellow
public static let brightYellow = Self(value: .bright(.yellow))
/// Bright blue
public static let brightBlue = Self(value: .bright(.blue))
/// Bright magenta
public static let brightMagenta = Self(value: .bright(.magenta))
/// Bright cyan
public static let brightCyan = Self(value: .bright(.cyan))
/// Bright white
public static let brightWhite = Self(value: .bright(.white))
// MARK: - Semantic Colors
/// Primary color (default: blue)
public static let primary = Self.blue
/// Secondary color (default: gray)
public static let secondary = Self.brightBlack
/// Accent color (default: cyan)
public static let accent = Self.cyan
/// Warning color
public static let warning = Self.yellow
/// Error color
public static let error = Self.red
/// Success color
public static let success = Self.green
// MARK: - Palette-Aware Semantic Colors
/// Namespace for palette-aware semantic colors.
///
/// These colors are resolved at render time against the current ``Palette``
/// via ``resolve(with:)``. Use them in view `body` properties where no
/// ``RenderContext`` is available:
///
/// ```swift
/// Text("Hello").foregroundStyle(.palette.accent)
/// ```
public enum Semantic {
// Background colors
public static let background = Color(value: .semantic(.background))
public static let statusBarBackground = Color(value: .semantic(.statusBarBackground))
public static let appHeaderBackground = Color(value: .semantic(.appHeaderBackground))
public static let overlayBackground = Color(value: .semantic(.overlayBackground))
// Foreground colors
public static let foreground = Color(value: .semantic(.foreground))
public static let foregroundSecondary = Color(value: .semantic(.foregroundSecondary))
public static let foregroundTertiary = Color(value: .semantic(.foregroundTertiary))
public static let foregroundQuaternary = Color(value: .semantic(.foregroundQuaternary))
// Accent colors
public static let accent = Color(value: .semantic(.accent))
// Status colors
public static let success = Color(value: .semantic(.success))
public static let warning = Color(value: .semantic(.warning))
public static let error = Color(value: .semantic(.error))
public static let info = Color(value: .semantic(.info))
// UI element colors
public static let border = Color(value: .semantic(.border))
}
/// Access palette-aware semantic colors.
///
/// Colors returned by this namespace are not resolved until render time,
/// when the current ``Palette`` is available via ``RenderContext``.
///
/// ```swift
/// Text("Hello").foregroundStyle(.palette.accent)
/// ```
public static var palette: Semantic.Type { Semantic.self }
/// The RGB components of this color.
///
/// Converts any color type to its RGB representation:
/// - `.rgb` returned directly
/// - `.standard` / `.bright` mapped to xterm standard RGB values
/// - `.palette256` mapped to xterm 256-color palette RGB values
/// - `.semantic` returns nil (must be resolved first via ``resolve(with:)``)
public var rgbComponents: (red: UInt8, green: UInt8, blue: UInt8)? {
switch value {
case .rgb(let red, let green, let blue):
return (red, green, blue)
case .standard(let ansi):
return ansi.rgbValues
case .bright(let ansi):
return ansi.brightRGBValues
case .palette256(let index):
return Self.palette256ToRGB(index)
case .semantic:
return nil
}
}
}
// MARK: - Public API
public extension Color {
/// Resolves this color against a palette.
///
/// Non-semantic colors are returned unchanged. Semantic colors
/// are mapped to the corresponding palette property.
///
/// - Parameter palette: The palette to resolve against.
/// - Returns: A concrete (non-semantic) color.
func resolve(with palette: any Palette) -> Color {
guard case .semantic(let token) = value else { return self }
return token.resolve(with: palette)
}
/// Creates a color from the 256-color palette.
///
/// - Parameter index: The palette index (0-255).
/// - Returns: The corresponding color.
static func palette(_ index: UInt8) -> Self {
Self(value: .palette256(index))
}
/// Creates a True Color RGB color.
///
/// - Parameters:
/// - red: The red component (0-255).
/// - green: The green component (0-255).
/// - blue: The blue component (0-255).
/// - Returns: The RGB color.
static func rgb(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> Self {
Self(value: .rgb(red: red, green: green, blue: blue))
}
/// Creates a color from a hex value.
///
/// - Parameter hex: The hex value (e.g., 0xFF5500).
/// - Returns: The corresponding RGB color.
static func hex(_ hex: UInt32) -> Self {
let red = UInt8((hex >> 16) & 0xFF)
let green = UInt8((hex >> 8) & 0xFF)
let blue = UInt8(hex & 0xFF)
return .rgb(red, green, blue)
}
/// Creates a color from a hex string.
///
/// Supports formats: "#RGB", "#RRGGBB", "RGB", "RRGGBB"
///
/// - Parameter hex: The hex string (e.g., "#FF5500", "F50", "#abc").
/// - Returns: The corresponding RGB color, or nil if invalid.
static func hex(_ hex: String) -> Self? {
var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
// Remove # prefix if present
if hexString.hasPrefix("#") {
hexString.removeFirst()
}
// Handle shorthand format (RGB -> RRGGBB)
if hexString.count == 3 {
let chars = Array(hexString)
hexString = String([chars[0], chars[0], chars[1], chars[1], chars[2], chars[2]])
}
// Must be 6 characters now
guard hexString.count == 6 else { return nil }
// Parse hex value
guard let hexValue = UInt32(hexString, radix: 16) else { return nil }
return .hex(hexValue)
}
/// Creates a color from HSL values.
///
/// - Parameters:
/// - hue: The hue component (0-360).
/// - saturation: The saturation component (0-100).
/// - lightness: The lightness component (0-100).
/// - Returns: The corresponding RGB color.
static func hsl(_ hue: Double, _ saturation: Double, _ lightness: Double) -> Self {
let normalizedHue = hue / 360.0
let normalizedSaturation = saturation / 100.0
let normalizedLightness = lightness / 100.0
if normalizedSaturation == 0 {
// Achromatic (gray)
let gray = UInt8(normalizedLightness * 255)
return .rgb(gray, gray, gray)
}
let chromaFactor =
normalizedLightness < 0.5
? normalizedLightness * (1 + normalizedSaturation)
: normalizedLightness + normalizedSaturation - normalizedLightness * normalizedSaturation
let luminanceFactor = 2 * normalizedLightness - chromaFactor
func hueToRGB(_ luminance: Double, _ chroma: Double, _ hueComponent: Double) -> Double {
var adjustedHue = hueComponent
if adjustedHue < 0 { adjustedHue += 1 }
if adjustedHue > 1 { adjustedHue -= 1 }
if adjustedHue < 1 / 6 { return luminance + (chroma - luminance) * 6 * adjustedHue }
if adjustedHue < 1 / 2 { return chroma }
if adjustedHue < 2 / 3 { return luminance + (chroma - luminance) * (2 / 3 - adjustedHue) * 6 }
return luminance
}
let red = UInt8(hueToRGB(luminanceFactor, chromaFactor, normalizedHue + 1 / 3) * 255)
let green = UInt8(hueToRGB(luminanceFactor, chromaFactor, normalizedHue) * 255)
let blue = UInt8(hueToRGB(luminanceFactor, chromaFactor, normalizedHue - 1 / 3) * 255)
return .rgb(red, green, blue)
}
/// Returns a lighter version of this color.
///
/// The percentage is relative to the remaining lightness headroom.
/// For example, a color with HSL lightness 60 lightened by 0.5 (50%)
/// moves halfway toward 100: `60 + (100 60) × 0.5 = 80`.
///
/// - Parameter percentage: The fraction to lighten (01, default 0.2 = 20%).
/// - Returns: A lighter color with preserved hue and saturation.
func lighter(by percentage: Double = 0.2) -> Self {
adjusted(by: percentage)
}
/// Returns a darker version of this color.
///
/// The percentage is relative to the current lightness.
/// For example, a color with HSL lightness 60 darkened by 0.5 (50%)
/// moves halfway toward 0: `60 × (1 0.5) = 30`.
///
/// - Parameter percentage: The fraction to darken (01, default 0.2 = 20%).
/// - Returns: A darker color with preserved hue and saturation.
func darker(by percentage: Double = 0.2) -> Self {
adjusted(by: -percentage)
}
/// Returns a color with adjusted opacity (simulated via color mixing).
///
/// Since terminals don't support true transparency, this mixes
/// the color with black to simulate opacity. Works with all color types
/// by converting to RGB first.
///
/// - Parameter opacity: The opacity (0-1).
/// - Returns: A color simulating the given opacity, or self if semantic.
func opacity(_ opacity: Double) -> Self {
guard let (red, green, blue) = rgbComponents else {
return self
}
let newRed = UInt8(Double(red) * opacity)
let newGreen = UInt8(Double(green) * opacity)
let newBlue = UInt8(Double(blue) * opacity)
return .rgb(newRed, newGreen, newBlue)
}
/// Linearly interpolates between two colors.
///
/// Both colors are converted to RGB before interpolation. If either
/// color is semantic (unresolved), the `from` color is returned unchanged.
///
/// Used by the breathing focus indicator to smoothly fade between
/// a dimmed and a full-brightness accent color.
///
/// - Parameters:
/// - from: The start color (returned when `phase` is 0).
/// - to: The end color (returned when `phase` is 1).
/// - phase: The interpolation factor (01, clamped).
/// - Returns: The interpolated RGB color.
static func lerp(_ from: Color, _ to: Color, phase: Double) -> Color {
guard let fromRGB = from.rgbComponents,
let toRGB = to.rgbComponents
else {
return from
}
let clamped = min(1, max(0, phase))
let red = UInt8(Double(fromRGB.red) + (Double(toRGB.red) - Double(fromRGB.red)) * clamped)
let green = UInt8(
Double(fromRGB.green) + (Double(toRGB.green) - Double(fromRGB.green)) * clamped)
let blue = UInt8(
Double(fromRGB.blue) + (Double(toRGB.blue) - Double(fromRGB.blue)) * clamped)
return .rgb(red, green, blue)
}
}
// MARK: - Internal API
extension Color {
/// Converts RGB components to HSL (hue 0360, saturation 0100, lightness 0100).
///
/// - Parameters:
/// - red: Red component (0255).
/// - green: Green component (0255).
/// - blue: Blue component (0255).
/// - Returns: A tuple of (hue, saturation, lightness) in their standard ranges.
static func rgbToHSL(red: UInt8, green: UInt8, blue: UInt8) -> (hue: Double, saturation: Double, lightness: Double) {
let normalizedRed = Double(red) / 255.0
let normalizedGreen = Double(green) / 255.0
let normalizedBlue = Double(blue) / 255.0
let maxComponent = max(normalizedRed, normalizedGreen, normalizedBlue)
let minComponent = min(normalizedRed, normalizedGreen, normalizedBlue)
let delta = maxComponent - minComponent
let lightness = (maxComponent + minComponent) / 2.0
guard delta > 0 else {
// Achromatic (gray)
return (hue: 0, saturation: 0, lightness: lightness * 100)
}
let saturation: Double
if lightness < 0.5 {
saturation = delta / (maxComponent + minComponent)
} else {
saturation = delta / (2.0 - maxComponent - minComponent)
}
let hue: Double
switch maxComponent {
case normalizedRed:
let segment = (normalizedGreen - normalizedBlue) / delta
hue = 60 * (segment < 0 ? segment + 6 : segment)
case normalizedGreen:
hue = 60 * ((normalizedBlue - normalizedRed) / delta + 2)
default:
hue = 60 * ((normalizedRed - normalizedGreen) / delta + 4)
}
return (hue: hue, saturation: saturation * 100, lightness: lightness * 100)
}
}
// MARK: - Private Helpers
private extension Color {
/// Converts a 256-color palette index to RGB values.
///
/// - Indices 07: standard ANSI colors
/// - Indices 815: bright ANSI colors
/// - Indices 16231: 6×6×6 color cube
/// - Indices 232255: grayscale ramp
static func palette256ToRGB(_ index: UInt8) -> (red: UInt8, green: UInt8, blue: UInt8) {
switch index {
case 0...7:
guard let ansi = ANSIColor(rawValue: index) else { return (0, 0, 0) }
return ansi.rgbValues
case 8...15:
guard let ansi = ANSIColor(rawValue: index - 8) else { return (0, 0, 0) }
return ansi.brightRGBValues
case 16...231:
// 6×6×6 color cube: index = 16 + 36*r + 6*g + b (each 05)
let cubeIndex = index - 16
let cubeRed = cubeIndex / 36
let cubeGreen = (cubeIndex % 36) / 6
let cubeBlue = cubeIndex % 6
let channelMap: [UInt8] = [0, 95, 135, 175, 215, 255]
return (channelMap[Int(cubeRed)], channelMap[Int(cubeGreen)], channelMap[Int(cubeBlue)])
default:
// Grayscale ramp: 232255 8, 18, 28, ..., 238
let gray = UInt8(8 + Int(index - 232) * 10)
return (gray, gray, gray)
}
}
/// Adjusts a color's lightness by a relative percentage in HSL space.
///
/// Positive values lighten (move toward 100), negative values darken
/// (move toward 0). The adjustment is **relative** to the current position:
///
/// - Lighten: `newLightness = lightness + (100 lightness) × percentage`
/// - Darken: `newLightness = lightness × (1 |percentage|)`
///
/// This means 0.5 always moves halfway to the target extreme, regardless
/// of the starting lightness. Hue and saturation are preserved.
///
/// - Parameter percentage: The relative adjustment (1 to 1).
/// - Returns: The adjusted color as HSL, or self if semantic (unresolved).
func adjusted(by percentage: Double) -> Self {
guard let (red, green, blue) = rgbComponents else {
return self
}
let (hue, saturation, lightness) = Self.rgbToHSL(red: red, green: green, blue: blue)
let clamped = min(1.0, max(-1.0, percentage))
let newLightness: Double
if clamped >= 0 {
// Lighten: move toward 100
newLightness = lightness + (100.0 - lightness) * clamped
} else {
// Darken: move toward 0
newLightness = lightness * (1.0 + clamped)
}
return .hsl(hue, saturation, min(100, max(0, newLightness)))
}
}
// MARK: - Foreground Style Environment
/// Environment key for the foreground style.
///
/// When set via `.foregroundStyle(_:)` on any View, this value propagates
/// down through the view hierarchy. Child views can read it from the
/// render context to apply the color.
private struct ForegroundStyleKey: EnvironmentKey {
static let defaultValue: Color? = nil
}
extension EnvironmentValues {
/// The foreground style (color) for text and other content.
///
/// Set via `.foregroundStyle(_:)` modifier on any View.
/// Returns `nil` if not explicitly set (use palette default).
public var foregroundStyle: Color? {
get { self[ForegroundStyleKey.self] }
set { self[ForegroundStyleKey.self] = newValue }
}
}
// MARK: - View Extension for foregroundStyle
extension View {
/// Sets the foreground style for this view and its children.
///
/// The style propagates through the view hierarchy via the environment.
/// Child views that render text or other colored content should read
/// `context.environment.foregroundStyle` and apply it.
///
/// ## Example
///
/// ```swift
/// VStack {
/// Text("Red text")
/// Text("Also red")
/// }
/// .foregroundStyle(.red)
/// ```
///
/// - Parameter style: The color to apply as foreground style.
/// - Returns: A view with the foreground style set.
public func foregroundStyle(_ style: Color?) -> some View {
environment(\.foregroundStyle, style)
}
}