Skip to content

Commit

Permalink
Add aspectRatio modifier (#422)
Browse files Browse the repository at this point in the history
  • Loading branch information
carson-katri authored Jul 12, 2021
1 parent b6790c5 commit ff3f81d
Show file tree
Hide file tree
Showing 15 changed files with 314 additions and 28 deletions.
71 changes: 71 additions & 0 deletions Sources/TokamakCore/Modifiers/AspectRatioLayout.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
123 changes: 108 additions & 15 deletions Sources/TokamakCore/Views/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand All @@ -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 }
}
24 changes: 22 additions & 2 deletions Sources/TokamakCore/Views/Text/Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 12 additions & 5 deletions Sources/TokamakGTK/Views/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,26 @@ import TokamakCore
extension Image: AnyWidget {
func new(_ application: UnsafeMutablePointer<GtkApplication>) -> UnsafeMutablePointer<GtkWidget> {
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 ""
}
}
}
14 changes: 14 additions & 0 deletions Sources/TokamakStaticHTML/Modifiers/LayoutModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")",
]
}
}
8 changes: 8 additions & 0 deletions Sources/TokamakStaticHTML/Resources/TokamakStyles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 17 additions & 6 deletions Sources/TokamakStaticHTML/Views/Images/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
22 changes: 22 additions & 0 deletions Tests/TokamakStaticHTMLTests/RenderingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit ff3f81d

Please # to comment.