diff --git a/Sources/TokamakCore/Modifiers/AspectRatioLayout.swift b/Sources/TokamakCore/Modifiers/AspectRatioLayout.swift new file mode 100644 index 000000000..2a40a25eb --- /dev/null +++ b/Sources/TokamakCore/Modifiers/AspectRatioLayout.swift @@ -0,0 +1,71 @@ +// 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. + +import Foundation + +@frozen public enum ContentMode: Hashable, CaseIterable { + case fit + case fill +} + +public struct _AspectRatioLayout: ViewModifier { + public let aspectRatio: CGFloat? + public let contentMode: ContentMode + + @inlinable + public init(aspectRatio: CGFloat?, contentMode: ContentMode) { + self.aspectRatio = aspectRatio + self.contentMode = contentMode + } + + public func body(content: Content) -> some View { + content + } +} + +public extension View { + @inlinable + func aspectRatio( + _ aspectRatio: CGFloat? = nil, + contentMode: ContentMode + ) -> some View { + modifier( + _AspectRatioLayout( + aspectRatio: aspectRatio, + contentMode: contentMode + ) + ) + } + + @inlinable + func aspectRatio( + _ aspectRatio: CGSize, + contentMode: ContentMode + ) -> some View { + self.aspectRatio( + aspectRatio.width / aspectRatio.height, + contentMode: contentMode + ) + } + + @inlinable + func scaledToFit() -> some View { + aspectRatio(contentMode: .fit) + } + + @inlinable + func scaledToFill() -> some View { + aspectRatio(contentMode: .fill) + } +} diff --git a/Sources/TokamakCore/Views/Image.swift b/Sources/TokamakCore/Views/Image.swift index 0107808cd..68b93e118 100644 --- a/Sources/TokamakCore/Views/Image.swift +++ b/Sources/TokamakCore/Views/Image.swift @@ -17,27 +17,121 @@ import Foundation -public struct Image: _PrimitiveView { - let label: Text? +public class _AnyImageProviderBox: AnyTokenBox, Equatable { + public struct _Image { + public indirect enum Storage { + case named(String, bundle: Bundle?) + case resizable(Storage, capInsets: EdgeInsets, resizingMode: Image.ResizingMode) + } + + public let storage: Storage + public let label: Text? + } + + public static func == (lhs: _AnyImageProviderBox, rhs: _AnyImageProviderBox) -> Bool { + lhs.equals(rhs) + } + + public func equals(_ other: _AnyImageProviderBox) -> Bool { + fatalError("implement \(#function) in subclass") + } + + public func resolve(in environment: EnvironmentValues) -> _Image { + fatalError("implement \(#function) in subclass") + } +} + +private class NamedImageProvider: _AnyImageProviderBox { let name: String let bundle: Bundle? + let label: Text? - public init(_ name: String, bundle: Bundle? = nil) { - label = Text(name) + init(name: String, bundle: Bundle?, label: Text?) { self.name = name self.bundle = bundle + self.label = label } - public init(_ name: String, bundle: Bundle? = nil, label: Text) { - self.label = label - self.name = name - self.bundle = bundle + override func equals(_ other: _AnyImageProviderBox) -> Bool { + guard let other = other as? NamedImageProvider else { return false } + return other.name == name + && other.bundle?.bundlePath == bundle?.bundlePath + && other.label == label } - public init(decorative name: String, bundle: Bundle? = nil) { - label = nil - self.name = name - self.bundle = bundle + override func resolve(in environment: EnvironmentValues) -> ResolvedValue { + .init(storage: .named(name, bundle: bundle), label: label) + } +} + +private class ResizableProvider: _AnyImageProviderBox { + let parent: _AnyImageProviderBox + let capInsets: EdgeInsets + let resizingMode: Image.ResizingMode + + init(parent: _AnyImageProviderBox, capInsets: EdgeInsets, resizingMode: Image.ResizingMode) { + self.parent = parent + self.capInsets = capInsets + self.resizingMode = resizingMode + } + + override func equals(_ other: _AnyImageProviderBox) -> Bool { + guard let other = other as? ResizableProvider else { return false } + return other.parent.equals(parent) + && other.capInsets == capInsets + && other.resizingMode == resizingMode + } + + override func resolve(in environment: EnvironmentValues) -> ResolvedValue { + let resolved = parent.resolve(in: environment) + return .init( + storage: .resizable( + resolved.storage, + capInsets: capInsets, + resizingMode: resizingMode + ), + label: resolved.label + ) + } +} + +public struct Image: _PrimitiveView, Equatable { + let provider: _AnyImageProviderBox + @Environment(\.self) var environment + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.provider == rhs.provider + } + + init(_ provider: _AnyImageProviderBox) { + self.provider = provider + } +} + +public extension Image { + init(_ name: String, bundle: Bundle? = nil) { + self.init(name, bundle: bundle, label: Text(name)) + } + + init(_ name: String, bundle: Bundle? = nil, label: Text) { + self.init(NamedImageProvider(name: name, bundle: bundle, label: label)) + } + + init(decorative name: String, bundle: Bundle? = nil) { + self.init(NamedImageProvider(name: name, bundle: bundle, label: nil)) + } +} + +public extension Image { + enum ResizingMode: Hashable { + case tile + case stretch + } + + func resizable(capInsets: EdgeInsets = EdgeInsets(), + resizingMode: ResizingMode = .stretch) -> Image + { + .init(ResizableProvider(parent: provider, capInsets: capInsets, resizingMode: resizingMode)) } } @@ -47,7 +141,6 @@ public struct _ImageProxy { public init(_ subject: Image) { self.subject = subject } - public var labelString: String? { subject.label?.storage.rawText } - public var name: String { subject.name } - public var path: String? { subject.bundle?.path(forResource: subject.name, ofType: nil) } + public var provider: _AnyImageProviderBox { subject.provider } + public var environment: EnvironmentValues { subject.environment } } diff --git a/Sources/TokamakCore/Views/Text/Text.swift b/Sources/TokamakCore/Views/Text/Text.swift index d47903c77..0faf5f7d8 100644 --- a/Sources/TokamakCore/Views/Text/Text.swift +++ b/Sources/TokamakCore/Views/Text/Text.swift @@ -31,15 +31,35 @@ import Foundation /// .bold() /// .italic() /// .underline(true, color: .red) -public struct Text: _PrimitiveView { +public struct Text: _PrimitiveView, Equatable { let storage: _Storage let modifiers: [_Modifier] @Environment(\.self) var environment - public enum _Storage { + public static func == (lhs: Text, rhs: Text) -> Bool { + lhs.storage == rhs.storage + && lhs.modifiers == rhs.modifiers + } + + public enum _Storage: Equatable { case verbatim(String) case segmentedText([(_Storage, [_Modifier])]) + + public static func == (lhs: Text._Storage, rhs: Text._Storage) -> Bool { + switch lhs { + case let .verbatim(lhsVerbatim): + guard case let .verbatim(rhsVerbatim) = rhs else { return false } + return lhsVerbatim == rhsVerbatim + case let .segmentedText(lhsSegments): + guard case let .segmentedText(rhsSegments) = rhs, + lhsSegments.count == rhsSegments.count else { return false } + return lhsSegments.enumerated().allSatisfy { + $0.element.0 == rhsSegments[$0.offset].0 + && $0.element.1 == rhsSegments[$0.offset].1 + } + } + } } public enum _Modifier: Equatable { diff --git a/Sources/TokamakGTK/Views/Image.swift b/Sources/TokamakGTK/Views/Image.swift index 7e318eab9..b7a4cf63c 100644 --- a/Sources/TokamakGTK/Views/Image.swift +++ b/Sources/TokamakGTK/Views/Image.swift @@ -22,19 +22,26 @@ import TokamakCore extension Image: AnyWidget { func new(_ application: UnsafeMutablePointer) -> UnsafeMutablePointer { let proxy = _ImageProxy(self) - let imagePath = proxy.path ?? proxy.name - let img = gtk_image_new_from_file(imagePath)! + let img = gtk_image_new_from_file(imagePath(for: proxy))! return img } func update(widget: Widget) { if case let .widget(w) = widget.storage { let proxy = _ImageProxy(self) - let imagePath = proxy.path ?? proxy.name - w.withMemoryRebound(to: GtkImage.self, capacity: 1) { - gtk_image_set_from_file($0, imagePath) + gtk_image_set_from_file($0, imagePath(for: proxy)) } } } + + func imagePath(for proxy: _ImageProxy) -> String { + let resolved = proxy.provider.resolve(in: proxy.environment) + switch resolved.storage { + case let .named(name, bundle), + let .resizable(.named(name, bundle), _, _): + return bundle?.path(forResource: name, ofType: nil) ?? name + default: return "" + } + } } diff --git a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift index 433d24c44..f1fda458e 100644 --- a/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift +++ b/Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift @@ -138,3 +138,17 @@ extension _ShadowLayout: DOMViewModifier { public var isOrderDependent: Bool { true } } + +extension _AspectRatioLayout: DOMViewModifier { + public var isOrderDependent: Bool { true } + public var attributes: [HTMLAttribute: String] { + [ + "style": """ + aspect-ratio: \(aspectRatio ?? 1)/1; + margin: 0 auto; + \(contentMode == ((aspectRatio ?? 1) > 1 ? .fill : .fit) ? "height: 100%" : "width: 100%"); + """, + "class": "_tokamak-aspect-ratio-\(contentMode == .fill ? "fill" : "fit")", + ] + } +} diff --git a/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift b/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift index 11d34fab3..558dd4238 100644 --- a/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift +++ b/Sources/TokamakStaticHTML/Resources/TokamakStyles.swift @@ -119,6 +119,14 @@ public let tokamakStyles = """ height: 100%; } +._tokamak-aspect-ratio-fill > img { + object-fit: fill; +} + +._tokamak-aspect-ratio-fit > img { + object-fit: contain; +} + @media (prefers-color-scheme:dark) { ._tokamak-text-redacted::after { background-color: rgb(100, 100, 100); diff --git a/Sources/TokamakStaticHTML/Views/Images/Image.swift b/Sources/TokamakStaticHTML/Views/Images/Image.swift index 7bd738853..1c5567f32 100644 --- a/Sources/TokamakStaticHTML/Views/Images/Image.swift +++ b/Sources/TokamakStaticHTML/Views/Images/Image.swift @@ -29,12 +29,23 @@ extension Image: _HTMLPrimitive { struct _HTMLImage: View { let proxy: _ImageProxy public var body: some View { - var attributes: [HTMLAttribute: String] = [ - "src": proxy.path ?? proxy.name, - "style": "max-width: 100%; max-height: 100%", - ] - if let label = proxy.labelString { - attributes["alt"] = label + let resolved = proxy.provider.resolve(in: proxy.environment) + var attributes: [HTMLAttribute: String] = [:] + switch resolved.storage { + case let .named(name, bundle): + attributes = [ + "src": bundle?.path(forResource: name, ofType: nil) ?? name, + "style": "max-width: 100%; max-height: 100%", + ] + case let .resizable(.named(name, bundle), _, _): + attributes = [ + "src": bundle?.path(forResource: name, ofType: nil) ?? name, + "style": "width: 100%; height: 100%", + ] + default: break + } + if let label = resolved.label { + attributes["alt"] = _TextProxy(label).rawText } return AnyView(HTML("img", attributes)) } diff --git a/Tests/TokamakStaticHTMLTests/RenderingTests.swift b/Tests/TokamakStaticHTMLTests/RenderingTests.swift index 13b98e029..7185fbb5d 100644 --- a/Tests/TokamakStaticHTMLTests/RenderingTests.swift +++ b/Tests/TokamakStaticHTMLTests/RenderingTests.swift @@ -254,6 +254,28 @@ final class RenderingTests: XCTestCase { 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 + ) + } } #endif diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt index 27f8ab554..1122808ba 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.1.txt @@ -106,6 +106,14 @@ height: 100%; } +._tokamak-aspect-ratio-fill > img { + object-fit: fill; +} + +._tokamak-aspect-ratio-fit > img { + object-fit: contain; +} + @media (prefers-color-scheme:dark) { ._tokamak-text-redacted::after { background-color: rgb(100, 100, 100); diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt index 460574bf9..67c3d5c01 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testFontStacks.2.txt @@ -106,6 +106,14 @@ height: 100%; } +._tokamak-aspect-ratio-fill > img { + object-fit: fill; +} + +._tokamak-aspect-ratio-fit > img { + object-fit: contain; +} + @media (prefers-color-scheme:dark) { ._tokamak-text-redacted::after { background-color: rgb(100, 100, 100); diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt index 394cde2a5..6d91d8b53 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testOptional.1.txt @@ -106,6 +106,14 @@ height: 100%; } +._tokamak-aspect-ratio-fill > img { + object-fit: fill; +} + +._tokamak-aspect-ratio-fit > img { + object-fit: contain; +} + @media (prefers-color-scheme:dark) { ._tokamak-text-redacted::after { background-color: rgb(100, 100, 100); diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt index 54e36f2aa..b5b8af2a5 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.1.txt @@ -106,6 +106,14 @@ height: 100%; } +._tokamak-aspect-ratio-fill > img { + object-fit: fill; +} + +._tokamak-aspect-ratio-fit > img { + object-fit: contain; +} + @media (prefers-color-scheme:dark) { ._tokamak-text-redacted::after { background-color: rgb(100, 100, 100); diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt index c17e1a487..7b232a063 100644 --- a/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt +++ b/Tests/TokamakStaticHTMLTests/__Snapshots__/HTMLTests/testPaddingFusion.2.txt @@ -106,6 +106,14 @@ height: 100%; } +._tokamak-aspect-ratio-fill > img { + object-fit: fill; +} + +._tokamak-aspect-ratio-fit > img { + object-fit: contain; +} + @media (prefers-color-scheme:dark) { ._tokamak-text-redacted::after { background-color: rgb(100, 100, 100); diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/RenderingTests/testAspectRatio.1.png b/Tests/TokamakStaticHTMLTests/__Snapshots__/RenderingTests/testAspectRatio.1.png new file mode 100644 index 000000000..15b1acc8f Binary files /dev/null and b/Tests/TokamakStaticHTMLTests/__Snapshots__/RenderingTests/testAspectRatio.1.png differ diff --git a/Tests/TokamakStaticHTMLTests/__Snapshots__/RenderingTests/testAspectRatio.2.png b/Tests/TokamakStaticHTMLTests/__Snapshots__/RenderingTests/testAspectRatio.2.png new file mode 100644 index 000000000..43c38dc9a Binary files /dev/null and b/Tests/TokamakStaticHTMLTests/__Snapshots__/RenderingTests/testAspectRatio.2.png differ