Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//
// TextModifiers.swift
// AndroidSwiftUICore
//
// Text-styling modifiers. Unlike layout modifiers (padding, background…) that
// fold into the Compose `Modifier` chain, these describe attributes of the
// `Text` composable itself; the interpreter reads their kinds off a Text
// node's modifier chain and applies them as `Text(...)` parameters.
//

// MARK: - Font

/// A font: either a named text style (resolved to a size by the interpreter)
/// or an explicit system size, with an optional weight.
public struct Font: Equatable, Sendable {

internal var style: String?
internal var size: Double?
internal var weight: Weight?

internal init(style: String? = nil, size: Double? = nil, weight: Weight? = nil) {
self.style = style
self.size = size
self.weight = weight
}

public enum Weight: String, Equatable, Sendable {
case ultraLight, thin, light, regular, medium, semibold, bold, heavy, black
}

public static let largeTitle = Font(style: "largeTitle")
public static let title = Font(style: "title")
public static let title2 = Font(style: "title2")
public static let title3 = Font(style: "title3")
public static let headline = Font(style: "headline")
public static let subheadline = Font(style: "subheadline")
public static let body = Font(style: "body")
public static let callout = Font(style: "callout")
public static let footnote = Font(style: "footnote")
public static let caption = Font(style: "caption")
public static let caption2 = Font(style: "caption2")

public static func system(size: Double, weight: Weight? = nil) -> Font {
Font(size: size, weight: weight)
}

public func weight(_ weight: Weight) -> Font {
var copy = self
copy.weight = weight
return copy
}

public func bold() -> Font { weight(.bold) }
}

public struct _FontModifier: RenderModifier {
let font: Font
public var _modifierNode: ModifierNode {
var args: [String: PropValue] = [:]
if let style = font.style { args["style"] = .string(style) }
if let size = font.size { args["size"] = .double(size) }
if let weight = font.weight { args["weight"] = .string(weight.rawValue) }
return ModifierNode(kind: "font", args: args)
}
}

public extension View {
func font(_ font: Font) -> ModifiedContent<Self, _FontModifier> {
modifier(_FontModifier(font: font))
}
}

// MARK: - Foreground color

public struct _ForegroundColorModifier: RenderModifier {
let color: Color
public var _modifierNode: ModifierNode {
ModifierNode(kind: "foregroundColor", args: ["color": color.propValue])
}
}

public extension View {
func foregroundColor(_ color: Color) -> ModifiedContent<Self, _ForegroundColorModifier> {
modifier(_ForegroundColorModifier(color: color))
}
func foregroundStyle(_ color: Color) -> ModifiedContent<Self, _ForegroundColorModifier> {
modifier(_ForegroundColorModifier(color: color))
}
}

// MARK: - Weight

public struct _FontWeightModifier: RenderModifier {
let weight: Font.Weight
public var _modifierNode: ModifierNode {
ModifierNode(kind: "fontWeight", args: ["weight": .string(weight.rawValue)])
}
}

public extension View {
func fontWeight(_ weight: Font.Weight) -> ModifiedContent<Self, _FontWeightModifier> {
modifier(_FontWeightModifier(weight: weight))
}
func bold() -> ModifiedContent<Self, _FontWeightModifier> {
modifier(_FontWeightModifier(weight: .bold))
}
}

// MARK: - Italic

public struct _ItalicModifier: RenderModifier {
public var _modifierNode: ModifierNode { ModifierNode(kind: "italic") }
}

public extension View {
func italic() -> ModifiedContent<Self, _ItalicModifier> {
modifier(_ItalicModifier())
}
}

// MARK: - Line limit

public struct _LineLimitModifier: RenderModifier {
let limit: Int?
public var _modifierNode: ModifierNode {
var args: [String: PropValue] = [:]
if let limit { args["count"] = .int(limit) }
return ModifierNode(kind: "lineLimit", args: args)
}
}

public extension View {
func lineLimit(_ number: Int?) -> ModifiedContent<Self, _LineLimitModifier> {
modifier(_LineLimitModifier(limit: number))
}
}

// MARK: - Multiline text alignment

public enum TextAlignment: String, Equatable, Sendable {
case leading, center, trailing
}

public struct _MultilineTextAlignmentModifier: RenderModifier {
let alignment: TextAlignment
public var _modifierNode: ModifierNode {
ModifierNode(kind: "multilineTextAlignment", args: ["value": .string(alignment.rawValue)])
}
}

public extension View {
func multilineTextAlignment(_ alignment: TextAlignment) -> ModifiedContent<Self, _MultilineTextAlignmentModifier> {
modifier(_MultilineTextAlignmentModifier(alignment: alignment))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,42 @@ struct ModifierTests {
#expect(frame?.args["width"] == .double(100))
#expect(frame?.args["height"] == .double(50))
}

@Test("Named font emits its style")
func fontStyle() {
let node = ViewHost(Text("x").font(.headline)).evaluate()
let font = node.modifiers.first { $0.kind == "font" }
#expect(font?.args["style"] == .string("headline"))
#expect(font?.args["size"] == nil)
}

@Test("System font emits size and weight")
func systemFont() {
let node = ViewHost(Text("x").font(.system(size: 24, weight: .heavy))).evaluate()
let font = node.modifiers.first { $0.kind == "font" }
#expect(font?.args["size"] == .double(24))
#expect(font?.args["weight"] == .string("heavy"))
}

@Test("foregroundColor emits an argb color")
func foregroundColor() {
let node = ViewHost(Text("x").foregroundColor(.red)).evaluate()
let color = node.modifiers.first { $0.kind == "foregroundColor" }
#expect(color?.args["color"] == Color.red.propValue)
}

@Test("bold and italic emit their kinds")
func boldItalic() {
let node = ViewHost(Text("x").bold().italic()).evaluate()
let weight = node.modifiers.first { $0.kind == "fontWeight" }
#expect(weight?.args["weight"] == .string("bold"))
#expect(node.modifiers.contains { $0.kind == "italic" })
}

@Test("lineLimit emits its count")
func lineLimit() {
let node = ViewHost(Text("x").lineLimit(2)).evaluate()
let limit = node.modifiers.first { $0.kind == "lineLimit" }
#expect(limit?.args["count"] == .int(2))
}
}
29 changes: 29 additions & 0 deletions Demo/App.swiftpm/Sources/ControlPlaygrounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,40 @@ struct TextPlayground: View {
Example("Verbatim") { Text(verbatim: "Raw string, no interpolation") }
Example("Interpolated") { Text("Counter is \(counter)") }
Example("Bump the counter") { Button("Increment") { counter += 1 } }
Example("Font sizes") {
VStack(alignment: .leading, spacing: 4) {
Text("Large title").font(.largeTitle)
Text("Title").font(.title)
Text("Headline").font(.headline)
Text("Body").font(.body)
Text("Caption").font(.caption)
}
}
Example("Weight & style") {
VStack(alignment: .leading, spacing: 4) {
Text("Bold").bold()
Text("Semibold").fontWeight(.semibold)
Text("Italic").italic()
Text("System 24 heavy").font(.system(size: 24, weight: .heavy))
}
}
Example("Colors") {
VStack(alignment: .leading, spacing: 4) {
Text("Red").foregroundColor(.red)
Text("Blue").foregroundColor(.blue)
Text("Green, bold, title").font(.title).bold().foregroundColor(.green)
}
}
Example("Line limit") {
Text("A longer passage of text that wraps onto multiple lines, capped at two by lineLimit so the rest is truncated.")
.lineLimit(2)
}
Example("Multiline") {
Text("A longer passage of text that wraps onto multiple lines when it no longer fits within the width of the screen.")
}
Example("Styled") {
Text("Blue on a rounded chip")
.foregroundColor(.white)
.padding()
.background(Color.blue)
.cornerRadius(12)
Expand Down
88 changes: 84 additions & 4 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.fillMaxWidth
Expand All @@ -79,10 +84,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
fun Render(node: ViewNode) {
key(node.id) {
when (node.type) {
"Text" -> Text(
text = node.string("text") ?: "",
modifier = node.composeModifiers(),
)
"Text" -> RenderText(node)

"Button" -> {
val onTap = node.long("onTap")
Expand Down Expand Up @@ -332,6 +334,84 @@ private fun zStackAlignment(node: ViewNode): Alignment {
}
}

// Text-styling modifiers describe attributes of the Text composable itself,
// not the layout Modifier chain, so they are read off the node here and passed
// as `Text(...)` parameters. Later (innermost) entries win for scalar
// attributes. Layout modifiers in the same chain still apply via composeModifiers().
@Composable
private fun RenderText(node: ViewNode) {
var color = Color.Unspecified
var fontSize: TextUnit = TextUnit.Unspecified
var weight: FontWeight? = null
var fontStyle: FontStyle? = null
var maxLines = Int.MAX_VALUE
var textAlign: TextAlign? = null
for (m in node.modifiers) {
when (m.kind) {
"font" -> {
m.args.string("style")?.let { style ->
fontSize = fontSizeForStyle(style).sp
if (weight == null) weight = defaultWeightForStyle(style)
}
m.args.double("size")?.let { fontSize = it.sp }
m.args.string("weight")?.let { weight = fontWeightFor(it) }
}
"fontWeight" -> m.args.string("weight")?.let { weight = fontWeightFor(it) }
"italic" -> fontStyle = FontStyle.Italic
"foregroundColor" -> m.args.long("color")?.let { color = Color(it.toInt()) }
"lineLimit" -> maxLines = m.args.long("count")?.toInt() ?: Int.MAX_VALUE
"multilineTextAlignment" -> textAlign = when (m.args.string("value")) {
"leading" -> TextAlign.Start
"trailing" -> TextAlign.End
else -> TextAlign.Center
}
}
}
Text(
text = node.string("text") ?: "",
color = color,
fontSize = fontSize,
fontWeight = weight,
fontStyle = fontStyle,
maxLines = maxLines,
textAlign = textAlign,
modifier = node.composeModifiers(),
)
}

// SwiftUI named text styles → point sizes (Compose has no built-in analog).
private fun fontSizeForStyle(style: String): Double = when (style) {
"largeTitle" -> 34.0
"title" -> 28.0
"title2" -> 22.0
"title3" -> 20.0
"headline" -> 17.0
"body" -> 17.0
"callout" -> 16.0
"subheadline" -> 15.0
"footnote" -> 13.0
"caption" -> 12.0
"caption2" -> 11.0
else -> 17.0
}

// Only headline carries a non-regular default weight in SwiftUI.
private fun defaultWeightForStyle(style: String): FontWeight? =
if (style == "headline") FontWeight.SemiBold else null

private fun fontWeightFor(name: String): FontWeight = when (name) {
"ultraLight" -> FontWeight.ExtraLight
"thin" -> FontWeight.Thin
"light" -> FontWeight.Light
"regular" -> FontWeight.Normal
"medium" -> FontWeight.Medium
"semibold" -> FontWeight.SemiBold
"bold" -> FontWeight.Bold
"heavy" -> FontWeight.ExtraBold
"black" -> FontWeight.Black
else -> FontWeight.Normal
}

/// Folds a node's ordered modifier chain into a Compose `Modifier`.
/// The chain arrives outermost-first, which is exactly Compose's order.
internal fun ViewNode.composeModifiers(): Modifier {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,5 @@ data class ViewNode(
internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull

internal fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull

internal fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.content
Loading