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:
parent
ab2e907f9d
commit
b7d7b125b2
|
@ -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.
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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:)`")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))"
|
||||
}
|
||||
}
|
|
@ -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: " ")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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] { [:] }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue