diff --git a/README.md b/README.md index 0b071d39..efb36c9a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![CI status](https://github.com/swiftwasm/Tokamak/workflows/CI/badge.svg?branch=main) At the moment Tokamak implements a very basic subset of SwiftUI. Its DOM renderer supports -a few view types, namely `Button`, `Text`, `HStack`/`VStack`/`ZStack`, the `@State` property wrapper +a few view types, namely `Button`, `Text` (and related `ViewModifiers`), `HStack`/`VStack`/`ZStack`, the `@State` property wrapper and a new `HTML` view for constructing arbitrary HTML. The long-term goal of Tokamak is to implement as much of SwiftUI API as possible and to provide a few more helpful additions that simplify HTML and CSS interactions. diff --git a/Sources/TokamakCore/Environment/Environment.swift b/Sources/TokamakCore/Environment/Environment.swift new file mode 100644 index 00000000..87d118e0 --- /dev/null +++ b/Sources/TokamakCore/Environment/Environment.swift @@ -0,0 +1,35 @@ +// Copyright 2020 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. + +@propertyWrapper public struct Environment { + enum Content { + case keyPath(KeyPath) + case value(Value) + } + + var content: Environment.Content + public init(_ keyPath: KeyPath) { + content = .keyPath(keyPath) + } + + public var wrappedValue: Value { + switch content { + case let .value(value): + return value + case let .keyPath(keyPath): + // not bound to a view, return the default value. + return EnvironmentValues()[keyPath: keyPath] + } + } +} diff --git a/Sources/TokamakCore/Environment/EnvironmentKey.swift b/Sources/TokamakCore/Environment/EnvironmentKey.swift new file mode 100644 index 00000000..76f45cab --- /dev/null +++ b/Sources/TokamakCore/Environment/EnvironmentKey.swift @@ -0,0 +1,36 @@ +// Copyright 2020 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. + +public protocol EnvironmentKey { + associatedtype Value + static var defaultValue: Self.Value { get } +} + +public struct _EnvironmentKeyWritingModifier: ViewModifier { + public typealias Body = Never + + public let keyPath: WritableKeyPath + public let value: Value + + public init(keyPath: WritableKeyPath, value: Value) { + self.keyPath = keyPath + self.value = value + } +} + +extension View { + public func environment(_ keyPath: WritableKeyPath, _ value: V) -> some View { + modifier(_EnvironmentKeyWritingModifier(keyPath: keyPath, value: value)) + } +} diff --git a/Sources/TokamakCore/Environment/EnvironmentValues.swift b/Sources/TokamakCore/Environment/EnvironmentValues.swift new file mode 100644 index 00000000..bc8dbcbb --- /dev/null +++ b/Sources/TokamakCore/Environment/EnvironmentValues.swift @@ -0,0 +1,35 @@ +// Copyright 2020 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. + +public struct EnvironmentValues: CustomStringConvertible { + public var description: String { + String(describing: values) + } + + private var values: [ObjectIdentifier: Any] = [:] + + public init() {} + + public subscript(key: K.Type) -> K.Value where K: EnvironmentKey { + get { + if let val = values[ObjectIdentifier(key)] as? K.Value { + return val + } + return K.defaultValue + } + set { + values[ObjectIdentifier(key)] = newValue + } + } +} diff --git a/Sources/TokamakCore/Modifiers/ViewModifier.swift b/Sources/TokamakCore/Modifiers/ViewModifier.swift index 9a198cf0..68cf2c83 100644 --- a/Sources/TokamakCore/Modifiers/ViewModifier.swift +++ b/Sources/TokamakCore/Modifiers/ViewModifier.swift @@ -32,3 +32,9 @@ public extension View { modifier.body(content: .init(modifier: modifier, view: AnyView(self))) } } + +extension ViewModifier where Body == Never { + public func body(content: Content) -> Body { + fatalError("\(self) is a primitive `ViewModifier`, you're not supposed to run `body(content:)`") + } +} diff --git a/Sources/TokamakCore/Props/Color.swift b/Sources/TokamakCore/Tokens/Color.swift similarity index 100% rename from Sources/TokamakCore/Props/Color.swift rename to Sources/TokamakCore/Tokens/Color.swift diff --git a/Sources/TokamakCore/Tokens/Font.swift b/Sources/TokamakCore/Tokens/Font.swift new file mode 100644 index 00000000..7d1ffe9b --- /dev/null +++ b/Sources/TokamakCore/Tokens/Font.swift @@ -0,0 +1,168 @@ +// Copyright 2018-2020 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. + +// swiftlint:disable line_length +public struct Font: Hashable { + public let _name: String + public let _size: CGFloat + public let _design: Design + public let _weight: Weight + public let _smallCaps: Bool + public let _italic: Bool + public let _bold: Bool + public let _monospaceDigit: Bool + public let _leading: Leading + + public func italic() -> Self { + .init(_name: _name, _size: _size, _design: _design, _weight: _weight, _smallCaps: _smallCaps, _italic: true, _bold: _bold, _monospaceDigit: _monospaceDigit, _leading: _leading) + } + + public func smallCaps() -> Self { + .init(_name: _name, _size: _size, _design: _design, _weight: _weight, _smallCaps: true, _italic: _italic, _bold: _bold, _monospaceDigit: _monospaceDigit, _leading: _leading) + } + + public func lowercaseSmallCaps() -> Self { + smallCaps() + } + + public func uppercaseSmallCaps() -> Self { + smallCaps() + } + + public func monospacedDigit() -> Self { + .init(_name: _name, _size: _size, _design: _design, _weight: _weight, _smallCaps: _smallCaps, _italic: _italic, _bold: _bold, _monospaceDigit: true, _leading: _leading) + } + + public func weight(_ weight: Weight) -> Self { + .init(_name: _name, _size: _size, _design: _design, _weight: weight, _smallCaps: _smallCaps, _italic: _italic, _bold: _bold, _monospaceDigit: _monospaceDigit, _leading: _leading) + } + + public func bold() -> Self { + .init(_name: _name, _size: _size, _design: _design, _weight: _weight, _smallCaps: _smallCaps, _italic: _italic, _bold: true, _monospaceDigit: _monospaceDigit, _leading: _leading) + } + + public func leading(_ leading: Leading) -> Self { + .init(_name: _name, _size: _size, _design: _design, _weight: _weight, _smallCaps: _smallCaps, _italic: _italic, _bold: true, _monospaceDigit: _monospaceDigit, _leading: leading) + } +} + +extension Font { + public struct Weight: Hashable { + public let value: Int + + public static let ultraLight: Self = .init(value: 100) + public static let thin: Self = .init(value: 200) + public static let light: Self = .init(value: 300) + public static let regular: Self = .init(value: 400) + public static let medium: Self = .init(value: 500) + public static let semibold: Self = .init(value: 600) + public static let bold: Self = .init(value: 700) + public static let heavy: Self = .init(value: 800) + public static let black: Self = .init(value: 900) + } +} + +extension Font { + public enum Leading { + case standard + case tight + case loose + } +} + +public enum _FontNames: String, CaseIterable { + case system +} + +extension Font { + public static func system(size: CGFloat, weight: Weight = .regular, design: Design = .default) -> Self { + .init(_name: _FontNames.system.rawValue, _size: size, _design: design, _weight: weight, _smallCaps: false, _italic: false, _bold: false, _monospaceDigit: false, _leading: .standard) + } + + public enum Design: Hashable { + case `default` + case serif + case rounded + case monospaced + } +} + +extension Font { + public static let largeTitle: Self = .system(size: 34) + public static let title: Self = .system(size: 28) + public static let title2: Self = .system(size: 22) + public static let title3: Self = .system(size: 20) + public static let headline: Font = .system(size: 17, weight: .semibold, design: .default) + public static let subheadline: Self = .system(size: 15) + public static let body: Self = .system(size: 17) + public static let callout: Self = .system(size: 16) + public static let footnote: Self = .system(size: 13) + public static let caption: Self = .system(size: 12) + public static let caption2: Font = .system(size: 11) + + public static func system(_ style: TextStyle, design: Design = .default) -> Self { + .system(size: style.font._size, weight: style.font._weight, design: design) + } + + public enum TextStyle: Hashable, CaseIterable { + case largeTitle + case title + case title2 + case title3 + case headline + case subheadline + case body + case callout + case footnote + case caption + case caption2 + + var font: Font { + switch self { + case .largeTitle: return .largeTitle + case .title: return .title + case .title2: return .title2 + case .title3: return .title3 + case .headline: return .headline + case .subheadline: return .subheadline + case .body: return .body + case .callout: return .callout + case .footnote: return .footnote + case .caption: return .caption + case .caption2: return .caption2 + } + } + } +} + +struct FontKey: EnvironmentKey { + static let defaultValue: Font? = nil +} + +public extension EnvironmentValues { + var font: Font? { + get { + self[FontKey.self] + } + set { + self[FontKey.self] = newValue + } + } +} + +public extension View { + func font(_ font: Font?) -> some View { + environment(\.font, font) + } +} diff --git a/Sources/TokamakCore/Props/LineBreakMode.swift b/Sources/TokamakCore/Tokens/LineBreakMode.swift similarity index 100% rename from Sources/TokamakCore/Props/LineBreakMode.swift rename to Sources/TokamakCore/Tokens/LineBreakMode.swift diff --git a/Sources/TokamakCore/Props/Rectangle.swift b/Sources/TokamakCore/Tokens/Rectangle.swift similarity index 100% rename from Sources/TokamakCore/Props/Rectangle.swift rename to Sources/TokamakCore/Tokens/Rectangle.swift diff --git a/Sources/TokamakCore/Props/TextAlignment.swift b/Sources/TokamakCore/Tokens/TextAlignment.swift similarity index 100% rename from Sources/TokamakCore/Props/TextAlignment.swift rename to Sources/TokamakCore/Tokens/TextAlignment.swift diff --git a/Sources/TokamakCore/Views/Text.swift b/Sources/TokamakCore/Views/Text.swift index f1c0522c..02563499 100644 --- a/Sources/TokamakCore/Views/Text.swift +++ b/Sources/TokamakCore/Views/Text.swift @@ -17,13 +17,32 @@ public struct Text: View { let content: String + public let _modifiers: [_Modifier] + + public enum _Modifier: Equatable { + case color(Color?) + case font(Font?) + case italic + case weight(Font.Weight?) + case kerning(CGFloat) + case tracking(CGFloat) + case baseline(CGFloat) + case rounded + case strikethrough(Bool, Color?) // Note: Not in SwiftUI + case underline(Bool, Color?) // Note: Not in SwiftUI + } + + init(content: String, modifiers: [_Modifier] = []) { + self.content = content + _modifiers = modifiers + } public init(verbatim content: String) { - self.content = content + self.init(content: content) } public init(_ content: S) where S: StringProtocol { - self.content = String(content) + self.init(content: String(content)) } public var body: Never { @@ -34,3 +53,45 @@ public struct Text: View { public func textContent(_ text: Text) -> String { text.content } + +public extension Text { + func foregroundColor(_ color: Color?) -> Text { + .init(content: content, modifiers: _modifiers + [.color(color)]) + } + + func font(_ font: Font?) -> Text { + .init(content: content, modifiers: _modifiers + [.font(font)]) + } + + func fontWeight(_ weight: Font.Weight?) -> Text { + .init(content: content, modifiers: _modifiers + [.weight(weight)]) + } + + func bold() -> Text { + .init(content: content, modifiers: _modifiers + [.weight(.bold)]) + } + + func italic() -> Text { + .init(content: content, modifiers: _modifiers + [.italic]) + } + + func strikethrough(_ active: Bool = true, color: Color? = nil) -> Text { + .init(content: content, modifiers: _modifiers + [.strikethrough(active, color)]) + } + + func underline(_ active: Bool = true, color: Color? = nil) -> Text { + .init(content: content, modifiers: _modifiers + [.underline(active, color)]) + } + + func kerning(_ kerning: CGFloat) -> Text { + .init(content: content, modifiers: _modifiers + [.kerning(kerning)]) + } + + func tracking(_ tracking: CGFloat) -> Text { + .init(content: content, modifiers: _modifiers + [.tracking(tracking)]) + } + + func baselineOffset(_ baselineOffset: CGFloat) -> Text { + .init(content: content, modifiers: _modifiers + [.baseline(baselineOffset)]) + } +} diff --git a/Sources/TokamakDOM/Tokens/Tokens.swift b/Sources/TokamakDOM/Tokens/Tokens.swift new file mode 100644 index 00000000..8479ef65 --- /dev/null +++ b/Sources/TokamakDOM/Tokens/Tokens.swift @@ -0,0 +1,23 @@ +// Copyright 2020 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 TokamakCore + +public typealias Font = TokamakCore.Font + +extension Color: CustomStringConvertible { + public var description: String { + "rgb(\(red * 255), \(green * 255), \(blue * 255), \(alpha * 255))" + } +} diff --git a/Sources/TokamakDOM/Views/HTML.swift b/Sources/TokamakDOM/Views/HTML.swift index 6deab280..482fe021 100644 --- a/Sources/TokamakDOM/Views/HTML.swift +++ b/Sources/TokamakDOM/Views/HTML.swift @@ -92,3 +92,14 @@ extension HTML: ParentView { [AnyView(content)] } } + +protocol StylesConvertible { + var styles: [String: String] { get } +} + +extension Dictionary { + var inlineStyles: String { + map { "\($0.0): \($0.1);" } + .joined(separator: " ") + } +} diff --git a/Sources/TokamakDOM/Views/Text.swift b/Sources/TokamakDOM/Views/Text.swift index 61d93261..0f28b6e1 100644 --- a/Sources/TokamakDOM/Views/Text.swift +++ b/Sources/TokamakDOM/Views/Text.swift @@ -17,9 +17,139 @@ import TokamakCore public typealias Text = TokamakCore.Text +extension Font.Design: CustomStringConvertible { + /// Some default font stacks for the various designs + public var description: String { + switch self { + case .default: + return #""" + system, + -apple-system, + '.SFNSText-Regular', + 'San Francisco', + 'Roboto', + 'Segoe UI', + 'Helvetica Neue', + 'Lucida Grande', + sans-serif + """# + case .monospaced: + return #""" + Consolas, + 'Andale Mono WT', + 'Andale Mono', + 'Lucida Console', + 'Lucida Sans Typewriter', + 'DejaVu Sans Mono', + 'Bitstream Vera Sans Mono', + 'Liberation Mono', + 'Nimbus Mono L', + Monaco, + 'Courier New', + Courier, + monospace + """# + case .rounded: // Not supported due to browsers not having a rounded font builtin + return Self.default.description + case .serif: + return #""" + Cambria, + 'Hoefler Text', + Utopia, + 'Liberation Serif', + 'Nimbus Roman No9 L Regular', + Times, + 'Times New Roman', + serif + """# + } + } +} + +extension Font.Leading: CustomStringConvertible { + public var description: String { + switch self { + case .standard: + return "normal" + case .loose: + return "1.5" + case .tight: + return "0.5" + } + } +} + +extension Font: StylesConvertible { + var styles: [String: String] { + [ + "font-family": _name == _FontNames.system.rawValue ? _design.description : _name, + "font-weight": "\(_bold ? Font.Weight.bold.value : _weight.value)", + "font-style": _italic ? "italic" : "normal", + "font-size": "\(_size)", + "line-height": _leading.description, + "font-variant": _smallCaps ? "small-caps" : "normal", + ] + } +} + extension Text: AnyHTML { public var innerHTML: String? { textContent(self) } var tag: String { "span" } - var attributes: [String: String] { [:] } + var attributes: [String: String] { + var font: Font? + var color: Color? + var italic: Bool = false + var weight: Font.Weight? + var kerning: String = "normal" + var baseline: CGFloat? + var strikethrough: (Bool, Color?)? + var underline: (Bool, Color?)? + for modifier in _modifiers { + switch modifier { + case let .color(_color): + color = _color + case let .font(_font): + font = _font + case .italic: + italic = true + case let .weight(_weight): + weight = _weight + case let .kerning(_kerning), let .tracking(_kerning): + kerning = "\(_kerning)em" + case let .baseline(_baseline): + baseline = _baseline + case .rounded: break + case let .strikethrough(active, color): + strikethrough = (active, color) + case let .underline(active, color): + underline = (active, color) + } + } + let hasStrikethrough = strikethrough?.0 ?? false + let hasUnderline = underline?.0 ?? false + let textDecoration = !hasStrikethrough && !hasUnderline ? + "none" : + "\(hasStrikethrough ? "line-through" : "") \(hasUnderline ? "underline" : "")" + return [ + "style": """ + \(font?.styles.filter { + if weight != nil { + return $0.key != "font-weight" + } else { + return true + } + }.inlineStyles ?? "") + \(font == nil ? "font-family: \(Font.Design.default.description);" : "") + color: \(color?.description ?? "inherit"); + font-style: \(italic ? "italic" : "normal"); + font-weight: \(weight?.value ?? font?._weight.value ?? 400); + letter-spacing: \(kerning); + vertical-align: \(baseline == nil ? "baseline" : "\(baseline!)em"); + text-decoration: \(textDecoration); + text-decoration-color: \(strikethrough?.1?.description ?? underline?.1?.description ?? "inherit") + """, + ] + } + var listeners: [String: Listener] { [:] } } diff --git a/Sources/TokamakDemo/main.swift b/Sources/TokamakDemo/main.swift index d36df102..cf47d769 100644 --- a/Sources/TokamakDemo/main.swift +++ b/Sources/TokamakDemo/main.swift @@ -42,6 +42,32 @@ let renderer = DOMRenderer( ForEachDemo() Text("This is the inital text") .modifier(CustomModifier()) + Text("I'm all fancy") + .font(.system(size: 16, weight: .regular, design: .serif)) + .italic() + HStack { + ForEach([ + Font.Weight.ultraLight, + .thin, + .light, + .regular, + .semibold, + .bold, + .heavy, + .black, + ], id: \.self) { weight in + Text("a") + .fontWeight(weight) + } + } + Text("This is super important") + .bold() + .underline(true, color: .red) + Text("This was super important") + .bold() + .strikethrough(true, color: .red) + Text("THICK TEXT") + .kerning(0.5) SVGCircle() }, div