Revise ShapeStyle and add Gradients (#435)

This commit is contained in:
Carson Katri 2021-08-14 18:26:39 -04:00 committed by GitHub
parent 21c21cd328
commit 4609b0a203
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1323 additions and 573 deletions

View File

@ -209,7 +209,7 @@ let package = Package(
condition: .when(platforms: [.macOS])
),
],
exclude: ["__Snapshots__"]
exclude: ["__Snapshots__", "RenderingTests/__Snapshots__"]
),
]
)

View File

@ -34,7 +34,7 @@ public extension Shape {
}
public extension ShapeStyle where Self: View, Self.Body == _ShapeView<Rectangle, Self> {
var body: some View {
var body: Body {
_ShapeView(shape: Rectangle(), style: self)
}
}

View File

@ -75,8 +75,12 @@ public extension View {
public func modifyEnvironment(_ values: inout EnvironmentValues) {
values._backgroundStyle = .init(
styles: (primary: style, secondary: style, tertiary: style),
styles: (primary: style, secondary: style, tertiary: style, quaternary: style),
environment: values
)
}
}
public extension ShapeStyle where Self == BackgroundStyle {
static var background: Self { .init() }
}

View File

@ -1,104 +0,0 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 7/7/21.
//
@frozen public struct PrimaryContentStyle {
@inlinable
public init() {}
}
extension PrimaryContentStyle: ShapeStyle {
public func _apply(to shape: inout _ShapeStyle_Shape) {
if !shape.inRecursiveStyle,
let foregroundStyle = shape.environment._foregroundStyle
{
if foregroundStyle.styles.primary is Self {
shape.inRecursiveStyle = true
}
foregroundStyle.styles.primary._apply(to: &shape)
} else {
shape.result = .color(shape.environment.foregroundColor ?? .primary)
}
}
public static func _apply(to shape: inout _ShapeStyle_ShapeType) {}
}
@frozen public struct SecondaryContentStyle {
@inlinable
public init() {}
}
extension SecondaryContentStyle: ShapeStyle {
public func _apply(to shape: inout _ShapeStyle_Shape) {
if !shape.inRecursiveStyle,
let foregroundStyle = shape.environment._foregroundStyle
{
if foregroundStyle.styles.secondary is Self {
shape.inRecursiveStyle = true
}
foregroundStyle.styles.secondary._apply(to: &shape)
} else {
shape.result = .color((shape.environment.foregroundColor ?? .primary).opacity(0.5))
}
}
public static func _apply(to shape: inout _ShapeStyle_ShapeType) {}
}
@frozen public struct TertiaryContentStyle {
@inlinable
public init() {}
}
extension TertiaryContentStyle: ShapeStyle {
public func _apply(to shape: inout _ShapeStyle_Shape) {
if !shape.inRecursiveStyle,
let foregroundStyle = shape.environment._foregroundStyle
{
if foregroundStyle.styles.tertiary is Self {
shape.inRecursiveStyle = true
}
foregroundStyle.styles.tertiary._apply(to: &shape)
} else {
shape.result = .color((shape.environment.foregroundColor ?? .primary).opacity(0.3))
}
}
public static func _apply(to shape: inout _ShapeStyle_ShapeType) {}
}
@frozen public struct QuaternaryContentStyle {
@inlinable
public init() {}
}
extension QuaternaryContentStyle: ShapeStyle {
public func _apply(to shape: inout _ShapeStyle_Shape) {
if !shape.inRecursiveStyle,
let foregroundStyle = shape.environment._foregroundStyle
{
if foregroundStyle.styles.tertiary is Self {
shape.inRecursiveStyle = true
}
foregroundStyle.styles.tertiary._apply(to: &shape)
} else {
shape.result = .color((shape.environment.foregroundColor ?? .primary).opacity(0.2))
}
}
public static func _apply(to shape: inout _ShapeStyle_ShapeType) {}
}

View File

@ -71,8 +71,9 @@ public extension View {
}
}
@frozen public struct _ForegroundStyleModifier<Primary, Secondary, Tertiary>: ViewModifier,
EnvironmentModifier
@frozen public struct _ForegroundStyleModifier<
Primary, Secondary, Tertiary
>: ViewModifier, EnvironmentModifier
where Primary: ShapeStyle, Secondary: ShapeStyle, Tertiary: ShapeStyle
{
public var primary: Primary
@ -90,6 +91,13 @@ public extension View {
public typealias Body = Never
public func modifyEnvironment(_ values: inout EnvironmentValues) {
values._foregroundStyle = .init(styles: (primary, secondary, tertiary), environment: values)
values._foregroundStyle = .init(
styles: (primary, secondary, tertiary, tertiary),
environment: values
)
}
}
public extension ShapeStyle where Self == ForegroundStyle {
static var foreground: Self { .init() }
}

View File

@ -0,0 +1,152 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
import Foundation
@frozen public struct AngularGradient: ShapeStyle, View {
internal var gradient: Gradient
internal var center: UnitPoint
internal var startAngle: Angle
internal var endAngle: Angle
public init(
gradient: Gradient,
center: UnitPoint,
startAngle: Angle = .zero,
endAngle: Angle = .zero
) {
self.gradient = gradient
self.center = center
self.startAngle = startAngle
self.endAngle = endAngle
}
public init(colors: [Color], center: UnitPoint, startAngle: Angle, endAngle: Angle) {
self.init(
gradient: Gradient(colors: colors),
center: center,
startAngle: startAngle,
endAngle: endAngle
)
}
public init(stops: [Gradient.Stop], center: UnitPoint, startAngle: Angle, endAngle: Angle) {
self.init(
gradient: Gradient(stops: stops),
center: center,
startAngle: startAngle,
endAngle: endAngle
)
}
public init(gradient: Gradient, center: UnitPoint, angle: Angle = .zero) {
self.init(gradient: gradient, center: center, startAngle: angle, endAngle: angle)
}
public init(colors: [Color], center: UnitPoint, angle: Angle = .zero) {
self.init(
gradient: Gradient(colors: colors),
center: center,
angle: angle
)
}
public init(stops: [Gradient.Stop], center: UnitPoint, angle: Angle = .zero) {
self.init(
gradient: Gradient(stops: stops),
center: center,
angle: angle
)
}
public typealias Body = _ShapeView<Rectangle, Self>
public func _apply(to shape: inout _ShapeStyle_Shape) {
shape.result = .resolved(
.gradient(
gradient,
style: .angular(center: center, startAngle: startAngle, endAngle: endAngle)
)
)
}
public static func _apply(to type: inout _ShapeStyle_ShapeType) {}
}
public extension ShapeStyle where Self == AngularGradient {
static func angularGradient(
_ gradient: Gradient,
center: UnitPoint,
startAngle: Angle,
endAngle: Angle
) -> AngularGradient {
.init(
gradient: gradient, center: center,
startAngle: startAngle, endAngle: endAngle
)
}
static func angularGradient(
colors: [Color],
center: UnitPoint,
startAngle: Angle,
endAngle: Angle
) -> AngularGradient {
.init(
colors: colors, center: center,
startAngle: startAngle, endAngle: endAngle
)
}
static func angularGradient(
stops: [Gradient.Stop],
center: UnitPoint,
startAngle: Angle,
endAngle: Angle
) -> AngularGradient {
.init(
stops: stops, center: center,
startAngle: startAngle, endAngle: endAngle
)
}
}
public extension ShapeStyle where Self == AngularGradient {
static func conicGradient(
_ gradient: Gradient,
center: UnitPoint,
angle: Angle = .zero
) -> AngularGradient {
.init(gradient: gradient, center: center, angle: angle)
}
static func conicGradient(
colors: [Color],
center: UnitPoint,
angle: Angle = .zero
) -> AngularGradient {
.init(colors: colors, center: center, angle: angle)
}
static func conicGradient(
stops: [Gradient.Stop],
center: UnitPoint,
angle: Angle = .zero
) -> AngularGradient {
.init(stops: stops, center: center, angle: angle)
}
}

View File

@ -0,0 +1,123 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
import Foundation
@frozen public struct EllipticalGradient: ShapeStyle, View {
internal var gradient: Gradient
internal var center: UnitPoint
internal var startRadiusFraction: CGFloat
internal var endRadiusFraction: CGFloat
public init(
gradient: Gradient,
center: UnitPoint = .center,
startRadiusFraction: CGFloat = 0,
endRadiusFraction: CGFloat = 0.5
) {
self.gradient = gradient
self.center = center
self.startRadiusFraction = startRadiusFraction
self.endRadiusFraction = endRadiusFraction
}
public init(
colors: [Color],
center: UnitPoint = .center,
startRadiusFraction: CGFloat = 0,
endRadiusFraction: CGFloat = 0.5
) {
self.init(
gradient: .init(colors: colors),
center: center,
startRadiusFraction: startRadiusFraction,
endRadiusFraction: endRadiusFraction
)
}
public init(
stops: [Gradient.Stop],
center: UnitPoint = .center,
startRadiusFraction: CGFloat = 0,
endRadiusFraction: CGFloat = 0.5
) {
self.init(
gradient: .init(stops: stops),
center: center,
startRadiusFraction: startRadiusFraction,
endRadiusFraction: endRadiusFraction
)
}
public typealias Body = _ShapeView<Rectangle, Self>
public func _apply(to shape: inout _ShapeStyle_Shape) {
shape.result = .resolved(
.gradient(
gradient,
style: .elliptical(
center: center,
startRadiusFraction: startRadiusFraction,
endRadiusFraction: endRadiusFraction
)
)
)
}
public static func _apply(to type: inout _ShapeStyle_ShapeType) {}
}
public extension ShapeStyle where Self == EllipticalGradient {
static func ellipticalGradient(
_ gradient: Gradient,
center: UnitPoint = .center,
startRadiusFraction: CGFloat = 0,
endRadiusFraction: CGFloat = 0.5
) -> EllipticalGradient {
.init(
gradient: gradient, center: center,
startRadiusFraction: startRadiusFraction,
endRadiusFraction: endRadiusFraction
)
}
static func ellipticalGradient(
colors: [Color],
center: UnitPoint = .center,
startRadiusFraction: CGFloat = 0,
endRadiusFraction: CGFloat = 0.5
) -> EllipticalGradient {
.init(
colors: colors, center: center,
startRadiusFraction: startRadiusFraction,
endRadiusFraction: endRadiusFraction
)
}
static func ellipticalGradient(
stops: [Gradient.Stop],
center: UnitPoint = .center,
startRadiusFraction: CGFloat = 0,
endRadiusFraction: CGFloat = 0.5
) -> EllipticalGradient {
.init(
stops: stops, center: center,
startRadiusFraction: startRadiusFraction,
endRadiusFraction: endRadiusFraction
)
}
}

View File

@ -0,0 +1,64 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 7/28/21.
//
import Foundation
@frozen public struct Gradient: Equatable {
@frozen public struct Stop: Equatable {
public var color: Color
public var location: CGFloat
public init(color: Color, location: CGFloat) {
self.color = color
self.location = location
}
}
public var stops: [Gradient.Stop]
public init(stops: [Gradient.Stop]) {
self.stops = stops
}
public init(colors: [Color]) {
stops = colors.enumerated().map {
.init(
color: $0.element,
location: CGFloat($0.offset) / CGFloat(colors.count - 1)
)
}
}
}
public enum _GradientStyle: Hashable {
case linear(startPoint: UnitPoint, endPoint: UnitPoint)
case radial(
center: UnitPoint,
startRadius: CGFloat,
endRadius: CGFloat
)
case elliptical(
center: UnitPoint,
startRadiusFraction: CGFloat,
endRadiusFraction: CGFloat
)
case angular(
center: UnitPoint,
startAngle: Angle,
endAngle: Angle
)
}

View File

@ -0,0 +1,80 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
import Foundation
@frozen public struct LinearGradient: ShapeStyle, View {
internal var gradient: Gradient
internal var startPoint: UnitPoint
internal var endPoint: UnitPoint
public init(gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint) {
self.gradient = gradient
self.startPoint = startPoint
self.endPoint = endPoint
}
public init(colors: [Color], startPoint: UnitPoint, endPoint: UnitPoint) {
self.init(
gradient: Gradient(colors: colors),
startPoint: startPoint, endPoint: endPoint
)
}
public init(stops: [Gradient.Stop], startPoint: UnitPoint, endPoint: UnitPoint) {
self.init(
gradient: Gradient(stops: stops),
startPoint: startPoint, endPoint: endPoint
)
}
public typealias Body = _ShapeView<Rectangle, Self>
public func _apply(to shape: inout _ShapeStyle_Shape) {
shape.result = .resolved(
.gradient(gradient, style: .linear(startPoint: startPoint, endPoint: endPoint))
)
}
public static func _apply(to type: inout _ShapeStyle_ShapeType) {}
}
public extension ShapeStyle where Self == LinearGradient {
static func linearGradient(
_ gradient: Gradient,
startPoint: UnitPoint,
endPoint: UnitPoint
) -> LinearGradient {
.init(gradient: gradient, startPoint: startPoint, endPoint: endPoint)
}
static func linearGradient(
colors: [Color],
startPoint: UnitPoint,
endPoint: UnitPoint
) -> LinearGradient {
.init(colors: colors, startPoint: startPoint, endPoint: endPoint)
}
static func linearGradient(
stops: [Gradient.Stop],
startPoint: UnitPoint,
endPoint: UnitPoint
) -> LinearGradient {
.init(stops: stops, startPoint: startPoint, endPoint: endPoint)
}
}

View File

@ -0,0 +1,97 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
import Foundation
@frozen public struct RadialGradient: ShapeStyle, View {
internal var gradient: Gradient
internal var center: UnitPoint
internal var startRadius: CGFloat
internal var endRadius: CGFloat
public init(gradient: Gradient, center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) {
self.gradient = gradient
self.center = center
self.startRadius = startRadius
self.endRadius = endRadius
}
public init(colors: [Color], center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) {
self.init(
gradient: Gradient(colors: colors), center: center,
startRadius: startRadius, endRadius: endRadius
)
}
public init(stops: [Gradient.Stop], center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) {
self.init(
gradient: Gradient(stops: stops), center: center,
startRadius: startRadius, endRadius: endRadius
)
}
public typealias Body = _ShapeView<Rectangle, Self>
public func _apply(to shape: inout _ShapeStyle_Shape) {
shape.result = .resolved(
.gradient(
gradient,
style: .radial(center: center, startRadius: startRadius, endRadius: endRadius)
)
)
}
public static func _apply(to type: inout _ShapeStyle_ShapeType) {}
}
public extension ShapeStyle where Self == RadialGradient {
static func radialGradient(
_ gradient: Gradient,
center: UnitPoint,
startRadius: CGFloat,
endRadius: CGFloat
) -> RadialGradient {
.init(
gradient: gradient, center: center,
startRadius: startRadius, endRadius: endRadius
)
}
static func radialGradient(
colors: [Color],
center: UnitPoint,
startRadius: CGFloat,
endRadius: CGFloat
) -> RadialGradient {
.init(
colors: colors, center: center,
startRadius: startRadius, endRadius: endRadius
)
}
static func radialGradient(
stops: [Gradient.Stop],
center: UnitPoint,
startRadius: CGFloat,
endRadius: CGFloat
) -> RadialGradient {
.init(
stops: stops, center: center,
startRadius: startRadius, endRadius: endRadius
)
}
}

View File

@ -0,0 +1,63 @@
// Copyright 2020-2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 7/7/21.
//
/// A `ShapeStyle` that provides the `primary`, `secondary`, `tertiary`, and `quaternary` styles.
@frozen public struct HierarchicalShapeStyle: ShapeStyle {
@usableFromInline
internal var id: UInt32
@inlinable
internal init(id: UInt32) {
self.id = id
}
public func _apply(to shape: inout _ShapeStyle_Shape) {
if let foregroundStyle = shape.environment._foregroundStyle,
foregroundStyle.stylesArray.count > id
{
let style = foregroundStyle.stylesArray[Int(id)]
if (style as? Self)?.id == id {
shape.inRecursiveStyle = true
// Walk up.
shape.environment = foregroundStyle.environment
}
style._apply(to: &shape)
} else {
// Fallback to changing the opacity of the `foregroundColor`.
shape.result = .color(
(shape.environment.foregroundColor ?? .primary)
.opacity({
switch id {
case 0: return 1
case 1: return 0.5
case 2: return 0.3
default: return 0.2
}
}())
)
}
}
public static func _apply(to type: inout _ShapeStyle_ShapeType) {}
}
public extension ShapeStyle where Self == HierarchicalShapeStyle {
static var primary: HierarchicalShapeStyle { .init(id: 0) }
static var secondary: HierarchicalShapeStyle { .init(id: 1) }
static var tertiary: HierarchicalShapeStyle { .init(id: 2) }
static var quaternary: HierarchicalShapeStyle { .init(id: 3) }
}

View File

@ -55,3 +55,12 @@ extension Material: ShapeStyle {
public extension Material {
static let bar = Self.regular
}
public extension ShapeStyle where Self == Material {
static var regularMaterial: Self { .regular }
static var thickMaterial: Self { .thick }
static var thinMaterial: Self { .thin }
static var ultraThinMaterial: Self { .ultraThin }
static var ultraThickMaterial: Self { .ultraThick }
static var bar: Self { .bar }
}

View File

@ -23,9 +23,14 @@ public protocol ShapeStyle {
}
public struct AnyShapeStyle: ShapeStyle {
let styles: (primary: ShapeStyle, secondary: ShapeStyle, tertiary: ShapeStyle)
let styles: (
primary: ShapeStyle,
secondary: ShapeStyle,
tertiary: ShapeStyle,
quaternary: ShapeStyle
)
var stylesArray: [ShapeStyle] {
[styles.primary, styles.secondary, styles.tertiary]
[styles.primary, styles.secondary, styles.tertiary, styles.quaternary]
}
let environment: EnvironmentValues
@ -106,9 +111,10 @@ public struct _ShapeStyle_Shape {
case bool(Bool)
case none
public func resolvedStyle(on shape: _ShapeStyle_Shape,
in environment: EnvironmentValues) -> _ResolvedStyle?
{
public func resolvedStyle(
on shape: _ShapeStyle_Shape,
in environment: EnvironmentValues
) -> _ResolvedStyle? {
switch self {
case let .resolved(resolved): return resolved
case let .style(anyStyle):
@ -134,6 +140,7 @@ public indirect enum _ResolvedStyle {
case array([_ResolvedStyle])
case opacity(Float, _ResolvedStyle)
// case multicolor(ResolvedMulticolorStyle)
case gradient(Gradient, style: _GradientStyle)
public func color(at level: Int) -> Color? {
switch self {
@ -146,6 +153,8 @@ public indirect enum _ResolvedStyle {
case let .opacity(opacity, resolved):
guard let color = resolved.color(at: level) else { return nil }
return color.opacity(Double(opacity))
case let .gradient(gradient, _):
return gradient.stops.first?.color
}
}
}

View File

@ -61,6 +61,16 @@ public struct BorderedButtonStyle: PrimitiveButtonStyle {
}
}
public struct BorderedProminentButtonStyle: PrimitiveButtonStyle {
public init() {}
public func makeBody(configuration: Configuration) -> some View {
_PrimitiveButtonStyleBody(style: self, configuration: configuration) {
configuration.label
}
}
}
public struct BorderlessButtonStyle: ButtonStyle {
public init() {}

View File

@ -130,6 +130,20 @@ public extension Color {
}
}
public extension ShapeStyle where Self == Color {
static var clear: Self { .clear }
static var black: Self { .black }
static var white: Self { .white }
static var gray: Self { .gray }
static var red: Self { .red }
static var green: Self { .green }
static var blue: Self { .blue }
static var orange: Self { .orange }
static var yellow: Self { .yellow }
static var pink: Self { .pink }
static var purple: Self { .purple }
}
extension Color: ExpressibleByIntegerLiteral {
/// Allows initializing value of `Color` type from hex values
public init(integerLiteral bitMask: UInt32) {
@ -175,8 +189,5 @@ extension Color: ShapeStyle {
}
extension Color: View {
@_spi(TokamakCore)
public var body: some View {
_ShapeView(shape: Rectangle(), style: self)
}
public typealias Body = _ShapeView<Rectangle, Self>
}

View File

@ -21,19 +21,6 @@ public enum Prominence: Hashable {
}
extension EnvironmentValues {
private enum ControlProminenceKey: EnvironmentKey {
static var defaultValue: Prominence = .standard
}
public var controlProminence: Prominence {
get {
self[ControlProminenceKey.self]
}
set {
self[ControlProminenceKey.self] = newValue
}
}
private enum HeaderProminenceKey: EnvironmentKey {
static var defaultValue: Prominence = .standard
}
@ -49,10 +36,6 @@ extension EnvironmentValues {
}
public extension View {
func controlProminence(_ prominence: Prominence) -> some View {
environment(\.controlProminence, prominence)
}
func headerProminence(_ prominence: Prominence) -> some View {
environment(\.headerProminence, prominence)
}

View File

@ -84,7 +84,6 @@ public struct _PrimitiveButtonStyleBody<Label>: _PrimitiveView where Label: View
anyStyle = .init(style)
}
@Environment(\.controlProminence) public var controlProminence
@Environment(\.controlSize) public var controlSize
}

View File

@ -45,7 +45,7 @@ public struct DefaultProgressViewStyle: ProgressViewStyle {
VStack(alignment: .leading, spacing: 0) {
HStack { Spacer() }
configuration.label
.foregroundStyle(PrimaryContentStyle())
.foregroundStyle(HierarchicalShapeStyle.primary)
if let fractionCompleted = configuration.fractionCompleted {
_FractionalProgressView(fractionCompleted)
} else {
@ -53,7 +53,7 @@ public struct DefaultProgressViewStyle: ProgressViewStyle {
}
configuration.currentValueLabel
.font(.caption)
.foregroundStyle(PrimaryContentStyle())
.foregroundStyle(HierarchicalShapeStyle.primary)
.opacity(0.5)
}
}

View File

@ -64,6 +64,7 @@ public typealias ButtonStyleConfiguration = TokamakCore.ButtonStyleConfiguration
public typealias DefaultButtonStyle = TokamakCore.DefaultButtonStyle
public typealias PlainButtonStyle = TokamakCore.PlainButtonStyle
public typealias BorderedButtonStyle = TokamakCore.BorderedButtonStyle
public typealias BorderedProminentButtonStyle = TokamakCore.BorderedProminentButtonStyle
public typealias BorderlessButtonStyle = TokamakCore.BorderlessButtonStyle
public typealias LinkButtonStyle = TokamakCore.LinkButtonStyle
@ -93,16 +94,19 @@ public typealias ContainerRelativeShape = TokamakCore.ContainerRelativeShape
// MARK: Shape Styles
public typealias PrimaryContentStyle = TokamakCore.PrimaryContentStyle
public typealias SecondaryContentStyle = TokamakCore.SecondaryContentStyle
public typealias TertiaryContentStyle = TokamakCore.TertiaryContentStyle
public typealias QuaternaryContentStyle = TokamakCore.QuaternaryContentStyle
public typealias HierarchicalShapeStyle = TokamakCore.HierarchicalShapeStyle
public typealias ForegroundStyle = TokamakCore.ForegroundStyle
public typealias BackgroundStyle = TokamakCore.BackgroundStyle
public typealias Material = TokamakCore.Material
public typealias Gradient = TokamakCore.Gradient
public typealias LinearGradient = TokamakCore.LinearGradient
public typealias RadialGradient = TokamakCore.RadialGradient
public typealias EllipticalGradient = TokamakCore.EllipticalGradient
public typealias AngularGradient = TokamakCore.AngularGradient
// MARK: Primitive values
public typealias Color = TokamakCore.Color

View File

@ -26,7 +26,7 @@ extension EnvironmentValues {
var environment = EnvironmentValues()
// `.toggleStyle` property is internal
environment[_ToggleStyleKey] = _AnyToggleStyle(DefaultToggleStyle())
environment[_ToggleStyleKey.self] = _AnyToggleStyle(DefaultToggleStyle())
environment.colorScheme = .init(matchMediaDarkScheme: matchMediaDarkScheme)
environment._defaultAppStorage = LocalStorage.standard

View File

@ -49,12 +49,11 @@ extension _PrimitiveButtonStyleBody: DOMPrimitive {
let isResetStyle = style is PlainButtonStyle.Type
|| style is BorderlessButtonStyle.Type
|| style is LinkButtonStyle.Type
let isBordered = style is BorderedButtonStyle.Type
|| style is DefaultButtonStyle.Type
let isBorderedProminent = style is BorderedProminentButtonStyle.Type
var attributes = [HTMLAttribute: String]()
if isResetStyle {
attributes["class"] = "_tokamak-buttonstyle-reset"
} else if isBordered && controlProminence == .increased {
} else if isBorderedProminent {
attributes["class"] = "_tokamak-button-prominence-increased"
}
let font: Font?
@ -70,7 +69,7 @@ extension _PrimitiveButtonStyleBody: DOMPrimitive {
listeners: listeners
) {
if !isResetStyle {
if isBordered && controlProminence == .increased {
if isBorderedProminent {
self.label
.foregroundColor(.white)
} else {

View File

@ -81,7 +81,7 @@ public struct ButtonStyleDemo: View {
if #available(iOS 15.0, macOS 12.0, *) {
#if compiler(>=5.5) || os(WASI) // Xcode 13 required for `controlProminence`.
Button("Prominent") {}
.controlProminence(.increased)
.buttonStyle(BorderedProminentButtonStyle())
Text("borderless")
.font(.headline)
allSizes

View File

@ -23,7 +23,7 @@ struct ShapeStyleDemo: View {
VStack {
Text("Red Style")
Rectangle()
.frame(width: 50, height: 50)
.frame(width: 25, height: 25)
}
.foregroundStyle(Color.red)
VStack {
@ -92,14 +92,46 @@ struct ShapeStyleDemo: View {
HStack {
VStack {
Text("Primary")
.foregroundStyle(PrimaryContentStyle())
.foregroundStyle(HierarchicalShapeStyle.primary)
Text("Secondary")
.foregroundStyle(SecondaryContentStyle())
.foregroundStyle(HierarchicalShapeStyle.secondary)
Text("Tertiary")
.foregroundStyle(TertiaryContentStyle())
.foregroundStyle(HierarchicalShapeStyle.tertiary)
Text("Quaternary")
.foregroundStyle(QuaternaryContentStyle())
.foregroundStyle(HierarchicalShapeStyle.quaternary)
}
VStack {
Text("Primary")
.foregroundStyle(HierarchicalShapeStyle.primary)
Text("Secondary")
.foregroundStyle(HierarchicalShapeStyle.secondary)
Text("Tertiary")
.foregroundStyle(HierarchicalShapeStyle.tertiary)
Text("Quaternary")
.foregroundStyle(HierarchicalShapeStyle.quaternary)
}
.foregroundStyle(Color.red, Color.green, Color.blue)
}
VStack {
Rectangle()
.fill(
LinearGradient(
colors: [.red, .green, .blue],
startPoint: .bottomLeading,
endPoint: .topTrailing
)
)
.frame(width: 300, height: 100)
Rectangle()
.fill(
RadialGradient(
colors: [.red, .green, .blue],
center: .topLeading,
startRadius: 50,
endRadius: 100
)
)
.frame(width: 300, height: 100)
}
#endif
}

View File

@ -46,16 +46,19 @@ public typealias ContainerRelativeShape = TokamakCore.ContainerRelativeShape
// MARK: Shape Styles
public typealias PrimaryContentStyle = TokamakCore.PrimaryContentStyle
public typealias SecondaryContentStyle = TokamakCore.SecondaryContentStyle
public typealias TertiaryContentStyle = TokamakCore.TertiaryContentStyle
public typealias QuaternaryContentStyle = TokamakCore.QuaternaryContentStyle
public typealias HierarchicalShapeStyle = TokamakCore.HierarchicalShapeStyle
public typealias ForegroundStyle = TokamakCore.ForegroundStyle
public typealias BackgroundStyle = TokamakCore.BackgroundStyle
public typealias Material = TokamakCore.Material
public typealias Gradient = TokamakCore.Gradient
public typealias LinearGradient = TokamakCore.LinearGradient
public typealias RadialGradient = TokamakCore.RadialGradient
public typealias EllipticalGradient = TokamakCore.EllipticalGradient
public typealias AngularGradient = TokamakCore.AngularGradient
// MARK: Primitive values
public typealias Color = TokamakCore.Color

View File

@ -56,26 +56,78 @@ extension _ShapeView: _HTMLPrimitive {
@_spi(TokamakStaticHTML)
public var renderedBody: AnyView {
let path = shape.path(in: .zero).renderedBody
let attributes: [HTMLAttribute: String]
var attributes: [HTMLAttribute: String] = [:]
var svgDefs: AnyView?
if let shapeAttributes = shape as? ShapeAttributes {
attributes = shapeAttributes.attributes(style)
} else if let color = style.resolve(
for: .resolveStyle(levels: 0..<1),
in: environment,
role: Content.role
)?.color(at: 0) {
attributes = ["style": "fill: \(color.cssValue(environment));"]
} else if let foregroundStyle = environment._foregroundStyle,
let color = foregroundStyle.resolve(
for: .resolveStyle(levels: 0..<1),
in: environment,
role: Content.role
)?.color(at: 0)
{
attributes = ["style": "fill: \(color.cssValue(environment));"]
} else {
return path
let resolved = style.resolve(
for: .resolveStyle(levels: 0..<1),
in: environment,
role: Content.role
)
switch resolved {
case let .gradient(gradient, style):
let stops = ForEach(Array(gradient.stops.enumerated()), id: \.offset) {
HTML("stop", [
"offset": "\($0.element.location * 100)%",
"stop-color": $0.element.color.cssValue(environment),
])
}
let id = Int.random(in: 0..<Int.max)
attributes = ["style": "fill: url(#gradient\(id));"]
switch style {
case let .linear(startPoint, endPoint):
svgDefs = AnyView(HTML(
"linearGradient",
[
"id": "gradient\(id)",
"x1": "\(startPoint.x * 100)%",
"y1": "\((1 - startPoint.y) * 100)%",
"x2": "\(endPoint.x * 100)%",
"y2": "\((1 - endPoint.y) * 100)%",
"gradientUnits": "userSpaceOnUse",
]
) {
stops
})
case let .radial(center, startRadius, endRadius):
svgDefs = AnyView(
HTML(
"radialGradient",
[
"id": "gradient\(id)",
"fx": "\(center.x * 100)%",
"fy": "\((1 - center.y) * 100)%",
"cx": "\(center.x * 100)%",
"cy": "\((1 - center.y) * 100)%",
"gradientUnits": "userSpaceOnUse",
"fr": "\(startRadius)",
"r": "\(endRadius)",
]
) {
stops
}
)
default: return path
}
default:
if let color = resolved?.color(at: 0) {
attributes = ["style": "fill: \(color.cssValue(environment));"]
} else if
let foregroundStyle = environment._foregroundStyle,
let color = foregroundStyle.resolve(
for: .resolveStyle(levels: 0..<1),
in: environment,
role: Content.role
)?.color(at: 0)
{
attributes = ["style": "fill: \(color.cssValue(environment));"]
} else {
return path
}
}
}
if let view = mapAnyView(path, transform: { (html: HTML<AnyView>) -> AnyView in
@ -84,7 +136,14 @@ extension _ShapeView: _HTMLPrimitive {
attributes,
uniquingKeysWith: uniqueKeys
)
return AnyView(HTML(html.tag, mergedAttributes) { html.content })
return AnyView(HTML(html.tag, mergedAttributes) {
html.content
if let svgDefs = svgDefs {
HTML("defs") {
svgDefs
}
}
})
}) {
return view
} else {

View File

@ -21,7 +21,7 @@ extension EnvironmentValues {
/// Returns default settings for the static HTML environment
static var defaultEnvironment: Self {
var environment = EnvironmentValues()
environment[_ColorSchemeKey] = .light
environment[_ColorSchemeKey.self] = .light
return environment
}

View File

@ -1,397 +0,0 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Max Desiatov on 13/06/2021.
//
// SnapshotTesting with image snapshots are only supported on macOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
// Needed for `NSImage`, but would be great to make this truly cross-platform.
import class AppKit.NSImage
public extension Snapshotting where Value: View, Format == NSImage {
static var image: Snapshotting { .image() }
/// A snapshot strategy for comparing Tokamak Views based on pixel equality.
static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
SimplySnapshotting.image(precision: precision).asyncPullback { view in
Async { callback in
let html = Data(StaticHTMLRenderer(view).render().utf8)
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let renderedPath = cwd.appendingPathComponent("rendered.html")
// swiftlint:disable:next force_try
try! html.write(to: renderedPath)
let browser = Process()
browser
.launchPath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
var arguments = [
"--headless",
"--disable-gpu",
"--force-device-scale-factor=1.0",
"--force-color-profile=srgb",
"--screenshot",
renderedPath.path,
]
if let size = size {
arguments.append("--window-size=\(Int(size.width)),\(Int(size.height))")
}
browser.arguments = arguments
browser.terminationHandler = { _ in
callback(NSImage(
contentsOfFile: cwd.appendingPathComponent("screenshot.png")
.path
)!)
}
browser.launch()
}
}
}
}
private let defaultSnapshotTimeout: TimeInterval = 10
struct Star: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
path.move(to: .init(x: 40, y: 0))
path.addLine(to: .init(x: 20, y: 76))
path.addLine(to: .init(x: 80, y: 30.4))
path.addLine(to: .init(x: 0, y: 30.4))
path.addLine(to: .init(x: 64, y: 76))
path.addLine(to: .init(x: 40, y: 0))
}
}
}
struct Stacks: View {
let spacing: CGFloat
var body: some View {
VStack(spacing: spacing) {
HStack(spacing: spacing) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
Rectangle()
.fill(Color.green)
.frame(width: 100, height: 100)
}
HStack(spacing: spacing) {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
Rectangle()
.fill(Color.black)
.frame(width: 100, height: 100)
}
}
}
}
struct Opacity: View {
var body: some View {
ZStack {
Circle()
.fill(Color.red)
.opacity(0.5)
.frame(width: 25, height: 25)
Circle()
.fill(Color.green)
.opacity(0.5)
.frame(width: 50, height: 50)
Circle()
.fill(Color.blue)
.opacity(0.5)
.frame(width: 75, height: 75)
}
}
}
final class RenderingTests: XCTestCase {
func testPath() {
assertSnapshot(
matching: Star().fill(Color(red: 1, green: 0.75, blue: 0.1, opacity: 1)),
as: .image(size: .init(width: 100, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testStrokedCircle() {
assertSnapshot(
matching: Circle().stroke(Color.green).frame(width: 100, height: 100, alignment: .center),
as: .image(size: .init(width: 150, height: 150)),
timeout: defaultSnapshotTimeout
)
}
func testStacks() {
assertSnapshot(
matching: Stacks(spacing: 10),
as: .image(size: .init(width: 210, height: 210)),
timeout: defaultSnapshotTimeout
)
assertSnapshot(
matching: Stacks(spacing: 20),
as: .image(size: .init(width: 220, height: 220)),
timeout: defaultSnapshotTimeout
)
}
func testOpacity() {
assertSnapshot(
matching: Opacity().preferredColorScheme(.light),
as: .image(size: .init(width: 75, height: 75)),
timeout: defaultSnapshotTimeout
)
}
func testContainerRelativeShape() {
assertSnapshot(
matching: ZStack {
ContainerRelativeShape()
.fill(Color.blue)
.frame(width: 100, height: 100, alignment: .center)
ContainerRelativeShape()
.fill(Color.green)
.frame(width: 50, height: 50)
}.containerShape(Circle()),
as: .image(size: .init(width: 150, height: 150)),
timeout: defaultSnapshotTimeout
)
}
func testForegroundStyle() {
assertSnapshot(
matching: HStack(spacing: 0) {
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(Color.red)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(Color.green)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(Color.blue)
},
as: .image(size: .init(width: 200, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testContentStyles() {
assertSnapshot(
matching: HStack {
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(PrimaryContentStyle())
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(SecondaryContentStyle())
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(TertiaryContentStyle())
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(QuaternaryContentStyle())
}
.foregroundColor(.blue),
as: .image(size: .init(width: 275, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testMaterial() {
assertSnapshot(
matching: ZStack {
HStack(spacing: 0) {
Color.red
Color.orange
Color.yellow
Color.green
Color.blue
Color.purple
}
VStack(spacing: 0) {
Color.clear
.background(Material.ultraThin)
Color.clear
.background(Material.ultraThick)
}
},
as: .image(size: .init(width: 100, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testFrames() {
assertSnapshot(
matching: Color.red
.frame(width: 20, height: 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing),
as: .image(size: .init(width: 50, height: 50)),
timeout: defaultSnapshotTimeout
)
}
func testProgressView() {
assertSnapshot(
matching: VStack(spacing: 0) {
ProgressView(value: 0.5) {
Text("Loading")
} currentValueLabel: {
Text("0.5")
}
ProgressView(Progress(totalUnitCount: 3))
},
as: .image(size: .init(width: 200, height: 200)),
timeout: defaultSnapshotTimeout
)
}
func testAspectRatio() {
assertSnapshot(
matching: Ellipse()
.fill(Color.purple)
.aspectRatio(0.75, contentMode: .fit)
.frame(width: 100, height: 100)
.border(Color(white: 0.75)),
as: .image(size: .init(width: 125, height: 125)),
timeout: defaultSnapshotTimeout
)
assertSnapshot(
matching: Ellipse()
.fill(Color.purple)
.aspectRatio(0.75, contentMode: .fill)
.frame(width: 100, height: 100)
.border(Color(white: 0.75)),
as: .image(size: .init(width: 125, height: 125)),
timeout: defaultSnapshotTimeout
)
}
func testScaleEffect() {
assertSnapshot(
matching: ZStack {
Circle()
.fill(Color.red)
.frame(width: 50, height: 50)
.scaleEffect(2)
.opacity(0.5)
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.opacity(0.5)
},
as: .image(size: .init(width: 100, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testAnchoredModifiers() {
assertSnapshot(
matching: ZStack {
Circle()
.fill(Color.red)
.frame(width: 50, height: 50)
.scaleEffect(2, anchor: .topLeading)
.opacity(0.5)
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.scaleEffect(2, anchor: .center)
.opacity(0.5)
Rectangle()
.fill(Color.red)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(45), anchor: .topLeading)
.opacity(0.5)
Rectangle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(45), anchor: .center)
.opacity(0.5)
},
as: .image(size: .init(width: 200, height: 200)),
timeout: defaultSnapshotTimeout
)
}
func testBackground() {
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.opacity(0.5)
.frame(width: 80, height: 80)
.background(
RoundedRectangle(cornerRadius: 10).fill(Color.red)
),
as: .image(size: .init(width: 100, height: 100))
)
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.opacity(0.5)
.frame(width: 80, height: 80)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
.frame(width: 40, height: 40),
alignment: .bottomTrailing
),
as: .image(size: .init(width: 100, height: 100))
)
}
func testOverlay() {
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.frame(width: 80, height: 80)
.overlay(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red.opacity(0.5))
),
as: .image(size: .init(width: 100, height: 100))
)
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.frame(width: 80, height: 80)
.overlay(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
.frame(width: 40, height: 40),
alignment: .bottomTrailing
),
as: .image(size: .init(width: 100, height: 100))
)
}
}
#endif

View File

@ -0,0 +1,183 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
// SnapshotTesting with image snapshots are only supported on macOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
final class LayoutRenderingTests: XCTestCase {
func testAnchoredModifiers() {
assertSnapshot(
matching: ZStack {
Circle()
.fill(Color.red)
.frame(width: 50, height: 50)
.scaleEffect(2, anchor: .topLeading)
.opacity(0.5)
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.scaleEffect(2, anchor: .center)
.opacity(0.5)
Rectangle()
.fill(Color.red)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(45), anchor: .topLeading)
.opacity(0.5)
Rectangle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(45), anchor: .center)
.opacity(0.5)
},
as: .image(size: .init(width: 200, height: 200)),
timeout: defaultSnapshotTimeout
)
}
func testAspectRatio() {
assertSnapshot(
matching: Ellipse()
.fill(Color.purple)
.aspectRatio(0.75, contentMode: .fit)
.frame(width: 100, height: 100)
.border(Color(white: 0.75)),
as: .image(size: .init(width: 125, height: 125)),
timeout: defaultSnapshotTimeout
)
assertSnapshot(
matching: Ellipse()
.fill(Color.purple)
.aspectRatio(0.75, contentMode: .fill)
.frame(width: 100, height: 100)
.border(Color(white: 0.75)),
as: .image(size: .init(width: 125, height: 125)),
timeout: defaultSnapshotTimeout
)
}
func testBackground() {
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.opacity(0.5)
.frame(width: 80, height: 80)
.background(
RoundedRectangle(cornerRadius: 10).fill(Color.red)
),
as: .image(size: .init(width: 100, height: 100))
)
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.opacity(0.5)
.frame(width: 80, height: 80)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
.frame(width: 40, height: 40),
alignment: .bottomTrailing
),
as: .image(size: .init(width: 100, height: 100))
)
}
func testFrames() {
assertSnapshot(
matching: Color.red
.frame(width: 20, height: 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing),
as: .image(size: .init(width: 50, height: 50)),
timeout: defaultSnapshotTimeout
)
}
func testOverlay() {
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.frame(width: 80, height: 80)
.overlay(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red.opacity(0.5))
),
as: .image(size: .init(width: 100, height: 100))
)
assertSnapshot(
matching: Rectangle()
.fill(Color.blue)
.frame(width: 80, height: 80)
.overlay(
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
.frame(width: 40, height: 40),
alignment: .bottomTrailing
),
as: .image(size: .init(width: 100, height: 100))
)
}
func testStacks() {
struct Stacks: View {
let spacing: CGFloat
var body: some View {
VStack(spacing: spacing) {
HStack(spacing: spacing) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
Rectangle()
.fill(Color.green)
.frame(width: 100, height: 100)
}
HStack(spacing: spacing) {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
Rectangle()
.fill(Color.black)
.frame(width: 100, height: 100)
}
}
}
}
assertSnapshot(
matching: Stacks(spacing: 10),
as: .image(size: .init(width: 210, height: 210)),
timeout: defaultSnapshotTimeout
)
assertSnapshot(
matching: Stacks(spacing: 20),
as: .image(size: .init(width: 220, height: 220)),
timeout: defaultSnapshotTimeout
)
}
}
#endif

View File

@ -0,0 +1,70 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
// SnapshotTesting with image snapshots are only supported on macOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
final class ShapeRenderingTests: XCTestCase {
func testContainerRelativeShape() {
assertSnapshot(
matching: ZStack {
ContainerRelativeShape()
.fill(Color.blue)
.frame(width: 100, height: 100, alignment: .center)
ContainerRelativeShape()
.fill(Color.green)
.frame(width: 50, height: 50)
}.containerShape(Circle()),
as: .image(size: .init(width: 150, height: 150)),
timeout: defaultSnapshotTimeout
)
}
func testPath() {
struct Star: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
path.move(to: .init(x: 40, y: 0))
path.addLine(to: .init(x: 20, y: 76))
path.addLine(to: .init(x: 80, y: 30.4))
path.addLine(to: .init(x: 0, y: 30.4))
path.addLine(to: .init(x: 64, y: 76))
path.addLine(to: .init(x: 40, y: 0))
}
}
}
assertSnapshot(
matching: Star().fill(Color(red: 1, green: 0.75, blue: 0.1, opacity: 1)),
as: .image(size: .init(width: 100, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testStrokedCircle() {
assertSnapshot(
matching: Circle().stroke(Color.green).frame(width: 100, height: 100, alignment: .center),
as: .image(size: .init(width: 150, height: 150)),
timeout: defaultSnapshotTimeout
)
}
}
#endif

View File

@ -0,0 +1,71 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
// SnapshotTesting with image snapshots are only supported on macOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
// Needed for `NSImage`, but would be great to make this truly cross-platform.
import class AppKit.NSImage
public extension Snapshotting where Value: View, Format == NSImage {
static var image: Snapshotting { .image() }
/// A snapshot strategy for comparing Tokamak Views based on pixel equality.
static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
SimplySnapshotting.image(precision: precision).asyncPullback { view in
Async { callback in
let html = Data(StaticHTMLRenderer(view).render().utf8)
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let renderedPath = cwd.appendingPathComponent("rendered.html")
// swiftlint:disable:next force_try
try! html.write(to: renderedPath)
let browser = Process()
browser
.launchPath = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
var arguments = [
"--headless",
"--disable-gpu",
"--force-device-scale-factor=1.0",
"--force-color-profile=srgb",
"--screenshot",
renderedPath.path,
]
if let size = size {
arguments.append("--window-size=\(Int(size.width)),\(Int(size.height))")
}
browser.arguments = arguments
browser.terminationHandler = { _ in
callback(NSImage(
contentsOfFile: cwd.appendingPathComponent("screenshot.png")
.path
)!)
}
browser.launch()
}
}
}
}
let defaultSnapshotTimeout: TimeInterval = 10
#endif

View File

@ -0,0 +1,41 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
// SnapshotTesting with image snapshots are only supported on macOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
final class ViewRenderingTests: XCTestCase {
func testProgressView() {
assertSnapshot(
matching: VStack(spacing: 0) {
ProgressView(value: 0.5) {
Text("Loading")
} currentValueLabel: {
Text("0.5")
}
ProgressView(Progress(totalUnitCount: 3))
},
as: .image(size: .init(width: 200, height: 200)),
timeout: defaultSnapshotTimeout
)
}
}
#endif

View File

@ -0,0 +1,177 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 8/7/21.
//
// SnapshotTesting with image snapshots are only supported on macOS.
#if os(macOS)
import SnapshotTesting
import TokamakStaticHTML
import XCTest
final class VisualRenderingTests: XCTestCase {
func testContentStyles() {
assertSnapshot(
matching: HStack {
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.primary)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.secondary)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.tertiary)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.quaternary)
}
.foregroundColor(.blue),
as: .image(size: .init(width: 275, height: 100)),
timeout: defaultSnapshotTimeout
)
assertSnapshot(
matching: HStack {
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.primary)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.secondary)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.tertiary)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(HierarchicalShapeStyle.quaternary)
}
.foregroundStyle(Color.red, Color.green, Color.blue),
as: .image(size: .init(width: 275, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testForegroundStyle() {
assertSnapshot(
matching: HStack(spacing: 0) {
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(Color.red)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(Color.green)
Rectangle()
.frame(width: 50, height: 50)
.foregroundStyle(Color.blue)
},
as: .image(size: .init(width: 200, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testGradients() {
assertSnapshot(
matching: HStack {
Rectangle()
.fill(LinearGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple],
startPoint: .bottomLeading,
endPoint: .topTrailing
))
.frame(width: 80, height: 80)
Circle()
.fill(RadialGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple],
center: .center,
startRadius: 5,
endRadius: 40
))
.frame(width: 80, height: 80)
},
as: .image(size: .init(width: 200, height: 100))
)
}
func testMaterial() {
assertSnapshot(
matching: ZStack {
HStack(spacing: 0) {
Color.red
Color.orange
Color.yellow
Color.green
Color.blue
Color.purple
}
VStack(spacing: 0) {
Color.clear
.background(Material.ultraThin)
Color.clear
.background(Material.ultraThick)
}
},
as: .image(size: .init(width: 100, height: 100)),
timeout: defaultSnapshotTimeout
)
}
func testOpacity() {
struct Opacity: View {
var body: some View {
ZStack {
Circle()
.fill(Color.red)
.opacity(0.5)
.frame(width: 25, height: 25)
Circle()
.fill(Color.green)
.opacity(0.5)
.frame(width: 50, height: 50)
Circle()
.fill(Color.blue)
.opacity(0.5)
.frame(width: 75, height: 75)
}
}
}
assertSnapshot(
matching: Opacity().preferredColorScheme(.light),
as: .image(size: .init(width: 75, height: 75)),
timeout: defaultSnapshotTimeout
)
}
func testScaleEffect() {
assertSnapshot(
matching: ZStack {
Circle()
.fill(Color.red)
.frame(width: 50, height: 50)
.scaleEffect(2)
.opacity(0.5)
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
.opacity(0.5)
},
as: .image(size: .init(width: 100, height: 100)),
timeout: defaultSnapshotTimeout
)
}
}
#endif

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB