Revise ShapeStyle and add Gradients (#435)
|
@ -209,7 +209,7 @@ let package = Package(
|
|||
condition: .when(platforms: [.macOS])
|
||||
),
|
||||
],
|
||||
exclude: ["__Snapshots__"]
|
||||
exclude: ["__Snapshots__", "RenderingTests/__Snapshots__"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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) {}
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {}
|
||||
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -56,17 +56,67 @@ 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(
|
||||
} else {
|
||||
let resolved = style.resolve(
|
||||
for: .resolveStyle(levels: 0..<1),
|
||||
in: environment,
|
||||
role: Content.role
|
||||
)?.color(at: 0) {
|
||||
)
|
||||
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,
|
||||
} else if
|
||||
let foregroundStyle = environment._foregroundStyle,
|
||||
let color = foregroundStyle.resolve(
|
||||
for: .resolveStyle(levels: 0..<1),
|
||||
in: environment,
|
||||
|
@ -77,6 +127,8 @@ extension _ShapeView: _HTMLPrimitive {
|
|||
} else {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let view = mapAnyView(path, transform: { (html: HTML<AnyView>) -> AnyView in
|
||||
let uniqueKeys = { (first: String, second: String) in "\(first) \(second)" }
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 1008 B After Width: | Height: | Size: 1008 B |
Before Width: | Height: | Size: 1007 B After Width: | Height: | Size: 1007 B |
Before Width: | Height: | Size: 319 B After Width: | Height: | Size: 319 B |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 886 B After Width: | Height: | Size: 886 B |
After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |