Add support for Text Modifiers (#114)

* Text styles and Environment setup for View font

* Text modifiers and demo

* Format Source files

* Fix font-size and add font-family when no Font is specified

* Add TextStyle

* PR fixes

* Format files

* Add note about Text modifiers
This commit is contained in:
Carson Katri 2020-06-28 18:05:53 -04:00 committed by GitHub
parent ab2e907f9d
commit b7d7b125b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 535 additions and 4 deletions

View File

@ -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.

View File

@ -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<Value> {
enum Content {
case keyPath(KeyPath<EnvironmentValues, Value>)
case value(Value)
}
var content: Environment<Value>.Content
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
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]
}
}
}

View File

@ -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<Value>: ViewModifier {
public typealias Body = Never
public let keyPath: WritableKeyPath<EnvironmentValues, Value>
public let value: Value
public init(keyPath: WritableKeyPath<EnvironmentValues, Value>, value: Value) {
self.keyPath = keyPath
self.value = value
}
}
extension View {
public func environment<V>(_ keyPath: WritableKeyPath<EnvironmentValues, V>, _ value: V) -> some View {
modifier(_EnvironmentKeyWritingModifier(keyPath: keyPath, value: value))
}
}

View File

@ -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<K>(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
}
}
}

View File

@ -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:)`")
}
}

View File

@ -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)
}
}

View File

@ -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<S>(_ 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)])
}
}

View File

@ -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))"
}
}

View File

@ -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: " ")
}
}

View File

@ -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] { [:] }
}

View File

@ -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