Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/kotlin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.gradle/
build/
*/build/
sample/android/build/
sample/android/app/build/
62 changes: 62 additions & 0 deletions packages/kotlin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# OpenUI Kotlin

Native Kotlin support for OpenUI Lang. The package is split into:

- `openui-lang`: Kotlin component library definitions, prompt generation, OpenUI Lang parsing, and streaming parse state.
- `openui-compose`: a Jetpack Compose renderer with a registry for host app components and action callbacks.
- `sample/android`: a minimal Android app that parses an editable OpenUI Lang buffer and renders it with Compose.

## Define a Library

```kotlin
val library = openUILibrary {
component(
name = "Root",
description = "Top-level screen container.",
props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))),
)
component(
name = "Button",
description = "Triggers app behavior.",
props = listOf(
PropDef("label", OpenUIType.StringType),
PropDef("action", OpenUIType.AnyType, required = false),
),
)
root("Root")
}

val systemPrompt = library.prompt()
```

## Parse and Stream

```kotlin
val parser = OpenUIParser(library)
val stream = parser.streamParser()

val partial = stream.push("root = Root([")
val complete = stream.push("Button(\"Save\")])")
```

## Render with Compose

```kotlin
OpenUIRenderer(
node = complete.root,
onAction = { action ->
// Route generated UI events into your Android app.
},
)
```

The default Compose registry includes common primitives for layout, text, forms, buttons, lists, tables, and chart placeholders. Apps can register native components with `OpenUIComposeRegistry.register`.

## Verify

From `packages/kotlin`:

```sh
gradle :openui-lang:test
gradle :sample:android:app:assembleDebug
```
12 changes: 12 additions & 0 deletions packages/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
plugins {
kotlin("jvm") version "2.1.21" apply false
id("com.android.library") version "8.10.1" apply false
id("com.android.application") version "8.10.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.21" apply false
}

subprojects {
group = "dev.openui"
version = "0.1.0"
}
26 changes: 26 additions & 0 deletions packages/kotlin/openui-compose/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}

android {
namespace = "dev.openui.compose"
compileSdk = 35

defaultConfig {
minSdk = 23
}

buildFeatures {
compose = true
}
}

dependencies {
implementation(project(":openui-lang"))
implementation(platform("androidx.compose:compose-bom:2025.05.01"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package dev.openui.compose

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.openui.lang.ElementNode
import dev.openui.lang.OpenUIValue
import dev.openui.lang.asArrayOrEmpty
import dev.openui.lang.asElementOrNull
import dev.openui.lang.asStringOrNull

public data class OpenUIAction(
public val component: String,
public val label: String?,
public val payload: OpenUIValue?,
)

public fun interface ComponentRenderer {
@Composable
public fun Render(node: ElementNode, context: OpenUIRenderContext)
}

public class OpenUIRenderContext(
private val registry: OpenUIComposeRegistry,
private val onAction: (OpenUIAction) -> Unit,
) {
@Composable
public fun Render(node: ElementNode?) {
OpenUIRenderer(node = node, registry = registry, onAction = onAction)
}

public fun dispatch(action: OpenUIAction) {
onAction(action)
}
}

public class OpenUIComposeRegistry(
renderers: Map<String, ComponentRenderer> = emptyMap(),
) {
private val renderers = LinkedHashMap(defaultRenderers()).apply {
putAll(renderers)
}

public fun register(name: String, renderer: ComponentRenderer): OpenUIComposeRegistry = apply {
renderers[name] = renderer
}

internal fun renderer(name: String): ComponentRenderer? = renderers[name]
}

@Composable
public fun OpenUIRenderer(
node: ElementNode?,
registry: OpenUIComposeRegistry = OpenUIComposeRegistry(),
onAction: (OpenUIAction) -> Unit = {},
) {
val context = OpenUIRenderContext(registry, onAction)
if (node == null) {
Text("No UI to render", color = MaterialTheme.colorScheme.onSurfaceVariant)
return
}
registry.renderer(node.typeName)?.Render(node, context)
?: Text("Unknown component: ${node.typeName}", color = MaterialTheme.colorScheme.error)
}

private fun defaultRenderers(): Map<String, ComponentRenderer> =
mapOf(
"Root" to ComponentRenderer { node, context ->
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
node.children().forEach { context.Render(it) }
}
},
"Stack" to ComponentRenderer { node, context ->
val direction = node.stringProp("direction") ?: "column"
if (direction == "row") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
node.children().forEach { context.Render(it) }
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
node.children().forEach { context.Render(it) }
}
}
},
"VStack" to ComponentRenderer { node, context ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
node.children().forEach { context.Render(it) }
}
},
"HStack" to ComponentRenderer { node, context ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
node.children().forEach { context.Render(it) }
}
},
"Card" to ComponentRenderer { node, context ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
node.children().forEach { context.Render(it) }
}
}
},
"Text" to ComponentRenderer { node, _ ->
Text(node.stringProp("text") ?: node.stringProp("children") ?: "")
},
"TextContent" to ComponentRenderer { node, _ ->
Text(node.stringProp("text") ?: "")
},
"Heading" to ComponentRenderer { node, _ ->
Text(node.stringProp("text") ?: "", style = MaterialTheme.typography.headlineSmall)
},
"Button" to ComponentRenderer { node, context ->
val label = node.stringProp("label") ?: node.stringProp("text") ?: "Button"
Button(
onClick = {
context.dispatch(OpenUIAction(node.typeName, label, node.props["action"]))
},
) {
Text(label)
}
},
"Input" to ComponentRenderer { node, context ->
OutlinedTextField(
value = node.stringProp("value") ?: "",
onValueChange = {
context.dispatch(OpenUIAction(node.typeName, node.stringProp("name"), OpenUIValue.StringValue(it)))
},
label = { Text(node.stringProp("label") ?: "") },
modifier = Modifier.fillMaxWidth(),
)
},
"Checkbox" to ComponentRenderer { node, context ->
Row {
Checkbox(
checked = (node.props["checked"] as? OpenUIValue.BooleanValue)?.value ?: false,
onCheckedChange = {
context.dispatch(OpenUIAction(node.typeName, node.stringProp("name"), OpenUIValue.BooleanValue(it)))
},
)
Text(node.stringProp("label") ?: "")
}
},
"List" to ComponentRenderer { node, context ->
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(node.children()) { child -> context.Render(child) }
}
},
"Table" to ComponentRenderer { node, _ ->
Column(modifier = Modifier.fillMaxWidth()) {
Text(node.stringProp("title") ?: "Table", style = MaterialTheme.typography.titleMedium)
Divider()
Text(
"Rows: ${node.props["rows"]?.asArrayOrEmpty().orEmpty().size}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
"Chart" to ComponentRenderer { node, _ ->
Column(modifier = Modifier.fillMaxWidth()) {
Text(node.stringProp("title") ?: "Chart", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(96.dp))
Text("Chart data ready", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
)

private fun ElementNode.children(): List<ElementNode> {
val direct = props["children"]?.asArrayOrEmpty().orEmpty().mapNotNull { it.asElementOrNull() }
if (direct.isNotEmpty()) return direct
return props["items"]?.asArrayOrEmpty().orEmpty().mapNotNull { it.asElementOrNull() }
}

private fun ElementNode.stringProp(name: String): String? =
props[name]?.asStringOrNull()
15 changes: 15 additions & 0 deletions packages/kotlin/openui-lang/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
kotlin("jvm")
}

kotlin {
jvmToolchain(17)
}

tasks.test {
useJUnitPlatform()
}

dependencies {
testImplementation(kotlin("test"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package dev.openui.lang

public class ComponentLibrary(
components: List<ComponentDef> = emptyList(),
public val componentGroups: List<ComponentGroup> = emptyList(),
public val root: String? = null,
) {
private val componentMap: LinkedHashMap<String, ComponentDef> = linkedMapOf()

public val components: Map<String, ComponentDef>
get() = componentMap.toMap()

init {
components.forEach(::register)
require(root == null || componentMap.containsKey(root)) {
"Root component \"$root\" was not found in the component library."
}
}

public fun register(component: ComponentDef): ComponentLibrary = apply {
componentMap[component.name] = component
}

public fun prompt(options: PromptOptions = PromptOptions()): String =
PromptGenerator.generate(this, options)

public fun paramNames(componentName: String): List<String> =
componentMap[componentName]?.props?.map { it.name }.orEmpty()

public fun component(componentName: String): ComponentDef? = componentMap[componentName]
}

public class ComponentLibraryBuilder {
private val components = mutableListOf<ComponentDef>()
private val groups = mutableListOf<ComponentGroup>()
private var root: String? = null

public fun component(
name: String,
description: String,
props: List<PropDef> = emptyList(),
) {
components += ComponentDef(name, description, props)
}

public fun group(name: String, components: List<String>, notes: List<String> = emptyList()) {
groups += ComponentGroup(name, components, notes)
}

public fun root(name: String) {
root = name
}

internal fun build(): ComponentLibrary = ComponentLibrary(components, groups, root)
}

public fun openUILibrary(block: ComponentLibraryBuilder.() -> Unit): ComponentLibrary =
ComponentLibraryBuilder().apply(block).build()
Loading
Loading