Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions Sources/SkipUI/SkipUI/Color/Color.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,15 +623,46 @@ private struct ColorSet : Decodable {
let alpha: String?

var color: Color {
let redValue = Double(red ?? "") ?? 0.0
let greenValue = Double(green ?? "") ?? 0.0
let blueValue = Double(blue ?? "") ?? 0.0
let alphaValue = Double(alpha ?? "") ?? 1.0
let redValue = parseColorComponent(red, defaultValue: 0.0)
let greenValue = parseColorComponent(green, defaultValue: 0.0)
let blueValue = parseColorComponent(blue, defaultValue: 0.0)
let alphaValue = parseColorComponent(alpha, defaultValue: 1.0)
return Color(red: redValue, green: greenValue, blue: blueValue, opacity: alphaValue)
}
}
}

/// Parse a single `.colorset` channel, covering all of Xcode's numeric "Input Method" variants:
/// Floating Point ("0.945", passed through unchanged), 8-bit Hexadecimal ("0xF1" / "#F1"), and
/// 8-bit (0-255) ("241"). Hex bytes and 0-255 integers are normalized to 0...1 by dividing by 255.
/// Malformed or empty input falls back to `defaultValue`. See https://github.com/skiptools/skip-ui/issues/146
///
/// The hex prefix is detected explicitly rather than relying on `Double(_:)` to reject it: Swift
/// parses `Double("0xF1")` as `241.0`, whereas the transpiled Kotlin `"0xF1".toDoubleOrNull()` is
/// `null`. A "try Double first" approach would therefore behave differently once transpiled (and is
/// the reason #146 manifests only on Android). Checking the prefix keeps both platforms identical.
/// Hex digits are parsed with Kotlin's `toIntOrNull(radix:)` because Swift's `Int(_:radix:)` does not
/// transpile. This is a free function rather than a method on `ColorComponents` because adding a
/// `static` member to that `Decodable` type breaks Skip's reflection-based JSON decoding of `ColorSet`.
private func parseColorComponent(_ string: String?, defaultValue: Double) -> Double {
guard let string, !string.isEmpty else { return defaultValue }
if string.hasPrefix("0x") || string.hasPrefix("0X") {
let byte: Int? = String(string.dropFirst(2)).toIntOrNull(radix: 16)
if let byte { return Double(byte) / 255.0 }
return defaultValue
}
if string.hasPrefix("#") {
let byte: Int? = String(string.dropFirst(1)).toIntOrNull(radix: 16)
if let byte { return Double(byte) / 255.0 }
return defaultValue
}
// Floating Point emits a decimal ("0.580"); the "8-bit (0-255)" method emits a bare integer ("148").
if !string.contains("."), let intValue = Int(string) {
return Double(intValue) / 255.0
}
return Double(string) ?? defaultValue
}

#endif

/*
Expand Down
36 changes: 36 additions & 0 deletions Tests/SkipUITests/ColorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,42 @@ final class ColorTests: XCSnapshotTestCase {
XCTAssertEqual("F", try render(compact: 1, view: Color.white.frame(width: 1.0, height: 1.0)).pixmap)
}

// Issue #146: a custom .colorset authored with Xcode's "8-bit Hexadecimal" input method (channels
// like "0xF1") rendered as pure black on Android, because Double("0xF1") returned nil and every
// channel fell back to 0.0. These tests render fixtures from the test bundle and are guarded to the
// transpiled (SKIP) side: on Apple platforms Color(_:bundle:) defers to SwiftUI's own asset loader,
// so the buggy parsing path only exists under SKIP.

// The two tests below validate the issue #146 fix end-to-end by rendering custom `.colorset` assets.
// They are DISABLED-prefixed (this file's convention for tests that don't run in the standard pass)
// because decoding a bundled component `.colorset` currently fails under the Robolectric *unit* runner:
// `JSONDecoder().decode(ColorSet.self, …)` throws a kotlin-reflect `IllegalAccessException` on the JVM.
// It works on a real Android device — which is where #146 was reported (hex colorsets rendered *black*,
// i.e. decode succeeded and only the parse was wrong). The failure is independent of this fix: it occurs
// in `ColorSet` decoding, before `parseColorComponent` is ever reached. Run these on an emulator/device
// to validate; the parsing algorithm itself is covered by the contribution's standalone logic harness.

func DISABLEDtestHexColorset() throws {
// 0x04/0xF1/0x88 must render the real color (#04F188), not black (pre-fix it rendered "0").
#if SKIP
XCTAssertEqual("04F188", try render(compact: 1, view: Color("HexColor", bundle: .module).frame(width: 1.0, height: 1.0)).pixmap)
#endif
}

func DISABLEDtestFloatColorset() throws {
// The identical color authored as floats must render the same #04F188 (hex path == float path).
#if SKIP
XCTAssertEqual("04F188", try render(compact: 1, view: Color("FloatColor", bundle: .module).frame(width: 1.0, height: 1.0)).pixmap)
#endif
}

func DISABLEDtestIntColorset() throws {
// The identical color authored with the "8-bit (0-255)" method (4/241/136) must render #04F188.
#if SKIP
XCTAssertEqual("04F188", try render(compact: 1, view: Color("IntColor", bundle: .module).frame(width: 1.0, height: 1.0)).pixmap)
#endif
}

// Disabled tests (due to slow performance when running against emulator)

func DISABLEDtestColorClearDark() throws {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.533333",
"green" : "0.945098",
"red" : "0.015686"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x88",
"green" : "0xF1",
"red" : "0x04"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "255",
"blue" : "136",
"green" : "241",
"red" : "4"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading