From db03e4e41c5f1e8befb007bc43c21947c1943a5a Mon Sep 17 00:00:00 2001 From: phranck Date: Tue, 3 Feb 2026 18:11:39 +0100 Subject: [PATCH] =?UTF-8?q?Refactor:=20HSL-based=20color=20system=20for=20?= =?UTF-8?q?all=20palettes=20=E2=80=94=20lighter/darker=20preserve=20hue,?= =?UTF-8?q?=20unified=20baseHue=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/TUIkit/Styling/Color.swift | 63 ++++++++++++--- .../Styling/Palettes/AmberPalette.swift | 74 ++++++++++++----- .../TUIkit/Styling/Palettes/BluePalette.swift | 72 +++++++++++++---- .../Styling/Palettes/GreenPalette.swift | 70 ++++++++++++---- .../TUIkit/Styling/Palettes/RedPalette.swift | 69 ++++++++++++---- .../Styling/Palettes/VioletPalette.swift | 2 +- .../Styling/Palettes/WhitePalette.swift | 64 +++++++++++---- Sources/TUIkit/Styling/Theme.swift | 4 +- Sources/TUIkit/Views/ContainerView.swift | 30 ++++--- Sources/TUIkitExample/ContentView.swift | 6 +- .../TUIkitExample/Pages/BlockThemePage.swift | 81 +++++++++++++++++++ .../TUIkitExample/Pages/MainMenuPage.swift | 1 + 12 files changed, 430 insertions(+), 106 deletions(-) create mode 100644 Sources/TUIkitExample/Pages/BlockThemePage.swift diff --git a/Sources/TUIkit/Styling/Color.swift b/Sources/TUIkit/Styling/Color.swift index db07cc51..f7e35a49 100644 --- a/Sources/TUIkit/Styling/Color.swift +++ b/Sources/TUIkit/Styling/Color.swift @@ -347,25 +347,68 @@ public struct Color: Sendable, Equatable { } } - /// Adjusts brightness by a signed amount. + /// Adjusts a color's lightness by the given amount in HSL space. /// - /// Positive values lighten, negative values darken. Works with all color types - /// (ANSI, 256-palette, RGB) by converting to RGB first. The result is always - /// an RGB color. + /// Positive values lighten, negative values darken. Converts to HSL first, + /// adjusts only the lightness component, then converts back to RGB. + /// This preserves hue and saturation, preventing colors from shifting + /// toward gray when lightened or darkened. /// - /// - Parameter amount: The adjustment amount (-1 to 1). + /// - Parameter amount: The lightness adjustment (-1 to 1). /// - Returns: The adjusted color as RGB, or self if semantic (unresolved). private func adjusted(by amount: Double) -> Self { guard let (red, green, blue) = rgbComponents else { return self } - let shift = 255 * amount - let newRed = UInt8(min(255, max(0, Double(red) + shift))) - let newGreen = UInt8(min(255, max(0, Double(green) + shift))) - let newBlue = UInt8(min(255, max(0, Double(blue) + shift))) + let (hue, saturation, lightness) = Self.rgbToHSL(red: red, green: green, blue: blue) + let newLightness = min(100, max(0, lightness + amount * 100)) - return .rgb(newRed, newGreen, newBlue) + return .hsl(hue, saturation, newLightness) + } + + /// Converts RGB components to HSL (hue 0–360, saturation 0–100, lightness 0–100). + /// + /// - Parameters: + /// - red: Red component (0–255). + /// - green: Green component (0–255). + /// - blue: Blue component (0–255). + /// - 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) } /// Returns a color with adjusted opacity (simulated via color mixing). diff --git a/Sources/TUIkit/Styling/Palettes/AmberPalette.swift b/Sources/TUIkit/Styling/Palettes/AmberPalette.swift index 7221abce..0c4dc541 100644 --- a/Sources/TUIkit/Styling/Palettes/AmberPalette.swift +++ b/Sources/TUIkit/Styling/Palettes/AmberPalette.swift @@ -8,37 +8,75 @@ /// Classic amber terminal palette (P3 phosphor). /// /// Inspired by terminals like the IBM 3278 and Wyse 50. -/// Uses a dark background with subtle amber/orange tint. +/// All colors are generated algorithmically from a single base hue (40°) +/// using HSL transformations. public struct AmberPalette: BlockPalette { public let id = "amber" public let name = "Amber" - // Background - public let background = Color.hex(0x0A0706) + /// The base hue used to generate all palette colors. + private static let baseHue: Double = 40 - // Amber text hierarchy (matching Spotnik) - public let foreground = Color.hex(0xFFAA00) // Bright amber - primary text - public let foregroundSecondary = Color.hex(0xCC8800) // Medium amber - secondary text - public let foregroundTertiary = Color.hex(0x8F6600) // Dim amber - tertiary/muted text + // Background + public let background: Color + + // Amber text hierarchy + public let foreground: Color + public let foregroundSecondary: Color + public let foregroundTertiary: Color // Accent - public let accent = Color.hex(0xFFCC33) // Lighter amber for highlights + public let accent: Color - // Semantic colors (stay in amber family) - public let success = Color.hex(0xFFCC00) - public let warning = Color.hex(0xFFE066) // Light amber - public let error = Color.hex(0xFF6633) // Orange-red (contrast) - public let info = Color.hex(0xFFD966) // Light amber + // Semantic colors + public let success: Color + public let warning: Color + public let error: Color + public let info: Color // UI elements - public let border = Color.hex(0x5A4A2D) // Subtle amber border + public let border: Color // Additional backgrounds - public let statusBarBackground = Color.hex(0x191613) - public let appHeaderBackground = Color.hex(0x1E110E) - public let overlayBackground = Color.hex(0x0A0706) + public let statusBarBackground: Color + public let appHeaderBackground: Color + public let overlayBackground: Color - public init() {} + public init() { + let hue = Self.baseHue + + // Background: very dark, subtly tinted + self.background = Color.hsl(hue, 30, 3) + + // Foregrounds: bright, saturated text + self.foreground = Color.hsl(hue, 100, 50) + self.foregroundSecondary = Color.hsl(hue, 100, 40) + self.foregroundTertiary = Color.hsl(hue, 100, 28) + + // Accent: lighter/brighter variant + self.accent = Color.hsl(hue + 5, 100, 60) + + // Semantic: hue-shifted from base + self.success = Color.hsl(Self.wrapHue(hue + 40), 100, 60) + self.warning = Color.hsl(Self.wrapHue(hue + 20), 100, 70) + self.error = Color.hsl(Self.wrapHue(hue - 25), 100, 60) + self.info = Color.hsl(Self.wrapHue(hue + 10), 100, 70) + + // UI elements + self.border = Color.hsl(hue, 33, 26) + + // Additional backgrounds + self.statusBarBackground = Color.hsl(hue, 35, 10) + self.appHeaderBackground = Color.hsl(hue, 35, 7) + self.overlayBackground = Color.hsl(hue, 30, 3) + } + + /// 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: - Convenience Accessors diff --git a/Sources/TUIkit/Styling/Palettes/BluePalette.swift b/Sources/TUIkit/Styling/Palettes/BluePalette.swift index 4f4a74a9..dbacb604 100644 --- a/Sources/TUIkit/Styling/Palettes/BluePalette.swift +++ b/Sources/TUIkit/Styling/Palettes/BluePalette.swift @@ -7,38 +7,76 @@ /// Blue VFD terminal palette. /// /// Inspired by vintage vacuum fluorescent displays (VFDs) found in -/// audio equipment, cash registers, and instrument panels. Uses the -/// characteristic bright cyan-blue glow on a near-black background. +/// audio equipment, cash registers, and instrument panels. +/// All colors are generated algorithmically from a single base hue (200°) +/// using HSL transformations. public struct BluePalette: BlockPalette { public let id = "blue" public let name = "Blue" + /// The base hue used to generate all palette colors. + private static let baseHue: Double = 200 + // Background - public let background = Color.hex(0x060708) + public let background: Color // Blue text hierarchy - public let foreground = Color.hex(0x00AAFF) // Bright VFD blue - primary text - public let foregroundSecondary = Color.hex(0x0088CC) // Medium blue - secondary text - public let foregroundTertiary = Color.hex(0x006699) // Dim blue - tertiary/muted text + public let foreground: Color + public let foregroundSecondary: Color + public let foregroundTertiary: Color // Accent - public let accent = Color.hex(0x33BBFF) // Lighter blue for highlights + public let accent: Color - // Semantic colors (stay in blue family) - public let success = Color.hex(0x33CCFF) // Cyan-blue - public let warning = Color.hex(0x66CCFF) // Light cyan - public let error = Color.hex(0xFF6633) // Orange-red (contrast) - public let info = Color.hex(0x99DDFF) // Pale blue + // Semantic colors + public let success: Color + public let warning: Color + public let error: Color + public let info: Color // UI elements - public let border = Color.hex(0x2D4A5A) // Subtle blue border + public let border: Color // Additional backgrounds - public let statusBarBackground = Color.hex(0x0F1822) - public let appHeaderBackground = Color.hex(0x0A121C) - public let overlayBackground = Color.hex(0x060708) + public let statusBarBackground: Color + public let appHeaderBackground: Color + public let overlayBackground: Color - public init() {} + public init() { + let hue = Self.baseHue + + // Background: very dark, subtly tinted + self.background = Color.hsl(hue, 30, 3) + + // Foregrounds: bright, saturated text + self.foreground = Color.hsl(hue, 100, 50) + self.foregroundSecondary = Color.hsl(hue, 100, 40) + self.foregroundTertiary = Color.hsl(hue, 100, 30) + + // Accent: lighter/brighter variant + self.accent = Color.hsl(hue, 100, 60) + + // Semantic: hue-shifted from base + self.success = Color.hsl(Self.wrapHue(hue + 10), 100, 60) + self.warning = Color.hsl(Self.wrapHue(hue + 20), 100, 70) + self.error = Color.hsl(Self.wrapHue(hue - 185), 100, 60) + self.info = Color.hsl(Self.wrapHue(hue + 5), 100, 75) + + // UI elements + self.border = Color.hsl(hue, 33, 26) + + // Additional backgrounds + self.statusBarBackground = Color.hsl(hue, 35, 10) + self.appHeaderBackground = Color.hsl(hue, 35, 7) + self.overlayBackground = Color.hsl(hue, 30, 3) + } + + /// 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: - Convenience Accessors diff --git a/Sources/TUIkit/Styling/Palettes/GreenPalette.swift b/Sources/TUIkit/Styling/Palettes/GreenPalette.swift index e15abe9a..0602c69f 100644 --- a/Sources/TUIkit/Styling/Palettes/GreenPalette.swift +++ b/Sources/TUIkit/Styling/Palettes/GreenPalette.swift @@ -8,37 +8,75 @@ /// Classic green terminal palette (P1 phosphor). /// /// Inspired by early CRT monitors like the IBM 5151 and Apple II. -/// Uses a dark background with subtle green tint. +/// All colors are generated algorithmically from a single base hue (120°) +/// using HSL transformations. public struct GreenPalette: BlockPalette { public let id = "green" public let name = "Green" + /// The base hue used to generate all palette colors. + private static let baseHue: Double = 120 + // Background - public let background = Color.hex(0x060A07) + public let background: Color // Green text hierarchy - public let foreground = Color.hex(0x33FF33) // Bright green - primary text - public let foregroundSecondary = Color.hex(0x27C227) // Medium green - secondary text - public let foregroundTertiary = Color.hex(0x1F8F1F) // Dim green - tertiary/muted text + public let foreground: Color + public let foregroundSecondary: Color + public let foregroundTertiary: Color // Accent - public let accent = Color.hex(0x66FF66) // Lighter green for highlights + public let accent: Color - // Semantic colors (stay in green family) - public let success = Color.hex(0x33FF33) - public let warning = Color.hex(0xCCFF33) // Yellow-green - public let error = Color.hex(0xFF6633) // Orange-red (contrast) - public let info = Color.hex(0x33FFCC) // Cyan-green + // Semantic colors + public let success: Color + public let warning: Color + public let error: Color + public let info: Color // UI elements - public let border = Color.hex(0x2D5A2D) // Subtle green border + public let border: Color // Additional backgrounds - public let statusBarBackground = Color.hex(0x0F2215) - public let appHeaderBackground = Color.hex(0x0A1B13) - public let overlayBackground = Color.hex(0x060A07) + public let statusBarBackground: Color + public let appHeaderBackground: Color + public let overlayBackground: Color - public init() {} + public init() { + let hue = Self.baseHue + + // Background: very dark, subtly tinted + self.background = Color.hsl(hue, 30, 3) + + // Foregrounds: bright, saturated text + self.foreground = Color.hsl(hue, 100, 60) + self.foregroundSecondary = Color.hsl(hue, 67, 46) + self.foregroundTertiary = Color.hsl(hue, 64, 34) + + // Accent: lighter/brighter variant + self.accent = Color.hsl(hue, 100, 70) + + // Semantic: hue-shifted from base + self.success = Color.hsl(hue, 100, 60) + self.warning = Color.hsl(Self.wrapHue(hue - 45), 100, 60) + self.error = Color.hsl(Self.wrapHue(hue - 105), 100, 60) + self.info = Color.hsl(Self.wrapHue(hue + 45), 100, 60) + + // UI elements + self.border = Color.hsl(hue, 33, 26) + + // Additional backgrounds + self.statusBarBackground = Color.hsl(hue, 35, 10) + self.appHeaderBackground = Color.hsl(hue, 35, 7) + self.overlayBackground = Color.hsl(hue, 30, 3) + } + + /// 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: - Convenience Accessors diff --git a/Sources/TUIkit/Styling/Palettes/RedPalette.swift b/Sources/TUIkit/Styling/Palettes/RedPalette.swift index d0458a41..b1474f9a 100644 --- a/Sources/TUIkit/Styling/Palettes/RedPalette.swift +++ b/Sources/TUIkit/Styling/Palettes/RedPalette.swift @@ -9,36 +9,75 @@ /// /// Less common but used in some military and specialized applications. /// Night-vision friendly with reduced eye strain in dark environments. +/// All colors are generated algorithmically from a single base hue (0°) +/// using HSL transformations. public struct RedPalette: BlockPalette { public let id = "red" public let name = "Red" + /// The base hue used to generate all palette colors. + private static let baseHue: Double = 0 + // Background - public let background = Color.hex(0x0A0606) + public let background: Color // Red text hierarchy - public let foreground = Color.hex(0xFF4444) // Bright red - primary text - public let foregroundSecondary = Color.hex(0xCC3333) // Medium red - secondary text - public let foregroundTertiary = Color.hex(0x8F2222) // Dim red - tertiary/muted text + public let foreground: Color + public let foregroundSecondary: Color + public let foregroundTertiary: Color // Accent - public let accent = Color.hex(0xFF6666) // Lighter red for highlights + public let accent: Color - // Semantic colors (stay in red family) - public let success = Color.hex(0xFF8080) // Light red (success in red theme) - public let warning = Color.hex(0xFFAA66) // Orange - public let error = Color.hex(0xFFFFFF) // White (stands out as error) - public let info = Color.hex(0xFF9999) // Light red + // Semantic colors + public let success: Color + public let warning: Color + public let error: Color + public let info: Color // UI elements - public let border = Color.hex(0x5A2D2D) // Subtle red border + public let border: Color // Additional backgrounds - public let statusBarBackground = Color.hex(0x191313) - public let appHeaderBackground = Color.hex(0x1E0F10) - public let overlayBackground = Color.hex(0x0A0606) + public let statusBarBackground: Color + public let appHeaderBackground: Color + public let overlayBackground: Color - public init() {} + public init() { + let hue = Self.baseHue + + // Background: very dark, subtly tinted + self.background = Color.hsl(hue, 30, 3) + + // Foregrounds: bright, saturated text + self.foreground = Color.hsl(hue, 100, 63) + self.foregroundSecondary = Color.hsl(hue, 60, 50) + self.foregroundTertiary = Color.hsl(hue, 62, 35) + + // Accent: lighter/brighter variant + self.accent = Color.hsl(hue, 100, 70) + + // Semantic: hue-shifted from base + self.success = Color.hsl(Self.wrapHue(hue + 30), 100, 75) + self.warning = Color.hsl(Self.wrapHue(hue + 30), 100, 70) + self.error = Color.hsl(0, 0, 100) + self.info = Color.hsl(hue, 100, 80) + + // UI elements + self.border = Color.hsl(hue, 33, 26) + + // Additional backgrounds + self.statusBarBackground = Color.hsl(hue, 35, 10) + self.appHeaderBackground = Color.hsl(hue, 35, 7) + self.overlayBackground = Color.hsl(hue, 30, 3) + } + + /// 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: - Convenience Accessors diff --git a/Sources/TUIkit/Styling/Palettes/VioletPalette.swift b/Sources/TUIkit/Styling/Palettes/VioletPalette.swift index 58be6578..6dc43f8d 100644 --- a/Sources/TUIkit/Styling/Palettes/VioletPalette.swift +++ b/Sources/TUIkit/Styling/Palettes/VioletPalette.swift @@ -65,7 +65,7 @@ public struct VioletPalette: BlockPalette { self.border = Color.hsl(hue, 40, 25) // Additional backgrounds - self.statusBarBackground = Color.hsl(hue, 35, 8) + self.statusBarBackground = Color.hsl(hue, 35, 10) self.appHeaderBackground = Color.hsl(hue, 35, 7) self.overlayBackground = Color.hsl(hue, 30, 3) } diff --git a/Sources/TUIkit/Styling/Palettes/WhitePalette.swift b/Sources/TUIkit/Styling/Palettes/WhitePalette.swift index 0f039257..8b0462e9 100644 --- a/Sources/TUIkit/Styling/Palettes/WhitePalette.swift +++ b/Sources/TUIkit/Styling/Palettes/WhitePalette.swift @@ -8,37 +8,69 @@ /// Classic white terminal palette (P4 phosphor). /// /// Inspired by terminals like the DEC VT100 and VT220. -/// Uses a dark background with subtle cool/blue tint. +/// Near-achromatic palette with a subtle cool blue tint (225°) in +/// backgrounds. Foregrounds are neutral gray. All colors are generated +/// algorithmically using HSL transformations. public struct WhitePalette: BlockPalette { public let id = "white" public let name = "White" + /// The base hue used for the subtle cool tint in backgrounds. + private static let baseHue: Double = 225 + // Background - public let background = Color.hex(0x06070A) + public let background: Color // White/gray text hierarchy - public let foreground = Color.hex(0xE8E8E8) // Bright white - primary text - public let foregroundSecondary = Color.hex(0xB0B0B0) // Medium gray - secondary text - public let foregroundTertiary = Color.hex(0x787878) // Dim gray - tertiary/muted text + public let foreground: Color + public let foregroundSecondary: Color + public let foregroundTertiary: Color // Accent - public let accent = Color.hex(0xFFFFFF) // Pure white for highlights + public let accent: Color - // Semantic colors (subtle tints) - public let success = Color.hex(0xC0FFC0) // Slight green tint - public let warning = Color.hex(0xFFE0A0) // Slight amber tint - public let error = Color.hex(0xFFA0A0) // Slight red tint - public let info = Color.hex(0xA0D0FF) // Slight blue tint + // Semantic colors + public let success: Color + public let warning: Color + public let error: Color + public let info: Color // UI elements - public let border = Color.hex(0x484848) // Subtle gray border + public let border: Color // Additional backgrounds - public let statusBarBackground = Color.hex(0x131619) - public let appHeaderBackground = Color.hex(0x0D131D) - public let overlayBackground = Color.hex(0x06070A) + public let statusBarBackground: Color + public let appHeaderBackground: Color + public let overlayBackground: Color - public init() {} + public init() { + let hue = Self.baseHue + + // Background: very dark, subtle cool tint + self.background = Color.hsl(hue, 25, 3) + + // Foregrounds: near-neutral gray (very low saturation) + self.foreground = Color.hsl(0, 0, 91) + self.foregroundSecondary = Color.hsl(0, 0, 69) + self.foregroundTertiary = Color.hsl(0, 0, 47) + + // Accent: pure white + self.accent = Color.hsl(0, 0, 100) + + // Semantic colors: subtle tints on neutral base + self.success = Color.hsl(120, 50, 75) + self.warning = Color.hsl(40, 60, 75) + self.error = Color.hsl(0, 60, 75) + self.info = Color.hsl(210, 60, 75) + + // UI elements: neutral gray + self.border = Color.hsl(0, 0, 28) + + // Additional backgrounds: subtle cool tint + self.statusBarBackground = Color.hsl(hue, 20, 10) + self.appHeaderBackground = Color.hsl(hue, 20, 7) + self.overlayBackground = Color.hsl(hue, 25, 3) + } } // MARK: - Convenience Accessors diff --git a/Sources/TUIkit/Styling/Theme.swift b/Sources/TUIkit/Styling/Theme.swift index 2c812933..ebfd70f4 100644 --- a/Sources/TUIkit/Styling/Theme.swift +++ b/Sources/TUIkit/Styling/Theme.swift @@ -138,8 +138,8 @@ public protocol BlockPalette: Palette { // MARK: - Default BlockPalette Implementation extension BlockPalette { - public var surfaceBackground: Color { background.lighter(by: 0.08) } - public var surfaceHeaderBackground: Color { background.lighter(by: 0.05) } + public var surfaceBackground: Color { background.lighter(by: 0.10) } + public var surfaceHeaderBackground: Color { background.lighter(by: 0.07) } public var elevatedBackground: Color { surfaceHeaderBackground.lighter(by: 0.05) } } diff --git a/Sources/TUIkit/Views/ContainerView.swift b/Sources/TUIkit/Views/ContainerView.swift index d4fb13da..15cff541 100644 --- a/Sources/TUIkit/Views/ContainerView.swift +++ b/Sources/TUIkit/Views/ContainerView.swift @@ -292,20 +292,30 @@ extension ContainerView: Renderable { let indicatorColor = context.focusIndicatorColor innerContext.focusIndicatorColor = nil - // Render body content first to determine its width. + // Render body content first to determine its natural width. let paddedContent = content.padding(padding) let bodyBuffer = TUIkit.renderToBuffer(paddedContent, context: innerContext) - // Calculate inner width from body and title (footer adapts to this). - let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders - let innerWidth = max(titleWidth, bodyBuffer.width) - - // Render footer constrained to the actual inner width. - // PaddingModifier is post-processing (doesn't reduce availableWidth for - // its child), so we subtract the footer padding from the context width - // manually. This ensures Spacer() in the footer fills exactly the - // container's inner width. + // Render footer with full available width for initial measurement. + // This ensures the footer's natural width is included in the + // innerWidth calculation, preventing truncation when footer content + // (e.g. HStack with Spacer + Button) is wider than the body. let footerPadding = EdgeInsets(horizontal: 1, vertical: 0) + let initialFooterBuffer: FrameBuffer? + if let footerView = footer { + let paddedFooter = footerView.padding(footerPadding) + initialFooterBuffer = TUIkit.renderToBuffer(paddedFooter, context: innerContext) + } else { + initialFooterBuffer = nil + } + + // Calculate inner width from title, body, AND footer. + let titleWidth = title.map { $0.count + 4 } ?? 0 // " Title " + borders + let footerNaturalWidth = initialFooterBuffer?.width ?? 0 + let innerWidth = max(titleWidth, bodyBuffer.width, footerNaturalWidth) + + // Re-render footer constrained to the final innerWidth so that + // Spacer() fills exactly the container's inner width. let footerBuffer: FrameBuffer? if let footerView = footer { var footerContext = innerContext diff --git a/Sources/TUIkitExample/ContentView.swift b/Sources/TUIkitExample/ContentView.swift index 7572775a..ccbb69c6 100644 --- a/Sources/TUIkitExample/ContentView.swift +++ b/Sources/TUIkitExample/ContentView.swift @@ -19,6 +19,7 @@ enum DemoPage: Int, CaseIterable { case layout case buttons case spinners + case blockTheme } // MARK: - Content View (Page Router) @@ -63,7 +64,7 @@ struct ContentView: View { .statusBarItems { StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "nav") StatusBarItem(shortcut: Shortcut.enter, label: "select", key: .enter) - StatusBarItem(shortcut: Shortcut.range("1", "7"), label: "jump") + StatusBarItem(shortcut: Shortcut.range("1", "8"), label: "jump") } case .textStyles: TextStylesPage() @@ -85,6 +86,9 @@ struct ContentView: View { case .spinners: SpinnersPage() .statusBarItems(subPageItems(pageSetter: pageSetter)) + case .blockTheme: + BlockThemePage() + .statusBarItems(subPageItems(pageSetter: pageSetter)) } } diff --git a/Sources/TUIkitExample/Pages/BlockThemePage.swift b/Sources/TUIkitExample/Pages/BlockThemePage.swift new file mode 100644 index 00000000..fcc9b544 --- /dev/null +++ b/Sources/TUIkitExample/Pages/BlockThemePage.swift @@ -0,0 +1,81 @@ +// +// BlockThemePage.swift +// TUIkitExample +// +// Visual test page for Block appearance background colors. +// Shows all background roles side by side for color tuning. +// + +import TUIkit + +/// Block theme color tuning page. +/// +/// Displays all background color roles used in block appearance +/// as labeled color swatches. This page is designed to be viewed +/// exclusively in block appearance mode for visual comparison. +struct BlockThemePage: View { + var body: some View { + VStack(spacing: 1) { + HeaderView(title: "Block Theme Colors") + + Text("Switch to block appearance (press 'a') and violet theme (press 't').") + .foregroundColor(.palette.foregroundSecondary) + + // Panel with Header, Body, and Footer — shows all 3 block background roles + DemoSection("Panel with Header + Body + Footer") { + Panel("Header — surfaceHeaderBackground", titleColor: .palette.accent) { + Text("Body area — surfaceBackground").foregroundColor(.palette.foreground) + Text("Secondary on body").foregroundColor(.palette.foregroundSecondary) + Text("Tertiary on body").foregroundColor(.palette.foregroundTertiary) + } footer: { + Text("Footer — surfaceHeaderBackground").foregroundColor(.palette.foreground) + } + } + + // Side-by-side containers to compare + DemoSection("Panel vs Card vs Box") { + HStack(spacing: 2) { + Panel("Panel", titleColor: .palette.accent) { + Text("Foreground").foregroundColor(.palette.foreground) + Text("Secondary").foregroundColor(.palette.foregroundSecondary) + Text("Tertiary").foregroundColor(.palette.foregroundTertiary) + } + + Card { + Text("Foreground").foregroundColor(.palette.foreground) + Text("Secondary").foregroundColor(.palette.foregroundSecondary) + Text("Tertiary").foregroundColor(.palette.foregroundTertiary) + } + + Box { + Text("Foreground").foregroundColor(.palette.foreground) + Text("Secondary").foregroundColor(.palette.foregroundSecondary) + Text("Tertiary").foregroundColor(.palette.foregroundTertiary) + } + } + } + + // Buttons on app background + DemoSection("Buttons — elevatedBackground") { + HStack(spacing: 2) { + Button("Default") {} + Button("Primary", style: .primary) {} + Button("Destructive", style: .destructive) {} + } + } + + // Buttons inside a Panel (on surfaceBackground) + DemoSection("Buttons inside Panel") { + Panel("Panel with Buttons", titleColor: .palette.accent) { + Text("Buttons on surfaceBackground:").foregroundColor(.palette.foregroundSecondary) + HStack(spacing: 2) { + Button("Default") {} + Button("Primary", style: .primary) {} + } + } + } + + Spacer() + } + } +} diff --git a/Sources/TUIkitExample/Pages/MainMenuPage.swift b/Sources/TUIkitExample/Pages/MainMenuPage.swift index f6f39f8a..1cda6a05 100644 --- a/Sources/TUIkitExample/Pages/MainMenuPage.swift +++ b/Sources/TUIkitExample/Pages/MainMenuPage.swift @@ -36,6 +36,7 @@ struct MainMenuPage: View { MenuItem(label: "Layout System", shortcut: "5"), MenuItem(label: "Buttons & Focus", shortcut: "6"), MenuItem(label: "Spinners", shortcut: "7"), + MenuItem(label: "Block Theme Colors", shortcut: "8"), ], selection: $menuSelection, onSelect: { index in