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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to PaletteKit are documented here.

## Unreleased

### Added
- `PaletteGraphic.resolvedStopColors` exposes the actual ordered gradient stops
without rendering an image, including repeated anchors for single-color results.

### Fixed
- Graphic Lab enables only color counts supported by the resolved gradient,
preserves valid selections, adjusts unavailable selections, and explains
single-color results with all color-count options disabled.

## 2.1.1

### Fixed
Expand Down
24 changes: 20 additions & 4 deletions Examples/PaletteKitDemo/PaletteKitDemo/Card/CardLabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ struct CardLabView: View {
direction: direction,
linearStart: axis.start,
linearEnd: axis.end,
colorCount: colorCount,
colorCount: colorOptions.selection(preserving: colorCount),
swatchStrategy: strategy,
grain: grain
)
Expand Down Expand Up @@ -118,6 +118,10 @@ struct CardLabView: View {
ResolvedColors(palette: palette, swatches: swatches, strategy: strategy)
}

private var colorOptions: GraphicColorOptions {
GraphicColorOptions(palette: palette, swatches: swatches, strategy: strategy)
}

var body: some View {
let cp = resolvedColors
VStack(spacing: 12) {
Expand Down Expand Up @@ -155,6 +159,9 @@ struct CardLabView: View {
.background(Color(cp.background).opacity(0.55).ignoresSafeArea())
.navigationTitle("Graphic Lab")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: colorOptions, initial: true) { _, options in
colorCount = options.selection(preserving: colorCount)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Expand Down Expand Up @@ -187,10 +194,19 @@ struct CardLabView: View {
}

private var stopsPicker: some View {
Picker("Colors", selection: $colorCount) {
ForEach(ColorCount.allCases) { c in Text("\(c.rawValue)").tag(c) }
let options = colorOptions
return VStack(spacing: 4) {
GraphicColorCountPicker(selection: $colorCount, options: options)
.fixedSize(horizontal: false, vertical: true)

if options.isSingleColor {
Text("The current combination produces a single color.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.accessibilityIdentifier("graphic.singleColor")
}
}
.pickerStyle(.segmented)
}

private var strategyPicker: some View {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import PaletteKit
import SwiftUI
import UIKit

/// UIKit supplies per-segment disabled interaction and accessibility semantics.
/// SwiftUI's segmented Picker does not consistently honor child `.disabled`.
struct GraphicColorCountPicker: UIViewRepresentable {
@Binding var selection: ColorCount
let options: GraphicColorOptions

func makeUIView(context: Context) -> UISegmentedControl {
let control = UISegmentedControl(items: ColorCount.allCases.map { String($0.rawValue) })
control.accessibilityLabel = "Colors"
control.accessibilityIdentifier = "graphic.colors"
control.addTarget(context.coordinator, action: #selector(Coordinator.selectCount(_:)), for: .valueChanged)
return control
}

func updateUIView(_ control: UISegmentedControl, context: Context) {
context.coordinator.parent = self
for (index, count) in ColorCount.allCases.enumerated() {
control.setEnabled(options.availableCounts.contains(count), forSegmentAt: index)
}
let resolved = options.selection(preserving: selection)
control.selectedSegmentIndex = options.isSingleColor
? UISegmentedControl.noSegment
: ColorCount.allCases.firstIndex(of: resolved) ?? UISegmentedControl.noSegment
control.accessibilityHint = options.isSingleColor
? "The current combination produces a single color."
: "Unavailable color counts are dimmed."
}

func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

@MainActor
final class Coordinator: NSObject {
var parent: GraphicColorCountPicker

init(parent: GraphicColorCountPicker) { self.parent = parent }

@objc func selectCount(_ sender: UISegmentedControl) {
guard ColorCount.allCases.indices.contains(sender.selectedSegmentIndex) else { return }
let count = ColorCount.allCases[sender.selectedSegmentIndex]
guard parent.options.availableCounts.contains(count) else { return }
parent.selection = count
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import PaletteKit

/// Demo policy built from the renderer's actual stops, not its candidate rules.
struct GraphicColorOptions: Equatable {
let availableCounts: [ColorCount]

@MainActor
init(palette: Palette, swatches: SwatchMap?, strategy: SwatchStrategy) {
availableCounts = ColorCount.allCases.filter { count in
let graphic = PaletteGraphic(
palette: palette, swatches: swatches,
configuration: .init(colorCount: count, swatchStrategy: strategy)
)
return Set(graphic.resolvedStopColors.map(\.rgb)).count == count.rawValue
}
}

var isSingleColor: Bool { availableCounts.isEmpty }

/// Preserve a valid selection; otherwise use the largest available count.
/// Two is the rendering fallback for a single color, with no segment selected.
func selection(preserving count: ColorCount) -> ColorCount {
availableCounts.contains(count) ? count : availableCounts.last ?? .two
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import PaletteKit
import SwiftUI
import UIKit
import XCTest
@testable import PaletteKitDemo

@MainActor
final class GraphicColorCountPickerTests: XCTestCase {
func testHostedLabUpdatesSegmentsAndSelectionWhenInputsChange() throws {
let host = UIHostingController(rootView: lab([.white, gray(200), gray(140), gray(70), .black]))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
host.view.layoutIfNeeded()
defer { window.isHidden = true }

waitUntil { Self.colorPicker(in: host.view) != nil }
let control = try XCTUnwrap(Self.colorPicker(in: host.view))
XCTAssertEqual(control.accessibilityLabel, "Colors")
assertSegments(control, enabled: [0, 1, 2, 3], selected: 0)

control.selectedSegmentIndex = 3
control.sendActions(for: .valueChanged)
host.rootView = lab([.white, gray(140), .black])
waitUntil { control.selectedSegmentIndex == 1 && !control.isEnabledForSegment(at: 2) }
assertSegments(control, enabled: [0, 1], selected: 1)

host.rootView = lab([.white, gray(200), gray(140), gray(70), .black])
waitUntil { control.isEnabledForSegment(at: 3) }
assertSegments(control, enabled: [0, 1, 2, 3], selected: 1)

host.rootView = lab([.black])
waitUntil { control.selectedSegmentIndex == UISegmentedControl.noSegment }
assertSegments(control, enabled: [], selected: UISegmentedControl.noSegment)
XCTAssertEqual(control.accessibilityHint, "The current combination produces a single color.")

host.rootView = lab([.white, .black])
waitUntil { control.isEnabledForSegment(at: 0) }
assertSegments(control, enabled: [0], selected: 0)
}

func testHostedLabCorrectsSelectionOnStrategyChange() throws {
// The darkest color is dominant: Vibrant and Muted collapse to one
// color, while Contrast uses the lightest and darkest palette colors.
let host = UIHostingController(rootView: lab([.black, gray(140), .white]))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
host.view.layoutIfNeeded()
defer { window.isHidden = true }

waitUntil { Self.colorPicker(in: host.view) != nil }
let control = try XCTUnwrap(Self.colorPicker(in: host.view))
let strategy = try XCTUnwrap(Self.segments(in: host.view).first { $0.titleForSegment(at: 0) == "Vibrant" })
assertSegments(control, enabled: [], selected: UISegmentedControl.noSegment)

strategy.selectedSegmentIndex = 1
strategy.sendActions(for: .valueChanged)
waitUntil { control.isEnabledForSegment(at: 1) }
assertSegments(control, enabled: [0, 1], selected: 0)
control.selectedSegmentIndex = 1
control.sendActions(for: .valueChanged)

strategy.selectedSegmentIndex = 2
strategy.sendActions(for: .valueChanged)
waitUntil { control.selectedSegmentIndex == UISegmentedControl.noSegment }
assertSegments(control, enabled: [], selected: UISegmentedControl.noSegment)

strategy.selectedSegmentIndex = 1
strategy.sendActions(for: .valueChanged)
waitUntil { control.selectedSegmentIndex == 0 }
assertSegments(control, enabled: [0, 1], selected: 0)
}

private func assertSegments(_ control: UISegmentedControl, enabled: Set<Int>, selected: Int,
file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(control.numberOfSegments, 4, file: file, line: line)
XCTAssertEqual(control.selectedSegmentIndex, selected, file: file, line: line)
for index in 0..<4 {
XCTAssertEqual(control.isEnabledForSegment(at: index), enabled.contains(index), file: file, line: line)
}
}

private func waitUntil(_ condition: @escaping @MainActor @Sendable () -> Bool,
file: StaticString = #filePath, line: UInt = #line) {
let expectation = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in
MainActor.assumeIsolated { condition() }
}, object: nil)
XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed, file: file, line: line)
}

private static func colorPicker(in view: UIView) -> UISegmentedControl? {
segments(in: view).first { $0.accessibilityIdentifier == "graphic.colors" }
}

private static func segments(in view: UIView) -> [UISegmentedControl] {
(view as? UISegmentedControl).map { [$0] } ?? view.subviews.flatMap { segments(in: $0) }
}

private func lab(_ colors: [PaletteColor]) -> CardLabView {
CardLabView(palette: Palette(colors: colors, colorSpaceUsed: .oklch), swatches: nil)
}

private func gray(_ value: UInt8) -> PaletteColor { PaletteColor(r: value, g: value, b: value) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Testing
@testable import PaletteKit
@testable import PaletteKitDemo

@MainActor
@Suite("Graphic Lab color options")
struct GraphicColorOptionsTests {
@Test("Single-color and empty palettes disable every count")
func singleColor() {
for colors in [[], [.black], [.white, .white]] as [[PaletteColor]] {
let options = options(colors)
#expect(options.availableCounts.isEmpty)
#expect(options.isSingleColor)
for count in ColorCount.allCases {
#expect(options.selection(preserving: count) == .two)
}
}
}

@Test("Only achievable counts are available", arguments: 0...3)
func candidateLimits(middleCount: Int) {
let colors = [PaletteColor.white] + [gray(200), gray(140), gray(70)].prefix(middleCount) + [.black]
let options = options(colors)
#expect(options.availableCounts == Array(ColorCount.allCases.prefix(middleCount + 1)))
#expect(!options.isSingleColor)
for count in ColorCount.allCases {
#expect(options.selection(preserving: count).rawValue == min(count.rawValue, middleCount + 2))
}
}

@Test("Strategy changes correct unavailable selections and preserve valid ones")
func strategyChanges() {
let palette = Palette(colors: [.white, gray(200), gray(70), .black], colorSpaceUsed: .oklch)
let swatches = SwatchMap(
vibrant: swatch(.black, .vibrant),
muted: swatch(.white, .muted),
darkVibrant: swatch(.black, .darkVibrant),
lightVibrant: swatch(gray(70), .lightVibrant)
)
let vibrant = GraphicColorOptions(palette: palette, swatches: swatches, strategy: .vibrant)
let contrast = GraphicColorOptions(palette: palette, swatches: swatches, strategy: .contrast)
let muted = GraphicColorOptions(palette: palette, swatches: swatches, strategy: .muted)
#expect(vibrant.availableCounts == [])
#expect(contrast.availableCounts == [.two])
#expect(muted.availableCounts == [.two, .three, .four])
var selection = ColorCount.five
selection = muted.selection(preserving: selection)
#expect(selection == .four)
selection = contrast.selection(preserving: selection)
#expect(selection == .two)
#expect(muted.selection(preserving: selection) == .two)
#expect(vibrant.selection(preserving: .four) == .two)
#expect(muted.selection(preserving: .three) == .three)
}

@Test("Changing the image or swatches recomputes availability")
func changedInputs() {
let full = options([.white, gray(200), gray(140), gray(70), .black])
let fewer = options([.white, gray(140), .black])
#expect(full.selection(preserving: .five) == .five)
#expect(fewer.selection(preserving: .five) == .three)
#expect(full.selection(preserving: .three) == .three)

let palette = Palette(colors: [.white, gray(140), .black], colorSpaceUsed: .oklch)
let collapsed = GraphicColorOptions(palette: palette,
swatches: SwatchMap(vibrant: swatch(.black, .vibrant)), strategy: .vibrant)
#expect(collapsed.isSingleColor)
#expect(collapsed.selection(preserving: .three) == .two)
}

private func options(_ colors: [PaletteColor]) -> GraphicColorOptions {
GraphicColorOptions(palette: Palette(colors: colors, colorSpaceUsed: .oklch), swatches: nil, strategy: .vibrant)
}

private func gray(_ value: UInt8) -> PaletteColor { PaletteColor(r: value, g: value, b: value) }

private func swatch(_ color: PaletteColor, _ role: SwatchRole) -> Swatch {
Swatch(color: color, role: role, titleTextColor: .white, bodyTextColor: .white)
}
}
15 changes: 15 additions & 0 deletions Examples/PaletteKitDemo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,18 @@ if it isn't already present. Subsequent runs skip that step.
The generated `PaletteKitDemo.xcodeproj` is **not committed**. It is
regenerated on every `make demo-app` from `project.yml`, so the source of
truth is the YAML plus the Swift/plist files in this folder.

## Graphic Lab regression tests

The demo tests cover available color counts, selection correction, and hosted
segmented-control updates using synthetic palettes. From the repo root:

```sh
xcodegen generate --spec Examples/PaletteKitDemo/project.yml
xcodebuild test \
-project Examples/PaletteKitDemo/PaletteKitDemo.xcodeproj \
-scheme PaletteKitDemo \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
```

Choose an installed simulator name or use `-destination 'id=<simulator-uuid>'`.
21 changes: 21 additions & 0 deletions Examples/PaletteKitDemo/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,24 @@ targets:
TARGETED_DEVICE_FAMILY: "1,2"
SUPPORTS_MACCATALYST: "NO"
CODE_SIGN_STYLE: Automatic

PaletteKitDemoTests:
type: bundle.unit-test
platform: iOS
sources:
- path: PaletteKitDemoTests
dependencies:
- target: PaletteKitDemo
- package: PaletteKit
settings:
base:
GENERATE_INFOPLIST_FILE: "YES"

schemes:
PaletteKitDemo:
build:
targets:
PaletteKitDemo: all
test:
targets:
- PaletteKitDemoTests
Loading
Loading