From a01035be2ce66ca22c7222c0cd010ebb0cd7f0a8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 1/7] Add shape views (Rectangle, Circle, Capsule, RoundedRectangle) --- .../Primitives/Shapes.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Shapes.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Shapes.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Shapes.swift new file mode 100644 index 0000000..f6318aa --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Shapes.swift @@ -0,0 +1,68 @@ +// +// Shapes.swift +// AndroidSwiftUICore +// +// Shape views fill their frame with a fill color. A shape without an explicit +// `.frame` renders at zero size (the layout engine's fill-the-parent behavior +// is not modeled); the catalog always frames them. +// + +private func shapeNode(_ kind: String, fill: Color?, cornerRadius: Double? = nil, context: ResolveContext) -> RenderNode { + var props: [String: PropValue] = ["shape": .string(kind)] + if let fill { props["fill"] = fill.propValue } + if let cornerRadius { props["cornerRadius"] = .double(cornerRadius) } + return RenderNode(type: "Shape", id: context.path, props: props) +} + +public struct Rectangle: View { + internal var fillColor: Color? + public init() {} + public func fill(_ color: Color) -> Rectangle { var copy = self; copy.fillColor = color; return copy } + public typealias Body = Never +} + +extension Rectangle: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + shapeNode("rectangle", fill: fillColor, context: context) + } +} + +public struct Circle: View { + internal var fillColor: Color? + public init() {} + public func fill(_ color: Color) -> Circle { var copy = self; copy.fillColor = color; return copy } + public typealias Body = Never +} + +extension Circle: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + shapeNode("circle", fill: fillColor, context: context) + } +} + +public struct Capsule: View { + internal var fillColor: Color? + public init() {} + public func fill(_ color: Color) -> Capsule { var copy = self; copy.fillColor = color; return copy } + public typealias Body = Never +} + +extension Capsule: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + shapeNode("capsule", fill: fillColor, context: context) + } +} + +public struct RoundedRectangle: View { + internal let cornerRadius: Double + internal var fillColor: Color? + public init(cornerRadius: Double) { self.cornerRadius = cornerRadius } + public func fill(_ color: Color) -> RoundedRectangle { var copy = self; copy.fillColor = color; return copy } + public typealias Body = Never +} + +extension RoundedRectangle: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + shapeNode("roundedRectangle", fill: fillColor, cornerRadius: cornerRadius, context: context) + } +} From e2ea084fc094c541a2b13b308b44e6b63870c374 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 2/7] Add LinearGradient and UnitPoint --- .../Primitives/Gradient.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Gradient.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Gradient.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Gradient.swift new file mode 100644 index 0000000..bb8e08d --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Gradient.swift @@ -0,0 +1,57 @@ +// +// Gradient.swift +// AndroidSwiftUICore +// + +/// A location in a view, normalized to its size (0…1 on each axis). +public struct UnitPoint: Equatable, Sendable { + public var x: Double + public var y: Double + public init(x: Double, y: Double) { self.x = x; self.y = y } + + public static let leading = UnitPoint(x: 0, y: 0.5) + public static let trailing = UnitPoint(x: 1, y: 0.5) + public static let top = UnitPoint(x: 0.5, y: 0) + public static let bottom = UnitPoint(x: 0.5, y: 1) + public static let topLeading = UnitPoint(x: 0, y: 0) + public static let topTrailing = UnitPoint(x: 1, y: 0) + public static let bottomLeading = UnitPoint(x: 0, y: 1) + public static let bottomTrailing = UnitPoint(x: 1, y: 1) + public static let center = UnitPoint(x: 0.5, y: 0.5) +} + +/// An ordered list of colors for a gradient. +public struct Gradient: Equatable, Sendable { + public var colors: [Color] + public init(colors: [Color]) { self.colors = colors } +} + +/// A linear gradient fill, usable as a view. +public struct LinearGradient: View { + + internal let colors: [Color] + internal let startPoint: UnitPoint + internal let endPoint: UnitPoint + + public init(colors: [Color], startPoint: UnitPoint, endPoint: UnitPoint) { + self.colors = colors + self.startPoint = startPoint + self.endPoint = endPoint + } + + public init(gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint) { + self.init(colors: gradient.colors, startPoint: startPoint, endPoint: endPoint) + } + + public typealias Body = Never +} + +extension LinearGradient: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode(type: "LinearGradient", id: context.path, props: [ + "colors": .array(colors.map { $0.propValue }), + "startX": .double(startPoint.x), "startY": .double(startPoint.y), + "endX": .double(endPoint.x), "endY": .double(endPoint.y), + ]) + } +} From bbca6e098b5180de5b1ed877fc3c3af4b58ae5e8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 3/7] Add Image(systemName:) for SF Symbols --- .../AndroidSwiftUICore/Primitives/Views.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift index 03bccee..eed8f7c 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift @@ -43,14 +43,22 @@ extension Color: PrimitiveView { } } -/// An image referenced by name. Rendering is placeholder-level until an asset -/// pipeline exists. +/// An image. `Image(systemName:)` maps a curated set of SF Symbol names to +/// Material icons in the interpreter; a named asset stays placeholder-level +/// until an asset pipeline exists. public struct Image: View { internal let name: String + internal let systemName: String? public init(_ name: String) { self.name = name + self.systemName = nil + } + + public init(systemName: String) { + self.name = systemName + self.systemName = systemName } public typealias Body = Never @@ -58,7 +66,9 @@ public struct Image: View { extension Image: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { - RenderNode(type: "Image", id: context.path, props: ["name": .string(name)]) + var props: [String: PropValue] = ["name": .string(name)] + if let systemName { props["systemName"] = .string(systemName) } + return RenderNode(type: "Image", id: context.path, props: props) } } From dde4ed5f18ae51dd5cba2b7965ce546831f0aa60 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 4/7] Test shape, gradient, and system image emission --- .../EvaluatorTests.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index a6144ac..c7f2727 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -181,3 +181,40 @@ struct ModifierTests { #expect(flag?.args["value"] == .bool(true)) } } + +// MARK: - Graphics + +@Suite("Graphics") +struct GraphicsTests { + + @Test("Shapes emit a Shape node with their kind and fill") + func shapes() { + let rect = ViewHost(Rectangle().fill(.blue)).evaluate() + #expect(rect.type == "Shape") + #expect(rect.props["shape"] == .string("rectangle")) + #expect(rect.props["fill"] == Color.blue.propValue) + + let rounded = ViewHost(RoundedRectangle(cornerRadius: 12)).evaluate() + #expect(rounded.props["shape"] == .string("roundedRectangle")) + #expect(rounded.props["cornerRadius"] == .double(12)) + + #expect(ViewHost(Circle()).evaluate().props["shape"] == .string("circle")) + #expect(ViewHost(Capsule()).evaluate().props["shape"] == .string("capsule")) + } + + @Test("LinearGradient emits its colors and endpoints") + func gradient() { + let node = ViewHost(LinearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing)).evaluate() + #expect(node.type == "LinearGradient") + #expect(node.props["colors"] == .array([Color.blue.propValue, Color.purple.propValue])) + #expect(node.props["startX"] == .double(0)) + #expect(node.props["endX"] == .double(1)) + } + + @Test("Image(systemName:) carries the symbol name") + func systemImage() { + let node = ViewHost(Image(systemName: "star.fill")).evaluate() + #expect(node.type == "Image") + #expect(node.props["systemName"] == .string("star.fill")) + } +} From 4312ee25c00b70a4ccd588435164535304343e97 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 5/7] Render shapes, gradients, and mapped SF Symbols --- .../kotlin/com/pureswift/swiftui/Render.kt | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) 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 680856f..4406067 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -50,6 +50,33 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -65,7 +92,11 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -163,7 +194,11 @@ fun Render(node: ViewNode) { .background(Color((node.long("color") ?: 0).toInt())) ) - "Image" -> Text("[${node.string("name") ?: "image"}]", modifier = node.composeModifiers()) + "Image" -> RenderImage(node) + + "Shape" -> RenderShape(node) + + "LinearGradient" -> RenderGradient(node) "ProgressView" -> { val value = node.double("value") @@ -372,6 +407,94 @@ private fun RenderEffects(node: ViewNode) { private fun ViewNode.isDisabled(): Boolean = modifiers.any { it.kind == "disabled" && (it.args["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true" } +// A shape fills its (frame-derived) size with its fill color; without a frame +// it collapses to zero, matching this backend's no-layout-engine limitation. +@Composable +private fun RenderShape(node: ViewNode) { + val fill = node.long("fill")?.let { Color(it.toInt()) } ?: Color(0xFF000000) + val shape = when (node.string("shape")) { + "circle" -> CircleShape + "capsule" -> RoundedCornerShape(percent = 50) + "roundedRectangle" -> RoundedCornerShape((node.double("cornerRadius") ?: 0.0).dp) + else -> RectangleShape + } + Box(modifier = node.composeModifiers().background(fill, shape)) +} + +// Horizontal/vertical when the endpoints share an axis, else a diagonal linear +// gradient across the frame. +@Composable +private fun RenderGradient(node: ViewNode) { + val colors = node.colorList("colors").ifEmpty { listOf(Color.Transparent, Color.Transparent) } + val startX = node.double("startX") ?: 0.0 + val startY = node.double("startY") ?: 0.5 + val endX = node.double("endX") ?: 1.0 + val endY = node.double("endY") ?: 0.5 + val brush = when { + startY == endY -> Brush.horizontalGradient(colors) + startX == endX -> Brush.verticalGradient(colors) + else -> Brush.linearGradient(colors) + } + // A gradient fills the available width by default (SwiftUI semantics); an + // explicit frame width in the chain still constrains it. fillMaxWidth is + // first so a cornerRadius clip in the chain sees the full width. + Box(modifier = Modifier.fillMaxWidth().then(node.composeModifiers()).background(brush)) +} + +@Composable +private fun RenderImage(node: ViewNode) { + val icon = node.string("systemName")?.let { materialIcon(it) } + if (icon != null) { + val tint = node.modifiers.firstOrNull { it.kind == "foregroundColor" } + ?.args?.long("color")?.let { Color(it.toInt()) } + Icon( + imageVector = icon, + contentDescription = node.string("systemName"), + tint = tint ?: LocalContentColor.current, + modifier = node.composeModifiers(), + ) + } else { + Text("[${node.string("name") ?: "image"}]", modifier = node.composeModifiers()) + } +} + +private fun ViewNode.colorList(key: String): List { + val arr = props[key] as? kotlinx.serialization.json.JsonArray ?: return emptyList() + return arr.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull()?.let { c -> Color(c.toInt()) } } +} + +// A curated SF Symbol → Material icon map; unknown names fall back to the +// placeholder text so gaps stay visible. +private fun materialIcon(name: String): ImageVector? = when (name) { + "star", "star.fill" -> Icons.Filled.Star + "heart", "heart.fill" -> Icons.Filled.Favorite + "trash", "trash.fill" -> Icons.Filled.Delete + "person", "person.fill" -> Icons.Filled.Person + "gear", "gearshape", "gearshape.fill" -> Icons.Filled.Settings + "house", "house.fill" -> Icons.Filled.Home + "magnifyingglass" -> Icons.Filled.Search + "plus" -> Icons.Filled.Add + "checkmark" -> Icons.Filled.Check + "xmark" -> Icons.Filled.Close + "bell", "bell.fill" -> Icons.Filled.Notifications + "envelope", "envelope.fill" -> Icons.Filled.Email + "phone", "phone.fill" -> Icons.Filled.Phone + "lock", "lock.fill" -> Icons.Filled.Lock + "cart", "cart.fill" -> Icons.Filled.ShoppingCart + "hand.thumbsup", "hand.thumbsup.fill" -> Icons.Filled.ThumbUp + "info", "info.circle" -> Icons.Filled.Info + "exclamationmark.triangle" -> Icons.Filled.Warning + "square.and.arrow.up" -> Icons.Filled.Share + "line.3.horizontal" -> Icons.Filled.Menu + "ellipsis" -> Icons.Filled.MoreVert + "play", "play.fill" -> Icons.Filled.PlayArrow + "arrow.clockwise" -> Icons.Filled.Refresh + "calendar" -> Icons.Filled.DateRange + "face.smiling" -> Icons.Filled.Face + "location", "location.fill" -> Icons.Filled.LocationOn + else -> null +} + // 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 From 9ab1402e63c96eeebb717c6fdd29696d8c9124ec Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 6/7] Add graphics playground --- .../Sources/GraphicsPlaygrounds.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/GraphicsPlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/GraphicsPlaygrounds.swift b/Demo/App.swiftpm/Sources/GraphicsPlaygrounds.swift new file mode 100644 index 0000000..c9b3393 --- /dev/null +++ b/Demo/App.swiftpm/Sources/GraphicsPlaygrounds.swift @@ -0,0 +1,48 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct GraphicsPlayground: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Shapes") { + HStack(spacing: 12) { + Rectangle().fill(.blue).frame(width: 60, height: 60) + Circle().fill(.green).frame(width: 60, height: 60) + Capsule().fill(.orange).frame(width: 90, height: 40) + RoundedRectangle(cornerRadius: 12).fill(.purple).frame(width: 60, height: 60) + } + } + Example("Linear gradient (horizontal)") { + LinearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing) + .frame(height: 60) + .cornerRadius(8) + } + Example("Linear gradient (vertical)") { + LinearGradient(colors: [.orange, .red, .pink], startPoint: .top, endPoint: .bottom) + .frame(height: 80) + .cornerRadius(8) + } + Example("SF Symbols") { + HStack(spacing: 16) { + Image(systemName: "star.fill").foregroundColor(.yellow) + Image(systemName: "heart.fill").foregroundColor(.red) + Image(systemName: "trash").foregroundColor(.gray) + Image(systemName: "gear") + Image(systemName: "bell.fill").foregroundColor(.blue) + } + } + Example("Symbols sized") { + HStack(spacing: 16) { + Image(systemName: "star.fill").frame(width: 40, height: 40).foregroundColor(.orange) + Image(systemName: "cart.fill").frame(width: 40, height: 40).foregroundColor(.green) + Image(systemName: "person.fill").frame(width: 40, height: 40).foregroundColor(.blue) + } + } + } + } + } +} From 078451e6159cfe8612998659a98595a3404f45b5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 01:37:45 -0400 Subject: [PATCH 7/7] Add Graphics to the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index e368f8f..f072ac2 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -34,6 +34,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())), CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())), CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())), + CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())), CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())), CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())), CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),