Skip to content

Commit f63a2d5

Browse files
authored
Merge pull request #26 from PureSwift/feature/text-styling
Text styling modifiers
2 parents cc60c6a + 35a552e commit f63a2d5

5 files changed

Lines changed: 308 additions & 4 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//
2+
// TextModifiers.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Text-styling modifiers. Unlike layout modifiers (padding, background…) that
6+
// fold into the Compose `Modifier` chain, these describe attributes of the
7+
// `Text` composable itself; the interpreter reads their kinds off a Text
8+
// node's modifier chain and applies them as `Text(...)` parameters.
9+
//
10+
11+
// MARK: - Font
12+
13+
/// A font: either a named text style (resolved to a size by the interpreter)
14+
/// or an explicit system size, with an optional weight.
15+
public struct Font: Equatable, Sendable {
16+
17+
internal var style: String?
18+
internal var size: Double?
19+
internal var weight: Weight?
20+
21+
internal init(style: String? = nil, size: Double? = nil, weight: Weight? = nil) {
22+
self.style = style
23+
self.size = size
24+
self.weight = weight
25+
}
26+
27+
public enum Weight: String, Equatable, Sendable {
28+
case ultraLight, thin, light, regular, medium, semibold, bold, heavy, black
29+
}
30+
31+
public static let largeTitle = Font(style: "largeTitle")
32+
public static let title = Font(style: "title")
33+
public static let title2 = Font(style: "title2")
34+
public static let title3 = Font(style: "title3")
35+
public static let headline = Font(style: "headline")
36+
public static let subheadline = Font(style: "subheadline")
37+
public static let body = Font(style: "body")
38+
public static let callout = Font(style: "callout")
39+
public static let footnote = Font(style: "footnote")
40+
public static let caption = Font(style: "caption")
41+
public static let caption2 = Font(style: "caption2")
42+
43+
public static func system(size: Double, weight: Weight? = nil) -> Font {
44+
Font(size: size, weight: weight)
45+
}
46+
47+
public func weight(_ weight: Weight) -> Font {
48+
var copy = self
49+
copy.weight = weight
50+
return copy
51+
}
52+
53+
public func bold() -> Font { weight(.bold) }
54+
}
55+
56+
public struct _FontModifier: RenderModifier {
57+
let font: Font
58+
public var _modifierNode: ModifierNode {
59+
var args: [String: PropValue] = [:]
60+
if let style = font.style { args["style"] = .string(style) }
61+
if let size = font.size { args["size"] = .double(size) }
62+
if let weight = font.weight { args["weight"] = .string(weight.rawValue) }
63+
return ModifierNode(kind: "font", args: args)
64+
}
65+
}
66+
67+
public extension View {
68+
func font(_ font: Font) -> ModifiedContent<Self, _FontModifier> {
69+
modifier(_FontModifier(font: font))
70+
}
71+
}
72+
73+
// MARK: - Foreground color
74+
75+
public struct _ForegroundColorModifier: RenderModifier {
76+
let color: Color
77+
public var _modifierNode: ModifierNode {
78+
ModifierNode(kind: "foregroundColor", args: ["color": color.propValue])
79+
}
80+
}
81+
82+
public extension View {
83+
func foregroundColor(_ color: Color) -> ModifiedContent<Self, _ForegroundColorModifier> {
84+
modifier(_ForegroundColorModifier(color: color))
85+
}
86+
func foregroundStyle(_ color: Color) -> ModifiedContent<Self, _ForegroundColorModifier> {
87+
modifier(_ForegroundColorModifier(color: color))
88+
}
89+
}
90+
91+
// MARK: - Weight
92+
93+
public struct _FontWeightModifier: RenderModifier {
94+
let weight: Font.Weight
95+
public var _modifierNode: ModifierNode {
96+
ModifierNode(kind: "fontWeight", args: ["weight": .string(weight.rawValue)])
97+
}
98+
}
99+
100+
public extension View {
101+
func fontWeight(_ weight: Font.Weight) -> ModifiedContent<Self, _FontWeightModifier> {
102+
modifier(_FontWeightModifier(weight: weight))
103+
}
104+
func bold() -> ModifiedContent<Self, _FontWeightModifier> {
105+
modifier(_FontWeightModifier(weight: .bold))
106+
}
107+
}
108+
109+
// MARK: - Italic
110+
111+
public struct _ItalicModifier: RenderModifier {
112+
public var _modifierNode: ModifierNode { ModifierNode(kind: "italic") }
113+
}
114+
115+
public extension View {
116+
func italic() -> ModifiedContent<Self, _ItalicModifier> {
117+
modifier(_ItalicModifier())
118+
}
119+
}
120+
121+
// MARK: - Line limit
122+
123+
public struct _LineLimitModifier: RenderModifier {
124+
let limit: Int?
125+
public var _modifierNode: ModifierNode {
126+
var args: [String: PropValue] = [:]
127+
if let limit { args["count"] = .int(limit) }
128+
return ModifierNode(kind: "lineLimit", args: args)
129+
}
130+
}
131+
132+
public extension View {
133+
func lineLimit(_ number: Int?) -> ModifiedContent<Self, _LineLimitModifier> {
134+
modifier(_LineLimitModifier(limit: number))
135+
}
136+
}
137+
138+
// MARK: - Multiline text alignment
139+
140+
public enum TextAlignment: String, Equatable, Sendable {
141+
case leading, center, trailing
142+
}
143+
144+
public struct _MultilineTextAlignmentModifier: RenderModifier {
145+
let alignment: TextAlignment
146+
public var _modifierNode: ModifierNode {
147+
ModifierNode(kind: "multilineTextAlignment", args: ["value": .string(alignment.rawValue)])
148+
}
149+
}
150+
151+
public extension View {
152+
func multilineTextAlignment(_ alignment: TextAlignment) -> ModifiedContent<Self, _MultilineTextAlignmentModifier> {
153+
modifier(_MultilineTextAlignmentModifier(alignment: alignment))
154+
}
155+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,42 @@ struct ModifierTests {
108108
#expect(frame?.args["width"] == .double(100))
109109
#expect(frame?.args["height"] == .double(50))
110110
}
111+
112+
@Test("Named font emits its style")
113+
func fontStyle() {
114+
let node = ViewHost(Text("x").font(.headline)).evaluate()
115+
let font = node.modifiers.first { $0.kind == "font" }
116+
#expect(font?.args["style"] == .string("headline"))
117+
#expect(font?.args["size"] == nil)
118+
}
119+
120+
@Test("System font emits size and weight")
121+
func systemFont() {
122+
let node = ViewHost(Text("x").font(.system(size: 24, weight: .heavy))).evaluate()
123+
let font = node.modifiers.first { $0.kind == "font" }
124+
#expect(font?.args["size"] == .double(24))
125+
#expect(font?.args["weight"] == .string("heavy"))
126+
}
127+
128+
@Test("foregroundColor emits an argb color")
129+
func foregroundColor() {
130+
let node = ViewHost(Text("x").foregroundColor(.red)).evaluate()
131+
let color = node.modifiers.first { $0.kind == "foregroundColor" }
132+
#expect(color?.args["color"] == Color.red.propValue)
133+
}
134+
135+
@Test("bold and italic emit their kinds")
136+
func boldItalic() {
137+
let node = ViewHost(Text("x").bold().italic()).evaluate()
138+
let weight = node.modifiers.first { $0.kind == "fontWeight" }
139+
#expect(weight?.args["weight"] == .string("bold"))
140+
#expect(node.modifiers.contains { $0.kind == "italic" })
141+
}
142+
143+
@Test("lineLimit emits its count")
144+
func lineLimit() {
145+
let node = ViewHost(Text("x").lineLimit(2)).evaluate()
146+
let limit = node.modifiers.first { $0.kind == "lineLimit" }
147+
#expect(limit?.args["count"] == .int(2))
148+
}
111149
}

Demo/App.swiftpm/Sources/ControlPlaygrounds.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,40 @@ struct TextPlayground: View {
1313
Example("Verbatim") { Text(verbatim: "Raw string, no interpolation") }
1414
Example("Interpolated") { Text("Counter is \(counter)") }
1515
Example("Bump the counter") { Button("Increment") { counter += 1 } }
16+
Example("Font sizes") {
17+
VStack(alignment: .leading, spacing: 4) {
18+
Text("Large title").font(.largeTitle)
19+
Text("Title").font(.title)
20+
Text("Headline").font(.headline)
21+
Text("Body").font(.body)
22+
Text("Caption").font(.caption)
23+
}
24+
}
25+
Example("Weight & style") {
26+
VStack(alignment: .leading, spacing: 4) {
27+
Text("Bold").bold()
28+
Text("Semibold").fontWeight(.semibold)
29+
Text("Italic").italic()
30+
Text("System 24 heavy").font(.system(size: 24, weight: .heavy))
31+
}
32+
}
33+
Example("Colors") {
34+
VStack(alignment: .leading, spacing: 4) {
35+
Text("Red").foregroundColor(.red)
36+
Text("Blue").foregroundColor(.blue)
37+
Text("Green, bold, title").font(.title).bold().foregroundColor(.green)
38+
}
39+
}
40+
Example("Line limit") {
41+
Text("A longer passage of text that wraps onto multiple lines, capped at two by lineLimit so the rest is truncated.")
42+
.lineLimit(2)
43+
}
1644
Example("Multiline") {
1745
Text("A longer passage of text that wraps onto multiple lines when it no longer fits within the width of the screen.")
1846
}
1947
Example("Styled") {
2048
Text("Blue on a rounded chip")
49+
.foregroundColor(.white)
2150
.padding()
2251
.background(Color.blue)
2352
.cornerRadius(12)

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,12 @@ import androidx.compose.ui.draw.clip
6464
import androidx.compose.ui.draw.rotate
6565
import androidx.compose.ui.draw.scale
6666
import androidx.compose.ui.graphics.Color
67+
import androidx.compose.ui.text.font.FontStyle
68+
import androidx.compose.ui.text.font.FontWeight
69+
import androidx.compose.ui.text.style.TextAlign
6770
import androidx.compose.ui.text.input.TextFieldValue
71+
import androidx.compose.ui.unit.TextUnit
72+
import androidx.compose.ui.unit.sp
6873
import androidx.compose.ui.unit.IntOffset
6974
import androidx.compose.ui.unit.dp
7075
import androidx.compose.foundation.layout.fillMaxWidth
@@ -79,10 +84,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
7984
fun Render(node: ViewNode) {
8085
key(node.id) {
8186
when (node.type) {
82-
"Text" -> Text(
83-
text = node.string("text") ?: "",
84-
modifier = node.composeModifiers(),
85-
)
87+
"Text" -> RenderText(node)
8688

8789
"Button" -> {
8890
val onTap = node.long("onTap")
@@ -332,6 +334,84 @@ private fun zStackAlignment(node: ViewNode): Alignment {
332334
}
333335
}
334336

337+
// Text-styling modifiers describe attributes of the Text composable itself,
338+
// not the layout Modifier chain, so they are read off the node here and passed
339+
// as `Text(...)` parameters. Later (innermost) entries win for scalar
340+
// attributes. Layout modifiers in the same chain still apply via composeModifiers().
341+
@Composable
342+
private fun RenderText(node: ViewNode) {
343+
var color = Color.Unspecified
344+
var fontSize: TextUnit = TextUnit.Unspecified
345+
var weight: FontWeight? = null
346+
var fontStyle: FontStyle? = null
347+
var maxLines = Int.MAX_VALUE
348+
var textAlign: TextAlign? = null
349+
for (m in node.modifiers) {
350+
when (m.kind) {
351+
"font" -> {
352+
m.args.string("style")?.let { style ->
353+
fontSize = fontSizeForStyle(style).sp
354+
if (weight == null) weight = defaultWeightForStyle(style)
355+
}
356+
m.args.double("size")?.let { fontSize = it.sp }
357+
m.args.string("weight")?.let { weight = fontWeightFor(it) }
358+
}
359+
"fontWeight" -> m.args.string("weight")?.let { weight = fontWeightFor(it) }
360+
"italic" -> fontStyle = FontStyle.Italic
361+
"foregroundColor" -> m.args.long("color")?.let { color = Color(it.toInt()) }
362+
"lineLimit" -> maxLines = m.args.long("count")?.toInt() ?: Int.MAX_VALUE
363+
"multilineTextAlignment" -> textAlign = when (m.args.string("value")) {
364+
"leading" -> TextAlign.Start
365+
"trailing" -> TextAlign.End
366+
else -> TextAlign.Center
367+
}
368+
}
369+
}
370+
Text(
371+
text = node.string("text") ?: "",
372+
color = color,
373+
fontSize = fontSize,
374+
fontWeight = weight,
375+
fontStyle = fontStyle,
376+
maxLines = maxLines,
377+
textAlign = textAlign,
378+
modifier = node.composeModifiers(),
379+
)
380+
}
381+
382+
// SwiftUI named text styles → point sizes (Compose has no built-in analog).
383+
private fun fontSizeForStyle(style: String): Double = when (style) {
384+
"largeTitle" -> 34.0
385+
"title" -> 28.0
386+
"title2" -> 22.0
387+
"title3" -> 20.0
388+
"headline" -> 17.0
389+
"body" -> 17.0
390+
"callout" -> 16.0
391+
"subheadline" -> 15.0
392+
"footnote" -> 13.0
393+
"caption" -> 12.0
394+
"caption2" -> 11.0
395+
else -> 17.0
396+
}
397+
398+
// Only headline carries a non-regular default weight in SwiftUI.
399+
private fun defaultWeightForStyle(style: String): FontWeight? =
400+
if (style == "headline") FontWeight.SemiBold else null
401+
402+
private fun fontWeightFor(name: String): FontWeight = when (name) {
403+
"ultraLight" -> FontWeight.ExtraLight
404+
"thin" -> FontWeight.Thin
405+
"light" -> FontWeight.Light
406+
"regular" -> FontWeight.Normal
407+
"medium" -> FontWeight.Medium
408+
"semibold" -> FontWeight.SemiBold
409+
"bold" -> FontWeight.Bold
410+
"heavy" -> FontWeight.ExtraBold
411+
"black" -> FontWeight.Black
412+
else -> FontWeight.Normal
413+
}
414+
335415
/// Folds a node's ordered modifier chain into a Compose `Modifier`.
336416
/// The chain arrives outermost-first, which is exactly Compose's order.
337417
internal fun ViewNode.composeModifiers(): Modifier {

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,5 @@ data class ViewNode(
8282
internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull
8383

8484
internal fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
85+
86+
internal fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.content

0 commit comments

Comments
 (0)