From e59c271ac508d6d2a53ce3e709ffb3296565a732 Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 30 Jan 2026 11:34:34 +0100 Subject: [PATCH] feat: Add GeneratedTheme for HSL-derived color palettes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce GeneratedTheme that computes an entire theme palette from a single base hue via HSL transformations. Backgrounds use very low lightness with subtle saturation, foregrounds are bright and saturated, semantic colors are hue-shifted (success +120°, warning +60°, error +180°, info −60°). Add 'Violet' (hue 270°) as the first generated theme in the registry, appearing as the 6th option in theme cycling. --- Sources/TUIKit/Core/Theme.swift | 159 +++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 2 deletions(-) diff --git a/Sources/TUIKit/Core/Theme.swift b/Sources/TUIKit/Core/Theme.swift index c0c0ec0..7ebb2c7 100644 --- a/Sources/TUIKit/Core/Theme.swift +++ b/Sources/TUIKit/Core/Theme.swift @@ -501,19 +501,169 @@ public struct NCursesTheme: Theme { public init() {} } +// MARK: - Generated Theme + +/// A theme that generates its entire color palette from a single base hue. +/// +/// All colors are derived algorithmically using HSL transformations. +/// This allows creating new themes with a single line of code. +/// +/// # How it works +/// +/// Given a base hue (0–360), the theme generates: +/// - **Backgrounds**: very low lightness, subtle saturation tinted toward the hue +/// - **Foregrounds**: high lightness with full saturation at the base hue +/// - **Accents**: slightly lighter/shifted versions of the foreground +/// - **Semantic colors**: hue-shifted variants (success = +120°, warning = +60°, error = +180°, info = −60°) +/// - **UI elements**: mid-range lightness/saturation for borders, selection, status bar +/// +/// # Example +/// +/// ```swift +/// // Create a violet theme +/// let violet = GeneratedTheme(name: "Violet", hue: 270) +/// +/// // Create a teal theme +/// let teal = GeneratedTheme(name: "Teal", hue: 180) +/// ``` +public struct GeneratedTheme: Theme, Sendable { + public let id: String + public let name: String + + // Background hierarchy + public let background: Color + public let backgroundSecondary: Color + public let backgroundTertiary: Color + + // Foreground hierarchy + public let foreground: Color + public let foregroundSecondary: Color + public let foregroundTertiary: Color + + // Accents + public let accent: Color + public let accentSecondary: Color + + // Semantic + public let success: Color + public let warning: Color + public let error: Color + public let info: Color + + // UI elements + public let border: Color + public let borderFocused: Color + public let selection: Color + public let selectionBackground: Color + + // Status bar + public let statusBarBackground: Color + public let statusBarForeground: Color + public let statusBarHighlight: Color + + // Container (block appearance) + public let containerBackground: Color + public let containerHeaderBackground: Color + public let buttonBackground: Color + + /// Creates a generated theme from a single base hue. + /// + /// - Parameters: + /// - name: The display name for the theme. + /// - hue: The base hue (0–360). Examples: red=0, green=120, blue=240, violet=270. + /// - saturation: The base saturation (0–100, default: 100). Lower values produce more muted palettes. + public init(name: String, hue: Double, saturation: Double = 100) { + self.id = "generated-\(name.lowercased())" + self.name = name + + let baseSaturation = min(100, max(0, saturation)) + + // --- Backgrounds: very dark, subtly tinted --- + // App background — near black with a hint of the hue + self.background = Color.hsl(hue, baseSaturation * 0.30, 3) + // Container body — slightly brighter + self.backgroundSecondary = Color.hsl(hue, baseSaturation * 0.40, 10) + // Header/footer — between app and body + self.backgroundTertiary = Color.hsl(hue, baseSaturation * 0.35, 7) + + // --- Foregrounds: bright, saturated text --- + self.foreground = Color.hsl(hue, baseSaturation * 0.80, 70) + self.foregroundSecondary = Color.hsl(hue, baseSaturation * 0.70, 55) + self.foregroundTertiary = Color.hsl(hue, baseSaturation * 0.60, 40) + + // --- Accents: lighter/brighter variant --- + self.accent = Color.hsl(hue, baseSaturation * 0.85, 78) + self.accentSecondary = Color.hsl(hue, baseSaturation * 0.75, 50) + + // --- Semantic: hue-shifted from base --- + // success = base + 120° (toward green family) + let successHue = GeneratedTheme.wrapHue(hue + 120) + self.success = Color.hsl(successHue, baseSaturation * 0.70, 65) + + // warning = base + 60° (toward yellow family) + let warningHue = GeneratedTheme.wrapHue(hue + 60) + self.warning = Color.hsl(warningHue, baseSaturation * 0.80, 70) + + // error = base + 180° (complementary) + let errorHue = GeneratedTheme.wrapHue(hue + 180) + self.error = Color.hsl(errorHue, baseSaturation * 0.85, 65) + + // info = base − 60° (analogous cool side) + let infoHue = GeneratedTheme.wrapHue(hue - 60) + self.info = Color.hsl(infoHue, baseSaturation * 0.70, 70) + + // --- UI elements --- + self.border = Color.hsl(hue, baseSaturation * 0.40, 25) + self.borderFocused = Color.hsl(hue, baseSaturation * 0.80, 70) + self.selection = Color.hsl(hue, baseSaturation * 0.85, 78) + self.selectionBackground = Color.hsl(hue, baseSaturation * 0.50, 18) + + // --- Status bar --- + self.statusBarBackground = Color.hsl(hue, baseSaturation * 0.35, 8) + self.statusBarForeground = Color.hsl(hue, baseSaturation * 0.75, 65) + self.statusBarHighlight = Color.hsl(hue, baseSaturation * 0.85, 78) + + // --- Container (block appearance) --- + self.containerBackground = Color.hsl(hue, baseSaturation * 0.40, 10) + self.containerHeaderBackground = Color.hsl(hue, baseSaturation * 0.35, 7) + self.buttonBackground = Color.hsl(hue, baseSaturation * 0.45, 15) + } + + /// Wraps a hue value to the 0–360 range. + private static func wrapHue(_ hue: Double) -> Double { + var wrapped = hue.truncatingRemainder(dividingBy: 360) + if wrapped < 0 { wrapped += 360 } + return wrapped + } + + // MARK: - Preset Hues + + /// Predefined hue values for common generated themes. + public enum Hue { + /// Violet hue (270°). + public static let violet: Double = 270 + } + + // MARK: - Presets + + /// A violet generated theme. + public static let violet = GeneratedTheme(name: "Violet", hue: Hue.violet) +} + // MARK: - Theme Registry /// Registry of available themes. public struct ThemeRegistry { /// All available themes in cycling order. /// - /// Order: Green → Amber → White → Red → NCurses + /// Order: Green → Amber → White → Red → NCurses → Violet (generated) public static let all: [Theme] = [ GreenPhosphorTheme(), AmberPhosphorTheme(), WhitePhosphorTheme(), RedPhosphorTheme(), - NCursesTheme() + NCursesTheme(), + GeneratedTheme.violet ] /// Finds a theme by ID. @@ -564,6 +714,11 @@ extension Theme where Self == NCursesTheme { public static var ncurses: NCursesTheme { NCursesTheme() } } +extension Theme where Self == GeneratedTheme { + /// Violet generated theme (hue 270°). + public static var violet: GeneratedTheme { GeneratedTheme.violet } +} + // MARK: - Theme Manager /// Manages theme cycling for the application.