Skip to content

Commit 7e4028b

Browse files
authored
Merge pull request #137 from liveviewnative/link
Add `Link` and `Text` interpolation
2 parents f82052d + 5d6e43c commit 7e4028b

File tree

6 files changed

+288
-10
lines changed

6 files changed

+288
-10
lines changed

Sources/LiveViewNative/BuiltinRegistry.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ struct BuiltinRegistry {
4444
Shape(element: element, context: context, shape: Rectangle())
4545
case "roundedrectangle":
4646
Shape(element: element, context: context, shape: RoundedRectangle(from: element))
47+
case "lvn-link":
48+
Link(element: element, context: context)
4749

4850
case "phx-form":
4951
PhxForm<R>(element: element, context: context)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// Link.swift
3+
//
4+
//
5+
// Created by Carson Katri on 1/12/23.
6+
//
7+
8+
import SwiftUI
9+
10+
struct Link<R: CustomRegistry>: View {
11+
@ObservedElement private var element: ElementNode
12+
let context: LiveContext<R>
13+
14+
init(element: ElementNode, context: LiveContext<R>) {
15+
self.context = context
16+
}
17+
18+
public var body: some View {
19+
SwiftUI.Link(
20+
destination: URL(string: element.attributeValue(for: "destination")!)!
21+
) {
22+
context.buildChildren(of: element)
23+
}
24+
}
25+
}

Sources/LiveViewNative/Views/Text Input and Output/Text.swift

Lines changed: 147 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,158 @@
77

88
import SwiftUI
99

10-
struct Text: View {
11-
@ObservedElement private var element: ElementNode
10+
/// A formatter that parses ISO8601 dates as produced by Elixir's `DateTime`.
11+
fileprivate let dateTimeFormatter: ISO8601DateFormatter = {
12+
let formatter = ISO8601DateFormatter()
13+
formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
14+
return formatter
15+
}()
16+
17+
/// A formatter that parses ISO8601 dates as produced by Elixir's `Date`.
18+
fileprivate let dateFormatter: DateFormatter = {
19+
let formatter = DateFormatter()
20+
formatter.dateFormat = "yyyy-MM-dd"
21+
return formatter
22+
}()
23+
24+
struct Text<R: CustomRegistry>: View {
25+
let element: ElementNode
26+
let context: LiveContext<R>
1227

13-
init(element: ElementNode, context: LiveContext<some CustomRegistry>) {
28+
init(element: ElementNode, context: LiveContext<R>) {
29+
self.element = element
30+
self.context = context
1431
}
1532

16-
public var body: some View {
17-
SwiftUI.Text(element.innerText())
33+
public var body: SwiftUI.Text {
34+
text
1835
.font(self.font)
1936
.foregroundColor(textColor)
2037
}
2138

39+
private func formatDate(_ date: String) -> Date? {
40+
dateTimeFormatter.date(from: date) ?? dateFormatter.date(from: date)
41+
}
42+
43+
private var text: SwiftUI.Text {
44+
if let verbatim = element.attributeValue(for: "verbatim") {
45+
return SwiftUI.Text(verbatim: verbatim)
46+
} else if let date = element.attributeValue(for: "date").flatMap(formatDate) {
47+
return SwiftUI.Text(date, style: dateStyle)
48+
} else if let dateStart = element.attributeValue(for: "date-start").flatMap(formatDate),
49+
let dateEnd = element.attributeValue(for: "date-end").flatMap(formatDate) {
50+
return SwiftUI.Text(dateStart...dateEnd)
51+
} else if let markdown = element.attributeValue(for: "markdown") {
52+
return SwiftUI.Text(.init(markdown))
53+
} else if let format = element.attributeValue(for: "format") {
54+
let innerText = element.attributeValue(for: "value") ?? element.innerText()
55+
switch format {
56+
case "date-time":
57+
if let date = formatDate(innerText) {
58+
return SwiftUI.Text(date, format: .dateTime)
59+
} else {
60+
return SwiftUI.Text(innerText)
61+
}
62+
case "url":
63+
if let url = URL(string: innerText) {
64+
return SwiftUI.Text(url, format: .url)
65+
} else {
66+
return SwiftUI.Text(innerText)
67+
}
68+
case "iso8601":
69+
if let date = formatDate(innerText) {
70+
return SwiftUI.Text(date, format: .iso8601)
71+
} else {
72+
return SwiftUI.Text(innerText)
73+
}
74+
case "number":
75+
if let number = Double(innerText) {
76+
return SwiftUI.Text(number, format: .number)
77+
} else {
78+
return SwiftUI.Text(innerText)
79+
}
80+
case "percent":
81+
if let number = Double(innerText) {
82+
return SwiftUI.Text(number, format: .percent)
83+
} else {
84+
return SwiftUI.Text("")
85+
}
86+
case "currency":
87+
if let code = element.attributeValue(for: "currency-code"),
88+
let number = Double(innerText) {
89+
return SwiftUI.Text(number, format: .currency(code: code))
90+
} else {
91+
return SwiftUI.Text(innerText)
92+
}
93+
case "name":
94+
if let style = element.attributeValue(for: "name-style"),
95+
let nameComponents = try? PersonNameComponents(innerText) {
96+
var nameStyle: PersonNameComponents.FormatStyle.Style {
97+
switch style {
98+
case "short":
99+
return .short
100+
case "medium":
101+
return .medium
102+
case "long":
103+
return .long
104+
case "abbreviated":
105+
return .abbreviated
106+
default:
107+
return .medium
108+
}
109+
}
110+
return SwiftUI.Text(nameComponents, format: .name(style: nameStyle))
111+
} else {
112+
return SwiftUI.Text(innerText)
113+
}
114+
default:
115+
return SwiftUI.Text(innerText)
116+
}
117+
} else {
118+
return element.children().reduce(into: SwiftUI.Text("")) { prev, next in
119+
if let element = next.asElement() {
120+
switch element.tag {
121+
case "text":
122+
prev = prev + Self(element: element, context: context).body
123+
case "lvn-link":
124+
prev = prev + SwiftUI.Text(
125+
.init("[\(element.innerText())](\(element.attributeValue(for: "destination")!))")
126+
)
127+
case "image":
128+
if let systemName = element.attributeValue(for: "system-name") {
129+
prev = prev + SwiftUI.Text(SwiftUI.Image(systemName: systemName))
130+
} else if let name = element.attributeValue(for: "name") {
131+
prev = prev + SwiftUI.Text(SwiftUI.Image(systemName: name))
132+
} else {
133+
preconditionFailure("<image> must have system-name or name")
134+
}
135+
default:
136+
break
137+
}
138+
} else {
139+
prev = prev + SwiftUI.Text(next.toString())
140+
}
141+
}
142+
}
143+
}
144+
145+
private var dateStyle: SwiftUI.Text.DateStyle {
146+
switch element.attributeValue(for: "date-style") {
147+
case "time":
148+
return .time
149+
case "date":
150+
return .date
151+
case "relative":
152+
return .relative
153+
case "offset":
154+
return .offset
155+
case "timer":
156+
return .timer
157+
default:
158+
return .date
159+
}
160+
}
161+
22162
private var font: Font? {
23163
let font: Font?
24164
switch element.attributeValue(for: "font")?.lowercased() {
@@ -59,6 +199,8 @@ struct Text: View {
59199
weight = Font.Weight.light
60200
case "regular":
61201
weight = Font.Weight.regular
202+
case "medium":
203+
weight = Font.Weight.medium
62204
case "semibold":
63205
weight = Font.Weight.semibold
64206
case "thin":

Tests/RenderingTests/LinkTests.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// File.swift
3+
//
4+
//
5+
// Created by Carson Katri on 1/17/23.
6+
//
7+
8+
import XCTest
9+
import SwiftUI
10+
@testable import LiveViewNative
11+
12+
@MainActor
13+
final class LinkTests: XCTestCase {
14+
func testSimple() throws {
15+
try assertMatch(#"<lvn-link destination="https://apple.com">Hello, world!</lvn-link>"#) {
16+
Link("Hello, world!", destination: URL(string: "https://apple.com")!)
17+
}
18+
}
19+
20+
func testComplexBody() throws {
21+
try assertMatch(#"""
22+
<lvn-link destination="https://apple.com">
23+
<hstack>
24+
<image system-name="link" />
25+
<text>Click the link</text>
26+
</hstack>
27+
</lvn-link>
28+
"""#) {
29+
Link(destination: URL(string: "https://apple.com")!) {
30+
HStack {
31+
Image(systemName: "link")
32+
Text("Click the link")
33+
}
34+
}
35+
}
36+
}
37+
}

Tests/RenderingTests/TextTests.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ final class TextTests: XCTestCase {
1616
Text("Hello, world!")
1717
}
1818
}
19+
1920
func testStyles() throws {
2021
for style in Font.TextStyle.allCases {
2122
try assertMatch(#"<text font="\#(style)">Hello, world!</text>"#) {
2223
Text("Hello, world!").font(.system(style, weight: .regular))
2324
}
2425
}
2526
}
27+
2628
func testWeights() throws {
2729
let allWeights: [String:Font.Weight] = [
2830
"ultraLight": .ultraLight,
@@ -41,11 +43,79 @@ final class TextTests: XCTestCase {
4143
}
4244
}
4345
}
46+
4447
func testColor() throws {
4548
for color in [Color.primary, Color.red, Color.blue] {
4649
try assertMatch(#"<text color="system-\#(color)">Hello, world!</text>"#) {
4750
Text("Hello, world!").foregroundColor(color)
4851
}
4952
}
5053
}
54+
55+
func testNesting() throws {
56+
try assertMatch(#"""
57+
<text>
58+
<image system-name="person.crop.circle.fill" />
59+
<text verbatim=" " />
60+
<text color="system-secondary">John Doe</text>
61+
<text verbatim="
62+
" />
63+
Plain text<text verbatim=" " />
64+
<lvn-link destination="https://apple.com">visit apple.com</lvn-link>
65+
<text>. More plain text</text>
66+
</text>
67+
"""#) {
68+
Text("""
69+
\(Image(systemName: "person.crop.circle.fill")) \(Text("John Doe").foregroundColor(.secondary))
70+
Plain text [visit apple.com](https://apple.com). More plain text
71+
""")
72+
}
73+
}
74+
75+
func testMarkdown() throws {
76+
try assertMatch(#"""
77+
<text markdown="*Hello, world!*
78+
This is some markdown text [click me](apple.com)" />
79+
"""#) {
80+
Text("""
81+
*Hello, world!*
82+
This is some markdown text [click me](apple.com)
83+
""")
84+
}
85+
}
86+
87+
func testDate() throws {
88+
try assertMatch(#"<text date="2023-01-17" date-style="date" />"#) {
89+
Text(Date(timeIntervalSince1970: 1673931600.0), style: .date)
90+
}
91+
for style in [(Text.DateStyle.time, "time"), (.date, "date"), (.relative, "relative"), (.offset, "offset"), (.timer, "timer")] {
92+
try assertMatch(#"<text date="2023-01-17T14:55:01.326Z" date-style="\#(style.1)" />"#) {
93+
Text(Date(timeIntervalSince1970: 1673967301.325973), style: style.0)
94+
}
95+
}
96+
}
97+
98+
func testFormat() throws {
99+
try assertMatch(#"<text format="date-time" value="2023-01-17" />"#) {
100+
Text(Date(timeIntervalSince1970: 1673931600.0), format: .dateTime)
101+
}
102+
try assertMatch(#"<text format="url">apple.com</text>"#) {
103+
Text(URL(string: "apple.com")!, format: .url)
104+
}
105+
try assertMatch(#"<text format="iso8601" value="2023-01-17T14:55:01.326Z" />"#) {
106+
Text(Date(timeIntervalSince1970: 1673967301.325973), format: .iso8601)
107+
}
108+
try assertMatch(#"<text format="number" value="0.42" />"#) {
109+
Text(0.42, format: .number)
110+
}
111+
try assertMatch(#"<text format="percent" value="0.42" />"#) {
112+
Text(0.42, format: .percent)
113+
}
114+
try assertMatch(#"<text format="currency" currency-code="mxn" value="42" />"#) {
115+
Text(42, format: .currency(code: "mxn"))
116+
}
117+
try assertMatch(#"<text format="name" name-style="short">John Doe</text>"#) {
118+
Text(try! PersonNameComponents("John Doe", strategy: .name), format: .name(style: .short))
119+
}
120+
}
51121
}

Tests/RenderingTests/assertMatch.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,27 @@ func assertMatch(
1717
_ file: String = #file,
1818
_ line: Int = #line,
1919
_ function: StaticString = #function,
20-
@ViewBuilder _ view: () -> some View
20+
@ViewBuilder _ view: () -> some View,
21+
environment: @escaping (inout EnvironmentValues) -> () = { _ in }
2122
) throws {
22-
try assertMatch(name: "\(URL(filePath: file).lastPathComponent)-\(line)-\(function)", markup, view)
23+
try assertMatch(name: "\(URL(filePath: file).lastPathComponent)-\(line)-\(function)", markup, view, environment: environment)
2324
}
2425

2526
@MainActor
2627
func assertMatch(
2728
name: String,
2829
_ markup: String,
29-
@ViewBuilder _ view: () -> some View
30+
@ViewBuilder _ view: () -> some View,
31+
environment: @escaping (inout EnvironmentValues) -> () = { _ in }
3032
) throws {
3133
let session = LiveSessionCoordinator(URL(string: "http://localhost")!)
3234
let document = try LiveViewNativeCore.Document.parse(markup)
3335
let viewTree = session.rootCoordinator.builder.fromNodes(
3436
document[document.root()].children(),
3537
context: LiveContext(coordinator: session.rootCoordinator, url: session.url)
3638
).environment(\.coordinatorEnvironment, CoordinatorEnvironment(session.rootCoordinator, document: document))
37-
let markupImage = ImageRenderer(content: viewTree).uiImage?.pngData()
38-
let viewImage = ImageRenderer(content: view()).uiImage?.pngData()
39+
let markupImage = ImageRenderer(content: viewTree.transformEnvironment(\.self, transform: environment)).uiImage?.pngData()
40+
let viewImage = ImageRenderer(content: view().transformEnvironment(\.self, transform: environment)).uiImage?.pngData()
3941

4042
if markupImage == viewImage {
4143
XCTAssert(true)

0 commit comments

Comments
 (0)