diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/TextModifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/TextModifiers.swift new file mode 100644 index 0000000..b3c053d --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/TextModifiers.swift @@ -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 { + 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 { + modifier(_ForegroundColorModifier(color: color)) + } + func foregroundStyle(_ color: Color) -> ModifiedContent { + 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 { + modifier(_FontWeightModifier(weight: weight)) + } + func bold() -> ModifiedContent { + modifier(_FontWeightModifier(weight: .bold)) + } +} + +// MARK: - Italic + +public struct _ItalicModifier: RenderModifier { + public var _modifierNode: ModifierNode { ModifierNode(kind: "italic") } +} + +public extension View { + func italic() -> ModifiedContent { + 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 { + 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 { + modifier(_MultilineTextAlignmentModifier(alignment: alignment)) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 447fcf9..b1aaaed 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -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)) + } } diff --git a/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift b/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift index c1eaf0e..7057779 100644 --- a/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift +++ b/Demo/App.swiftpm/Sources/ControlPlaygrounds.swift @@ -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) diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 95213e1..454989b 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -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 @@ -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") @@ -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 { diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt index 2b3cbdb..7b0acb4 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt @@ -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