From 4942df3a59c0dd7c95b82727f8b03ddf80657973 Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Wed, 14 Oct 2026 22:21:05 +0200 Subject: improve hex color conversion --- Sources/bar/Models/Color+Hex.swift | 14 +++++++------ Sources/bar/bar.docc/customisation.md | 3 +++ Tests/barTests/ColorHexTests.swift | 39 +++++++++++++++++++++++++++++++++++ tasks/20261014-192228/TASK.md | 19 ++++++++++++++++- 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 Tests/barTests/ColorHexTests.swift diff --git a/Sources/bar/Models/Color+Hex.swift b/Sources/bar/Models/Color+Hex.swift index 4f82e78..9d8fc36 100644 --- a/Sources/bar/Models/Color+Hex.swift +++ b/Sources/bar/Models/Color+Hex.swift @@ -7,10 +7,8 @@ extension SwiftUI.Color { /// `#RRGGBB`, and `#RRGGBBAA`. Single-digit components are expanded by /// repeating the digit. When present, the trailing digits encode alpha /// (opacity); otherwise opacity defaults to `1`. Surrounding whitespace is - /// trimmed. Unsupported lengths resolve to opaque black. At supported - /// lengths, Scanner may parse only a leading hexadecimal portion; its - /// success result is not checked. Invalid characters therefore do not - /// reliably produce opaque black (alpha formats can be transparent). + /// trimmed. Unsupported lengths or any non-hexadecimal characters resolve + /// to opaque black. /// /// - Parameter code: A hex color code. static func hex(_ code: String) -> SwiftUI.Color { @@ -19,8 +17,12 @@ extension SwiftUI.Color { sanitized.removeFirst() } - var rgba: UInt64 = 0 - Scanner(string: sanitized).scanHexInt64(&rgba) + // Restrict input to ASCII hex digits; integer conversion also accepts signs. + guard sanitized.utf8.allSatisfy({ + (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) + }), let rgba = UInt64(sanitized, radix: 16) else { + return Color(.sRGB, red: 0, green: 0, blue: 0, opacity: 1) + } let red: Double let green: Double diff --git a/Sources/bar/bar.docc/customisation.md b/Sources/bar/bar.docc/customisation.md index f5d5195..cdf48d9 100644 --- a/Sources/bar/bar.docc/customisation.md +++ b/Sources/bar/bar.docc/customisation.md @@ -36,6 +36,9 @@ static let foreground: Color = .hex("#CDD6F4") ``` Supported formats (the leading `#` is optional): `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`. +Surrounding whitespace is ignored, and letters can be uppercase or lowercase. +Short components repeat their digit (`#3aF` becomes `#33AAFF`); trailing alpha sets opacity, which otherwise defaults to 1. +Unsupported lengths or invalid characters produce opaque black. ## Spacing diff --git a/Tests/barTests/ColorHexTests.swift b/Tests/barTests/ColorHexTests.swift new file mode 100644 index 0000000..14f60c0 --- /dev/null +++ b/Tests/barTests/ColorHexTests.swift @@ -0,0 +1,39 @@ +import SwiftUI +import Testing +@testable import bar + +private func expectColor(_ color: Color, _ red: Double, _ green: Double, _ blue: Double, _ alpha: Double = 1) { + let actual = color.resolve(in: EnvironmentValues()) + let expected = Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + .resolve(in: EnvironmentValues()) + #expect(abs(actual.red - expected.red) < 0.00001) + #expect(abs(actual.green - expected.green) < 0.00001) + #expect(abs(actual.blue - expected.blue) < 0.00001) + #expect(abs(actual.opacity - expected.opacity) < 0.00001) +} + +@Test func validHexColors() { + expectColor(.hex("#3aF"), 0x33 / 255.0, 0xAA / 255.0, 1) + expectColor(.hex("3Af8"), 0x33 / 255.0, 0xAA / 255.0, 1, 0x88 / 255.0) + expectColor(.hex("33aaff"), 0x33 / 255.0, 0xAA / 255.0, 1) + expectColor(.hex(" \n#33AAff80\t"), 0x33 / 255.0, 0xAA / 255.0, 1, 0x80 / 255.0) + expectColor(.hex("#0000"), 0, 0, 0, 0) + expectColor(.hex("#ffffff00"), 1, 1, 1, 0) + expectColor(.hex("#FFFFFFFF"), 1, 1, 1) +} + +@Test(arguments: [ + "", " \n\t", "#", "12", "12345", "1234567", "123456789", + "g12", "1g2", "12g", "g123", "1g23", "123g", + "g12345", "12g345", "12345g", "g1234567", "123g4567", "1234567g", + "+123", "-123", "0xFF", "# 123", "12 3", "##123", "123", "abc💙", +]) func malformedHexIsOpaqueBlack(_ input: String) { + expectColor(.hex(input), 0, 0, 0) +} + +@Test @MainActor func configuredColorsKeepTheirAppearance() { + expectColor(Config.foreground, 0xAE / 255.0, 0xD3 / 255.0, 0xF3 / 255.0) + expectColor(Config.background, 0x01 / 255.0, 0x04 / 255.0, 0x08 / 255.0, 0.825) + expectColor(Config.highlights[0], 0x0F / 255.0, 0x30 / 255.0, 0x4A / 255.0) + expectColor(Config.highlights[1], 0x1F / 255.0, 0x68 / 255.0, 0x9D / 255.0) +} diff --git a/tasks/20261014-192228/TASK.md b/tasks/20261014-192228/TASK.md index 453663a..9fd771c 100644 --- a/tasks/20261014-192228/TASK.md +++ b/tasks/20261014-192228/TASK.md @@ -1,6 +1,6 @@ # Validate complete hex color input -- STATUS: OPEN +- STATUS: CLOSED - PRIORITY: 160 - TAGS: review,bug,core @@ -13,3 +13,20 @@ Approach: Validate every character and require complete successful conversion be Completion checks: Valid short and long RGB/RGBA resolve correctly, including lowercase and alpha. Invalid characters at the start/middle/end, unsupported lengths, empty input, and partial scans all resolve to opaque black. Keep parsing independent of any new configuration framework and verify existing Config colors retain their appearance. Design constraint: Follow README.md: keep source-based configuration, no dependencies or plugin framework, and use the smallest readable change that makes the existing behavior reliable. This is a suggestion for individual review, not a deadline. Findings were recorded on 2026-10-14; re-check the current code before implementing. + +## Completion + +Updated the retained helper in Sources/bar/Models/Color+Hex.swift (HEX.swift was +removed by task 20261014-192305). Validate all characters as ASCII hexadecimal +digits and require complete UInt64(radix: 16) conversion before extracting +components. Unsupported lengths and malformed input consistently return opaque +sRGB black. Optional #, surrounding whitespace, letter case, repeated short +digits, trailing alpha, and Config appearance are preserved. Updated the helper +comment and customization documentation to state the fallback. + +Added ColorHexTests covering all four valid formats, mixed case, whitespace, +transparent and opaque alpha, 27 malformed inputs including invalid characters +at start/middle/end and partial scans, and the configured colors' resolved RGBA. +Validation: swift test passed all seven tests (including 27 malformed-input +cases); the clean DocC workflow built the app and documentation without warnings; +git diff --check passed. -- cgit v1.2.3