From d6e1da847e0e032599d58a5ccc974b511f71fa75 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Sat, 25 Jul 2026 17:01:39 +0900 Subject: [PATCH 1/5] feat(flare-ui): add generated native renderers --- .gitignore | 2 + flareUI/README.md | 225 ++++++++ flareUI/android-compose/build.gradle.kts | 46 ++ .../flareui/compose/AndroidComposeBackend.kt | 126 +++++ .../renderers/ButtonComposeRenderer.kt | 22 + .../renderers/ColumnComposeRenderer.kt | 22 + .../compose/renderers/RowComposeRenderer.kt | 22 + .../compose/renderers/TextComposeRenderer.kt | 16 + flareUI/android-view/build.gradle.kts | 57 ++ .../flare/flareui/view/FlareViewHostTest.kt | 172 ++++++ .../flareui/view/AndroidSnapshotManager.kt | 37 ++ .../flare/flareui/view/AndroidViewNode.kt | 68 +++ .../flare/flareui/view/FlareViewHost.kt | 127 +++++ .../view/renderers/ButtonViewRenderer.kt | 37 ++ .../view/renderers/ColumnViewRenderer.kt | 21 + .../flareui/view/renderers/RowViewRenderer.kt | 22 + .../view/renderers/TextViewRenderer.kt | 23 + flareUI/apple-runtime/build.gradle.kts | 45 ++ .../flare/flareui/apple/FlareUiTreeHost.kt | 242 +++++++++ .../flareui/apple/FlareUiTreeHostTest.kt | 55 ++ .../Sources/AppKit/FlareAppKitHostView.swift | 57 ++ .../FlareAppKitNodeFactory.generated.swift | 31 ++ .../Renderers/FlareAppKitButtonRenderer.swift | 43 ++ .../Renderers/FlareAppKitColumnRenderer.swift | 17 + .../Renderers/FlareAppKitRowRenderer.swift | 17 + .../Renderers/FlareAppKitTextRenderer.swift | 9 + .../SwiftUI/FlareSwiftUIChildren.swift | 12 + .../Sources/SwiftUI/FlareSwiftUIHost.swift | 17 + .../Sources/SwiftUI/FlareSwiftUILayout.swift | 5 + .../Sources/SwiftUI/FlareTreeModel.swift | 23 + .../FlareSwiftUINode.generated.swift | 36 ++ .../FlareSwiftUIButtonRenderer.swift | 14 + .../FlareSwiftUIColumnRenderer.swift | 13 + .../Renderers/FlareSwiftUIRowRenderer.swift | 13 + .../Renderers/FlareSwiftUITextRenderer.swift | 12 + .../Sources/UIKit/FlareUIKitHostView.swift | 55 ++ .../FlareUIKitNodeFactory.generated.swift | 31 ++ .../Renderers/FlareUIKitButtonRenderer.swift | 36 ++ .../Renderers/FlareUIKitColumnRenderer.swift | 17 + .../Renderers/FlareUIKitRowRenderer.swift | 17 + .../Renderers/FlareUIKitTextRenderer.swift | 13 + flareUI/apple/project.yml | 112 ++++ flareUI/codegen/build.gradle.kts | 71 +++ .../flare/flareui/codegen/FlareUiGenerator.kt | 503 ++++++++++++++++++ .../flare/flareui/codegen/FlareUiMetadata.kt | 33 ++ .../codegen/FlareUiProcessorProvider.kt | 201 +++++++ ...ols.ksp.processing.SymbolProcessorProvider | 1 + .../flareui/codegen/FlareUiGeneratorTest.kt | 53 ++ flareUI/core/build.gradle.kts | 73 +++ .../dev/dimension/flare/flareui/Button.kt | 33 ++ .../dev/dimension/flare/flareui/Column.kt | 16 + .../dimension/flare/flareui/FlareComponent.kt | 10 + .../kotlin/dev/dimension/flare/flareui/Row.kt | 16 + .../dev/dimension/flare/flareui/Text.kt | 22 + .../dimension/flare/flareui/WidgetRegistry.kt | 188 +++++++ .../flare/flareui/WidgetRegistryTest.kt | 99 ++++ flareUI/demo/androidApp/build.gradle.kts | 30 ++ .../androidApp/src/main/AndroidManifest.xml | 25 + .../demo/android/ComposeDemoActivity.kt | 31 ++ .../flareui/demo/android/MainActivity.kt | 67 +++ .../flareui/demo/android/ViewDemoActivity.kt | 22 + .../src/main/res/resources.properties | 1 + .../androidApp/src/main/res/values/themes.xml | 4 + flareUI/demo/apple-framework/build.gradle.kts | 42 ++ .../flareui/demo/apple/FlareUiAppleDemo.kt | 15 + .../Sources/iOS/FlareUIDemoIOSApp.swift | 73 +++ .../Sources/macOS/FlareUIDemoMacApp.swift | 76 +++ flareUI/demo/appleApp/project.yml | 115 ++++ flareUI/demo/shared/build.gradle.kts | 28 + .../flare/flareui/demo/FlareUiDemo.kt | 54 ++ gradle/libs.versions.toml | 2 + settings.gradle.kts | 8 + 72 files changed, 3899 insertions(+) create mode 100644 flareUI/README.md create mode 100644 flareUI/android-compose/build.gradle.kts create mode 100644 flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/AndroidComposeBackend.kt create mode 100644 flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ButtonComposeRenderer.kt create mode 100644 flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ColumnComposeRenderer.kt create mode 100644 flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/RowComposeRenderer.kt create mode 100644 flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/TextComposeRenderer.kt create mode 100644 flareUI/android-view/build.gradle.kts create mode 100644 flareUI/android-view/src/androidHostTest/kotlin/dev/dimension/flare/flareui/view/FlareViewHostTest.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidSnapshotManager.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidViewNode.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/FlareViewHost.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ButtonViewRenderer.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ColumnViewRenderer.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/RowViewRenderer.kt create mode 100644 flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/TextViewRenderer.kt create mode 100644 flareUI/apple-runtime/build.gradle.kts create mode 100644 flareUI/apple-runtime/src/appleMain/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHost.kt create mode 100644 flareUI/apple-runtime/src/macosArm64Test/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHostTest.kt create mode 100644 flareUI/apple/Sources/AppKit/FlareAppKitHostView.swift create mode 100644 flareUI/apple/Sources/AppKit/Generated/FlareAppKitNodeFactory.generated.swift create mode 100644 flareUI/apple/Sources/AppKit/Renderers/FlareAppKitButtonRenderer.swift create mode 100644 flareUI/apple/Sources/AppKit/Renderers/FlareAppKitColumnRenderer.swift create mode 100644 flareUI/apple/Sources/AppKit/Renderers/FlareAppKitRowRenderer.swift create mode 100644 flareUI/apple/Sources/AppKit/Renderers/FlareAppKitTextRenderer.swift create mode 100644 flareUI/apple/Sources/SwiftUI/FlareSwiftUIChildren.swift create mode 100644 flareUI/apple/Sources/SwiftUI/FlareSwiftUIHost.swift create mode 100644 flareUI/apple/Sources/SwiftUI/FlareSwiftUILayout.swift create mode 100644 flareUI/apple/Sources/SwiftUI/FlareTreeModel.swift create mode 100644 flareUI/apple/Sources/SwiftUI/Generated/FlareSwiftUINode.generated.swift create mode 100644 flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIButtonRenderer.swift create mode 100644 flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIColumnRenderer.swift create mode 100644 flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIRowRenderer.swift create mode 100644 flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUITextRenderer.swift create mode 100644 flareUI/apple/Sources/UIKit/FlareUIKitHostView.swift create mode 100644 flareUI/apple/Sources/UIKit/Generated/FlareUIKitNodeFactory.generated.swift create mode 100644 flareUI/apple/Sources/UIKit/Renderers/FlareUIKitButtonRenderer.swift create mode 100644 flareUI/apple/Sources/UIKit/Renderers/FlareUIKitColumnRenderer.swift create mode 100644 flareUI/apple/Sources/UIKit/Renderers/FlareUIKitRowRenderer.swift create mode 100644 flareUI/apple/Sources/UIKit/Renderers/FlareUIKitTextRenderer.swift create mode 100644 flareUI/apple/project.yml create mode 100644 flareUI/codegen/build.gradle.kts create mode 100644 flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGenerator.kt create mode 100644 flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiMetadata.kt create mode 100644 flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiProcessorProvider.kt create mode 100644 flareUI/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 flareUI/codegen/src/test/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGeneratorTest.kt create mode 100644 flareUI/core/build.gradle.kts create mode 100644 flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Button.kt create mode 100644 flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Column.kt create mode 100644 flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/FlareComponent.kt create mode 100644 flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Row.kt create mode 100644 flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Text.kt create mode 100644 flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/WidgetRegistry.kt create mode 100644 flareUI/core/src/jvmTest/kotlin/dev/dimension/flare/flareui/WidgetRegistryTest.kt create mode 100644 flareUI/demo/androidApp/build.gradle.kts create mode 100644 flareUI/demo/androidApp/src/main/AndroidManifest.xml create mode 100644 flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ComposeDemoActivity.kt create mode 100644 flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt create mode 100644 flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ViewDemoActivity.kt create mode 100644 flareUI/demo/androidApp/src/main/res/resources.properties create mode 100644 flareUI/demo/androidApp/src/main/res/values/themes.xml create mode 100644 flareUI/demo/apple-framework/build.gradle.kts create mode 100644 flareUI/demo/apple-framework/src/appleMain/kotlin/dev/dimension/flare/flareui/demo/apple/FlareUiAppleDemo.kt create mode 100644 flareUI/demo/appleApp/Sources/iOS/FlareUIDemoIOSApp.swift create mode 100644 flareUI/demo/appleApp/Sources/macOS/FlareUIDemoMacApp.swift create mode 100644 flareUI/demo/appleApp/project.yml create mode 100644 flareUI/demo/shared/build.gradle.kts create mode 100644 flareUI/demo/shared/src/commonMain/kotlin/dev/dimension/flare/flareui/demo/FlareUiDemo.kt diff --git a/.gitignore b/.gitignore index 8090a4fb9..bcd95ac5a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ build/ */Pods/* */Flare.xcworkspace/* */Flare.xcodeproj/* +/flareUI/apple/FlareUIApple.xcodeproj/ +/flareUI/demo/appleApp/FlareUIDemo.xcodeproj/ shared/shared.podspec .kotlin .history diff --git a/flareUI/README.md b/flareUI/README.md new file mode 100644 index 000000000..295265afc --- /dev/null +++ b/flareUI/README.md @@ -0,0 +1,225 @@ +# Flare UI + +This is a proof of concept for defining a small UI with Compose Runtime and +rendering it with platform UI toolkits. The current widget vocabulary is only +`Column`, `Row`, `Text`, and `Button`. + +The runtime libraries and runnable demos are deliberately separate. None of the +demo code is linked into the existing Flare Android or Apple applications. + +## Layout + +| Path | Responsibility | +| --- | --- | +| `core` | One Kotlin file per component plus the shared Compose Runtime, registry, and Applier | +| `codegen` | KSP discovery, generated registries/routers/payloads, and one-time renderer scaffolding | +| `android-compose` | Maps the widget tree to Compose Material 3 | +| `android-view` | Maps the widget tree to `LinearLayout`, `MaterialTextView`, and `MaterialButton` | +| `apple-runtime` | Runs a composition and exposes immutable snapshots to Swift | +| `apple` | Independent XcodeGen project with SwiftUI, UIKit, and AppKit framework targets | +| `demo/shared` | Stateful UI definition shared by every demo | +| `demo/androidApp` | Standalone Android app containing Compose and View screens | +| `demo/apple-framework` | Standalone `FlareUIDemoKit` framework and demo factory | +| `demo/appleApp` | XcodeGen app project with only iOS and macOS demo entry points | + +The platform coverage is: + +| Platform | Renderers | +| --- | --- | +| Android | Compose UI and Android Views | +| iOS | SwiftUI and UIKit, each on its own navigation destination | +| macOS | SwiftUI and AppKit, each on its own navigation destination | + +UIKit is iOS-only and AppKit is macOS-only. Both Apple demos use the same shared +definition and link only the renderer frameworks available on their platform. + +## Dependency direction + +```text +core ──KSP/codegen──┬── android-compose glue + ├── android-view glue + ├── apple-runtime payloads + └── SwiftUI/UIKit/AppKit routers + +core ── apple-runtime ── demo/apple-framework ──┬── apple + ▲ │ │ + └── demo/shared └─────┴── demo/appleApp +``` + +There is no runtime backend plug-in system. Each app selects its renderer when +it is assembled, while the iOS demo keeps both of its already-linked renderers +only to make comparison convenient. + +## Default layout semantics + +Compose UI is the reference for the default behavior of the minimal widget +vocabulary: + +| Widget | Default behavior | +| --- | --- | +| `Column` | Wrap content, place children from top to bottom, align children to start, no spacing | +| `Row` | Wrap content, place children from start to end, align children to top, no spacing | +| `Text` | Use intrinsic size and leading multiline alignment | +| `Button` | Use intrinsic content size, center its label, and add no sibling spacing | + +Android Views, SwiftUI, UIKit, and AppKit implement these same layout rules. +Visual styling remains native to each toolkit. UIKit excludes `UIButton`'s +style-dependent outer content insets from its Flare layout size so those insets +do not become gaps inside a zero-spacing `Row`. + +## Run Android + +Build the standalone APK: + +```shell +./gradlew :flareUI:demo:androidApp:assembleDebug +``` + +Install it on a connected device or emulator: + +```shell +./gradlew :flareUI:demo:androidApp:installDebug +``` + +The launcher presents two entries: Compose UI and Android Views. +Both use Material 3 components. An app embedding the Android View renderer +should use a theme derived from `Theme.Material3`; the demo uses +`Theme.Material3.DayNight.NoActionBar`. + +## Run iOS and macOS + +Generate all backend glue and both ignored Xcode projects: + +```shell +./gradlew :flareUI:codegen:generateFlareUiCode +xcodegen generate --spec flareUI/apple/project.yml +xcodegen generate --spec flareUI/demo/appleApp/project.yml +``` + +Open `flareUI/demo/appleApp/FlareUIDemo.xcodeproj`, then choose: + +- `FlareUIDemo-iOS` for the SwiftUI/UIKit navigation demo. +- `FlareUIDemo-macOS` for the SwiftUI/AppKit navigation demo. + +The app project references `flareUI/apple/FlareUIApple.xcodeproj`. That project +provides the following independently buildable static frameworks: + +- `FlareUISwiftUI` for iOS and macOS. +- `FlareUIUIKit` for iOS. +- `FlareUIAppKit` for macOS. + +Both generated Xcode projects are ignored, while the generated Swift routers +are checked in. Each shared scheme runs +`:flareUI:demo:apple-framework:embedAndSignAppleFrameworkForXcode` +before building any Xcode target; that task regenerates all backend glue +before compiling the Kotlin framework. + +## Component generation + +There is no handwritten schema or renderer manifest. A component is declared +next to its props and public Compose Runtime function in `core`: + +```kotlin +@Immutable +public data class BadgeProps( + public val value: String, +) + +@FlareComponent +public data object BadgeType : WidgetType("Badge") + +@Composable +@FlareUiComposable +public fun Badge(value: String) { + EmitWidget(BadgeType, BadgeProps(value), content = {}) +} +``` + +KSP derives the component name from the required `BadgeType` suffix and reads +the props type, constructor properties, and callback events directly from that +declaration. It passes this model straight to the generator; there is no +intermediate schema file. + +`:flareUI:codegen:generateFlareUiCode` then generates: + +- Compose and Android View registry/dispatch glue. +- The Apple node enum, typed payload classes, event methods, snapshot mapping, + and registry. +- SwiftUI, UIKit, and AppKit routers. +- A renderer file for each toolkit if that file does not already exist. + +The last item is deliberately create-only. Running generation again never +overwrites a handwritten renderer. A new renderer starts with a +`FLARE_UI_RENDERER_TODO` marker, and +`:flareUI:codegen:verifyFlareUiRenderers` fails until all five implementations +are completed. Native rendering semantics remain handwritten because they +cannot be inferred from a props class; all repetitive registration and routing +is generated. + +Normal backend compilation depends on generation and verification, so missing +coverage cannot silently ship. Generated Kotlin lives under each module's +`build/generated/flareui` directory. Generated Swift routers live in each +toolkit's `Sources/.../Generated` folder so XcodeGen includes them automatically. + +## Reuse the runtime + +Android Compose: + +```kotlin +FlareComposeContent { + MySharedContent() +} +``` + +Android Views: + +```kotlin +setContentView( + FlareViewHost(this).apply { + setContent { + MySharedContent() + } + }, +) +``` + +On Apple platforms, a small Kotlin framework module creates a +`FlareUiTreeHost` for concrete shared content. SwiftUI, UIKit, or AppKit then +maps its snapshots to native views. Keeping that factory outside +`apple-runtime` prevents the runtime library from depending on any particular +screen. + +The renderer frameworks receive that host explicitly, so they do not depend on +the demo factory: + +```swift +import FlareUISwiftUI + +FlareSwiftUIHost(host: contentFactory.createHost()) +``` + +The UIKit equivalent is `FlareUIKitHostView(host:)` from `FlareUIUIKit`. +On macOS, use `FlareAppKitHostView(host:)` from `FlareUIAppKit`. + +## Extending the widget vocabulary + +Adding a widget no longer touches a central list, registry, `switch`, JSON +schema, or Apple snapshot model: + +1. Add one annotated component file in `core`. +2. Run `:flareUI:codegen:generateFlareUiCode`. +3. Fill in the five create-only renderer files generated for that component. +4. Run `:flareUI:codegen:verifyFlareUiRenderers`. + +The component's public Compose Runtime function decides whether it accepts +children. Function-valued props are exported as typed Apple payload actions; +for example `onClick` becomes `performClick()`. The processor rejects nullable +callbacks and callbacks that do not return `Unit`. + +Generated files grow linearly with the vocabulary, while the stable runtime, +hosts, and handwritten component renderers do not. + +This demo intentionally does not define modifiers, design tokens, +accessibility, focus, navigation, list virtualization, or state restoration. +Those semantics should be added from concrete product requirements rather than +by copying every API from the five host toolkits. diff --git a/flareUI/android-compose/build.gradle.kts b/flareUI/android-compose/build.gradle.kts new file mode 100644 index 000000000..03c9adda4 --- /dev/null +++ b/flareUI/android-compose/build.gradle.kts @@ -0,0 +1,46 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare + +plugins { + id("dev.dimension.flare.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flare { + namespace = "dev.dimension.flare.flareui.android.compose" + platforms(FlarePlatform.ANDROID) + } + android { + withHostTest {} + } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.flareUI.core) + } + } + val androidMain by getting { + kotlin.srcDir(layout.buildDirectory.dir("generated/flareui/kotlin")) + dependencies { + implementation(dependencies.platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.material3) + } + } + } +} + +tasks.configureEach { + if ( + name.startsWith("compile") || + name.startsWith("ksp") || + name.startsWith("runKtlint") || + name == "prepareKotlinIdeaImport" + ) { + dependsOn(":flareUI:codegen:verifyFlareUiRenderers") + } +} diff --git a/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/AndroidComposeBackend.kt b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/AndroidComposeBackend.kt new file mode 100644 index 000000000..0ea2f21f6 --- /dev/null +++ b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/AndroidComposeBackend.kt @@ -0,0 +1,126 @@ +package dev.dimension.flare.flareui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.UiComposable +import dev.dimension.flare.flareui.FlareUiApplier +import dev.dimension.flare.flareui.FlareUiContent +import dev.dimension.flare.flareui.ProvideWidgetRegistry +import dev.dimension.flare.flareui.WidgetNode +import dev.dimension.flare.flareui.WidgetRegistry +import dev.dimension.flare.flareui.WidgetType + +/** + * Hosts the Flare UI composition and renders its small observable tree with Compose UI. + */ +@Composable +@UiComposable +public fun FlareComposeContent(content: FlareUiContent) { + val parent = rememberCompositionContext() + val root = remember { ComposeTreeNode(type = null) } + val registry = remember { composeWidgetRegistry() } + val currentContent by rememberUpdatedState(content) + + DisposableEffect(parent, root, registry) { + val composition = + createFlareComposition( + root = root, + parent = parent, + registry = registry, + content = { currentContent() }, + ) + onDispose(composition::dispose) + } + + root.children.forEach { child -> + key(child) { + renderGeneratedComposeNode(child) + } + } +} + +private fun createFlareComposition( + root: ComposeTreeNode, + parent: CompositionContext, + registry: WidgetRegistry, + content: FlareUiContent, +): Composition = + Composition( + applier = FlareUiApplier(root), + parent = parent, + ).apply { + setContent { + ProvideWidgetRegistry( + registry = registry, + content = { content() }, + ) + } + } + +private fun composeWidgetRegistry(): WidgetRegistry = generatedComposeWidgetRegistry() + +internal class ComposeTreeNode( + type: WidgetType<*>?, +) : WidgetNode(type) { + internal var value: Any? by mutableStateOf(null) + internal val children: MutableList = mutableStateListOf() + + override fun insert( + index: Int, + child: WidgetNode, + ) { + children.add(index, child.requireComposeTreeNode()) + } + + override fun move( + from: Int, + to: Int, + count: Int, + ) { + if (from == to || count == 0) return + + val moved = children.subList(from, from + count).toList() + children.subList(from, from + count).clear() + val destination = if (from > to) to else to - count + children.addAll(destination, moved) + } + + override fun remove( + index: Int, + count: Int, + ) { + children.subList(index, index + count).clear() + } + + override fun clear() { + children.clear() + } + + private fun WidgetNode.requireComposeTreeNode(): ComposeTreeNode = + this as? ComposeTreeNode + ?: error("Cannot mix Compose tree nodes with another backend") +} + +@Composable +@UiComposable +internal fun renderComposeChildren(children: List) { + children.forEach { child -> + key(child) { + renderGeneratedComposeNode(child) + } + } +} + +internal inline fun ComposeTreeNode.requireValue(): P = + value as? P + ?: error("Invalid props for ${type?.debugName}: ${value?.let { it::class.simpleName }}") diff --git a/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ButtonComposeRenderer.kt b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ButtonComposeRenderer.kt new file mode 100644 index 000000000..cfc48b0d9 --- /dev/null +++ b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ButtonComposeRenderer.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.flareui.compose + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.UiComposable +import dev.dimension.flare.flareui.ButtonProps + +@Composable +@UiComposable +@Suppress("UNUSED_PARAMETER") +internal fun renderButtonCompose( + props: ButtonProps, + children: List, +) { + Button( + onClick = props.onClick, + enabled = props.enabled, + ) { + Text(props.label) + } +} diff --git a/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ColumnComposeRenderer.kt b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ColumnComposeRenderer.kt new file mode 100644 index 000000000..2bd2e6727 --- /dev/null +++ b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/ColumnComposeRenderer.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.flareui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.UiComposable + +@Composable +@UiComposable +@Suppress("UNUSED_PARAMETER") +internal fun renderColumnCompose( + props: Unit, + children: List, +) { + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + renderComposeChildren(children) + } +} diff --git a/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/RowComposeRenderer.kt b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/RowComposeRenderer.kt new file mode 100644 index 000000000..69f9f7d70 --- /dev/null +++ b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/RowComposeRenderer.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.flareui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.UiComposable + +@Composable +@UiComposable +@Suppress("UNUSED_PARAMETER") +internal fun renderRowCompose( + props: Unit, + children: List, +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top, + ) { + renderComposeChildren(children) + } +} diff --git a/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/TextComposeRenderer.kt b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/TextComposeRenderer.kt new file mode 100644 index 000000000..cd53598c0 --- /dev/null +++ b/flareUI/android-compose/src/androidMain/kotlin/dev/dimension/flare/flareui/compose/renderers/TextComposeRenderer.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.flareui.compose + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.UiComposable +import dev.dimension.flare.flareui.TextProps + +@Composable +@UiComposable +@Suppress("UNUSED_PARAMETER") +internal fun renderTextCompose( + props: TextProps, + children: List, +) { + Text(props.value) +} diff --git a/flareUI/android-view/build.gradle.kts b/flareUI/android-view/build.gradle.kts new file mode 100644 index 000000000..7b85b9a88 --- /dev/null +++ b/flareUI/android-view/build.gradle.kts @@ -0,0 +1,57 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare + +plugins { + id("dev.dimension.flare.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flare { + namespace = "dev.dimension.flare.flareui.android.view" + platforms(FlarePlatform.ANDROID) + } + android { + withHostTest { + isIncludeAndroidResources = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.flareUI.core) + } + } + val androidMain by getting { + kotlin.srcDir(layout.buildDirectory.dir("generated/flareui/kotlin")) + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.material.components) + implementation( + "org.jetbrains.kotlinx:kotlinx-coroutines-android:" + + libs.versions.kotlinx.coroutines.get(), + ) + } + } + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + } + } + } +} + +tasks.configureEach { + if ( + name.startsWith("compile") || + name.startsWith("ksp") || + name.startsWith("runKtlint") || + name == "prepareKotlinIdeaImport" + ) { + dependsOn(":flareUI:codegen:verifyFlareUiRenderers") + } +} diff --git a/flareUI/android-view/src/androidHostTest/kotlin/dev/dimension/flare/flareui/view/FlareViewHostTest.kt b/flareUI/android-view/src/androidHostTest/kotlin/dev/dimension/flare/flareui/view/FlareViewHostTest.kt new file mode 100644 index 000000000..8fccb4136 --- /dev/null +++ b/flareUI/android-view/src/androidHostTest/kotlin/dev/dimension/flare/flareui/view/FlareViewHostTest.kt @@ -0,0 +1,172 @@ +package dev.dimension.flare.flareui.view + +import android.app.Activity +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.google.android.material.button.MaterialButton +import com.google.android.material.textview.MaterialTextView +import dev.dimension.flare.flareui.Column +import dev.dimension.flare.flareui.Row +import dev.dimension.flare.flareui.Text +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.time.Duration +import kotlin.math.roundToInt +import dev.dimension.flare.flareui.Button as FlareButton + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@LooperMode(LooperMode.Mode.PAUSED) +class FlareViewHostTest { + @Test + fun rendersNativeViewsAndRecomposesAfterClick() { + val activity = + Robolectric + .buildActivity(Activity::class.java) + .setup() + .get() + .apply { + setTheme(com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar) + } + val host = FlareViewHost(activity) + activity.setContentView(host) + + host.setContent { + var count by remember { mutableIntStateOf(0) } + var showDetails by remember { mutableStateOf(false) } + + Column { + Text("Flare UI") + Text("One definition, five native renderers") + Row { + FlareButton( + label = "−", + enabled = count > 0, + onClick = { count -= 1 }, + ) + Text("Count: $count") + FlareButton( + label = "+", + onClick = { count += 1 }, + ) + } + Row { + FlareButton( + label = if (showDetails) "Hide details" else "Show details", + onClick = { showDetails = !showDetails }, + ) + FlareButton( + label = "Reset", + enabled = count != 0, + onClick = { count = 0 }, + ) + } + if (showDetails) { + Text("The state and events live in shared Compose Runtime code.") + } + } + } + shadowOf(Looper.getMainLooper()).idle() + + val column = host.getChildAt(0) as LinearLayout + assertEquals(LinearLayout.VERTICAL, column.orientation) + assertEquals("Flare UI", (column.getChildAt(0) as MaterialTextView).text.toString()) + assertWrapContent(column.getChildAt(0)) + assertEquals( + "One definition, five native renderers", + (column.getChildAt(1) as MaterialTextView).text.toString(), + ) + + val counter = column.getChildAt(2) as LinearLayout + assertEquals(LinearLayout.HORIZONTAL, counter.orientation) + assertWrapContent(counter) + val decrement = counter.getChildAt(0) as MaterialButton + val count = counter.getChildAt(1) as MaterialTextView + val increment = counter.getChildAt(2) as MaterialButton + assertWrapContent(decrement) + assertWrapContent(count) + assertWrapContent(increment) + layoutAtIntrinsicSize(column) + assertEquals(0, column.getChildAt(0).left) + assertEquals(0, column.getChildAt(1).left) + assertEquals(0, counter.left) + assertEquals(column.getChildAt(0).bottom, column.getChildAt(1).top) + assertEquals(column.getChildAt(1).bottom, counter.top) + assertEquals(0, decrement.top) + assertEquals(0, count.top) + assertEquals(0, increment.top) + assertEquals(decrement.right, count.left) + assertEquals(count.right, increment.left) + val composeButtonMinWidth = + ( + COMPOSE_MATERIAL3_BUTTON_MIN_WIDTH_DP * + activity.resources.displayMetrics.density + ).roundToInt() + assertEquals( + buttonWidthDiagnostics(decrement), + composeButtonMinWidth, + decrement.measuredWidth, + ) + assertEquals( + buttonWidthDiagnostics(increment), + composeButtonMinWidth, + increment.measuredWidth, + ) + assertEquals(false, decrement.isEnabled) + assertEquals("Count: 0", count.text.toString()) + + increment.performClick() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + + assertEquals("Count: 1", (counter.getChildAt(1) as MaterialTextView).text.toString()) + assertEquals(true, decrement.isEnabled) + + val controls = column.getChildAt(3) as LinearLayout + (controls.getChildAt(0) as MaterialButton).performClick() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(32)) + + assertEquals( + "The state and events live in shared Compose Runtime code.", + (column.getChildAt(4) as MaterialTextView).text.toString(), + ) + } + + private fun assertWrapContent(view: View) { + assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, view.layoutParams.width) + assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, view.layoutParams.height) + + val params = view.layoutParams as? ViewGroup.MarginLayoutParams ?: return + assertEquals(0, params.leftMargin) + assertEquals(0, params.topMargin) + assertEquals(0, params.rightMargin) + assertEquals(0, params.bottomMargin) + } + + private fun layoutAtIntrinsicSize(view: View) { + view.measure( + View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.AT_MOST), + ) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + } + + private fun buttonWidthDiagnostics(button: MaterialButton): String = + "minimumWidth=${button.minimumWidth}, minWidth=${button.minWidth}, " + + "padding=${button.paddingLeft}+${button.paddingRight}, " + + "compoundPadding=${button.compoundPaddingLeft}+${button.compoundPaddingRight}, " + + "inset=${button.insetLeft}+${button.insetRight}, " + + "textWidth=${button.paint.measureText(button.text.toString())}" +} diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidSnapshotManager.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidSnapshotManager.kt new file mode 100644 index 000000000..34750c07e --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidSnapshotManager.kt @@ -0,0 +1,37 @@ +package dev.dimension.flare.flareui.view + +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Delivers global snapshot writes to standalone Runtime compositions. + * + * Compose UI normally owns this process-level service. A pure Android View host has to start it + * itself so state writes wake the Recomposer. + */ +internal object AndroidSnapshotManager { + private val started = AtomicBoolean(false) + private val notificationPending = AtomicBoolean(false) + + fun ensureStarted() { + if (!started.compareAndSet(false, true)) return + + val notifications = Channel(capacity = Channel.CONFLATED) + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { + for (notification in notifications) { + notificationPending.set(false) + Snapshot.sendApplyNotifications() + } + } + Snapshot.registerGlobalWriteObserver { + if (notificationPending.compareAndSet(false, true)) { + notifications.trySend(Unit) + } + } + } +} diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidViewNode.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidViewNode.kt new file mode 100644 index 000000000..aa75d5988 --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/AndroidViewNode.kt @@ -0,0 +1,68 @@ +package dev.dimension.flare.flareui.view + +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import dev.dimension.flare.flareui.WidgetNode +import dev.dimension.flare.flareui.WidgetType + +internal class AndroidViewNode( + type: WidgetType<*>?, + internal val view: View, +) : WidgetNode(type) { + override fun insert( + index: Int, + child: WidgetNode, + ) { + val parent = view.requireViewGroup() + val childView = child.requireAndroidViewNode().view + if (parent is LinearLayout) { + parent.addView( + childView, + index, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } else { + parent.addView(childView, index) + } + } + + override fun move( + from: Int, + to: Int, + count: Int, + ) { + if (from == to || count == 0) return + + val parent = view.requireViewGroup() + val moved = List(count) { offset -> parent.getChildAt(from + offset) } + parent.removeViews(from, count) + + val destination = if (from > to) to else to - count + moved.forEachIndexed { offset, child -> + parent.addView(child, destination + offset) + } + } + + override fun remove( + index: Int, + count: Int, + ) { + view.requireViewGroup().removeViews(index, count) + } + + override fun clear() { + view.requireViewGroup().removeAllViews() + } + + private fun View.requireViewGroup(): ViewGroup = + this as? ViewGroup + ?: error("${this::class.simpleName} cannot contain children") + + private fun WidgetNode.requireAndroidViewNode(): AndroidViewNode = + this as? AndroidViewNode + ?: error("Cannot mix Android View nodes with another backend") +} diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/FlareViewHost.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/FlareViewHost.kt new file mode 100644 index 000000000..514146d7f --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/FlareViewHost.kt @@ -0,0 +1,127 @@ +package dev.dimension.flare.flareui.view + +import android.content.Context +import android.os.Looper +import android.util.AttributeSet +import android.view.Choreographer +import android.widget.FrameLayout +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import dev.dimension.flare.flareui.FlareUiApplier +import dev.dimension.flare.flareui.FlareUiContent +import dev.dimension.flare.flareui.ProvideWidgetRegistry +import dev.dimension.flare.flareui.WidgetRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +/** + * A standalone Android View host for Flare UI content. + */ +public class FlareViewHost + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : FrameLayout(context, attrs, defStyleAttr) { + private var composition: Composition? = null + private var recomposer: Recomposer? = null + private var recomposerScope: CoroutineScope? = null + private var content: FlareUiContent? = null + private val widgetRegistry: WidgetRegistry by lazy { + androidViewWidgetRegistry(context) + } + + public fun setContent(content: FlareUiContent) { + checkMainThread() + this.content = content + + val currentComposition = composition + if (currentComposition == null) { + createComposition() + } else { + currentComposition.setContent { + ProvideWidgetRegistry( + registry = widgetRegistry, + content = { content() }, + ) + } + } + } + + public fun disposeComposition() { + checkMainThread() + composition?.dispose() + composition = null + recomposer?.cancel() + recomposer = null + recomposerScope?.cancel() + recomposerScope = null + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (composition == null) { + createComposition() + } + } + + override fun onDetachedFromWindow() { + disposeComposition() + super.onDetachedFromWindow() + } + + private fun createComposition() { + val currentContent = content ?: return + AndroidSnapshotManager.ensureStarted() + val coroutineContext = androidRecomposerContext() + val scope = CoroutineScope(coroutineContext) + val newRecomposer = Recomposer(coroutineContext) + val newComposition = + Composition( + applier = + FlareUiApplier( + AndroidViewNode( + type = null, + view = this, + ), + ), + parent = newRecomposer, + ) + + recomposerScope = scope + recomposer = newRecomposer + composition = newComposition + + scope.launch { + newRecomposer.runRecomposeAndApplyChanges() + } + newComposition.setContent { + ProvideWidgetRegistry( + registry = widgetRegistry, + content = { currentContent() }, + ) + } + } + + private fun androidRecomposerContext(): CoroutineContext { + val choreographer = Choreographer.getInstance() + lateinit var frameClock: BroadcastFrameClock + frameClock = + BroadcastFrameClock { + choreographer.postFrameCallback(frameClock::sendFrame) + } + return Dispatchers.Main.immediate + frameClock + SupervisorJob() + } + + private fun checkMainThread() { + check(Looper.myLooper() === Looper.getMainLooper()) { + "FlareViewHost must be used from the Android main thread" + } + } + } diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ButtonViewRenderer.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ButtonViewRenderer.kt new file mode 100644 index 000000000..7f14e6044 --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ButtonViewRenderer.kt @@ -0,0 +1,37 @@ +package dev.dimension.flare.flareui.view + +import android.content.Context +import android.view.Gravity +import com.google.android.material.button.MaterialButton +import dev.dimension.flare.flareui.ButtonProps +import dev.dimension.flare.flareui.ButtonType +import kotlin.math.roundToInt + +internal const val COMPOSE_MATERIAL3_BUTTON_MIN_WIDTH_DP: Int = 58 + +internal fun createButtonView(context: Context): AndroidViewNode = + AndroidViewNode( + type = ButtonType, + view = + MaterialButton(context).apply { + gravity = Gravity.CENTER + val composeMinimumWidth = + ( + COMPOSE_MATERIAL3_BUTTON_MIN_WIDTH_DP * + resources.displayMetrics.density + ).roundToInt() + minimumWidth = composeMinimumWidth + minWidth = composeMinimumWidth + }, + ) + +internal fun updateButtonView( + node: AndroidViewNode, + props: ButtonProps, +) { + (node.view as MaterialButton).apply { + text = props.label + isEnabled = props.enabled + setOnClickListener { props.onClick() } + } +} diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ColumnViewRenderer.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ColumnViewRenderer.kt new file mode 100644 index 000000000..b9a9046a5 --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/ColumnViewRenderer.kt @@ -0,0 +1,21 @@ +package dev.dimension.flare.flareui.view + +import android.content.Context +import android.view.Gravity +import android.widget.LinearLayout + +internal fun createColumnView(context: Context): AndroidViewNode = + AndroidViewNode( + type = dev.dimension.flare.flareui.ColumnType, + view = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.START or Gravity.TOP + }, + ) + +@Suppress("UNUSED_PARAMETER") +internal fun updateColumnView( + node: AndroidViewNode, + props: Unit, +): Unit = Unit diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/RowViewRenderer.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/RowViewRenderer.kt new file mode 100644 index 000000000..f4b7be593 --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/RowViewRenderer.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.flareui.view + +import android.content.Context +import android.view.Gravity +import android.widget.LinearLayout + +internal fun createRowView(context: Context): AndroidViewNode = + AndroidViewNode( + type = dev.dimension.flare.flareui.RowType, + view = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.START or Gravity.TOP + isBaselineAligned = false + }, + ) + +@Suppress("UNUSED_PARAMETER") +internal fun updateRowView( + node: AndroidViewNode, + props: Unit, +): Unit = Unit diff --git a/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/TextViewRenderer.kt b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/TextViewRenderer.kt new file mode 100644 index 000000000..2993bc2a3 --- /dev/null +++ b/flareUI/android-view/src/androidMain/kotlin/dev/dimension/flare/flareui/view/renderers/TextViewRenderer.kt @@ -0,0 +1,23 @@ +package dev.dimension.flare.flareui.view + +import android.content.Context +import android.view.Gravity +import com.google.android.material.textview.MaterialTextView +import dev.dimension.flare.flareui.TextProps +import dev.dimension.flare.flareui.TextType + +internal fun createTextView(context: Context): AndroidViewNode = + AndroidViewNode( + type = TextType, + view = + MaterialTextView(context).apply { + gravity = Gravity.START or Gravity.TOP + }, + ) + +internal fun updateTextView( + node: AndroidViewNode, + props: TextProps, +) { + (node.view as MaterialTextView).text = props.value +} diff --git a/flareUI/apple-runtime/build.gradle.kts b/flareUI/apple-runtime/build.gradle.kts new file mode 100644 index 000000000..a71cca017 --- /dev/null +++ b/flareUI/apple-runtime/build.gradle.kts @@ -0,0 +1,45 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare + +plugins { + id("dev.dimension.flare.multiplatform-library") + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + flare { + platforms( + FlarePlatform.IOS, + FlarePlatform.MACOS, + ) + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.flareUI.core) + implementation(libs.kotlinx.coroutines.core) + } + } + val appleMain by getting { + kotlin.srcDir(layout.buildDirectory.dir("generated/flareui/kotlin")) + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +tasks.configureEach { + if ( + name.startsWith("compile") || + name.startsWith("ksp") || + name.startsWith("runKtlint") || + name == "prepareKotlinIdeaImport" + ) { + dependsOn(":flareUI:codegen:verifyFlareUiRenderers") + } +} diff --git a/flareUI/apple-runtime/src/appleMain/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHost.kt b/flareUI/apple-runtime/src/appleMain/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHost.kt new file mode 100644 index 000000000..fc8debe3b --- /dev/null +++ b/flareUI/apple-runtime/src/appleMain/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHost.kt @@ -0,0 +1,242 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.dimension.flare.flareui.apple + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.snapshots.Snapshot +import dev.dimension.flare.flareui.FlareUiApplier +import dev.dimension.flare.flareui.FlareUiContent +import dev.dimension.flare.flareui.ProvideWidgetRegistry +import dev.dimension.flare.flareui.WidgetNode +import dev.dimension.flare.flareui.WidgetType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import platform.Foundation.NSThread +import platform.QuartzCore.CACurrentMediaTime +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue +import kotlin.coroutines.CoroutineContext +import kotlin.native.HiddenFromObjC + +/** + * An immutable snapshot passed across the Kotlin/Swift boundary. + */ +public class FlareUiNodeSnapshot internal constructor( + public val id: Long, + public val kind: FlareUiNodeKind, + public val payload: FlareUiNodePayload, + public val children: List, +) + +/** + * Owns a standalone Compose Runtime composition and publishes immutable snapshots to Swift. + * + * All methods must be called from the Apple main thread. + */ +public class FlareUiTreeHost internal constructor( + private val content: FlareUiContent, +) { + private var nextNodeId = 1L + private var onTreeChanged: ((List) -> Unit)? = null + private var publishPending = false + private var disposed = false + private val root = + AppleTreeNode( + id = 0L, + type = null, + onChanged = ::schedulePublish, + onAction = ::performAction, + ) + private val registry = generatedAppleWidgetRegistry(::createNode) + private val coroutineContext = appleRecomposerContext() + private val recomposerScope = CoroutineScope(coroutineContext) + private val recomposer = Recomposer(coroutineContext) + private val composition = + Composition( + applier = FlareUiApplier(root), + parent = recomposer, + ) + + init { + checkMainThread() + AppleSnapshotManager.ensureStarted() + recomposerScope.launch { + recomposer.runRecomposeAndApplyChanges() + } + renderContent() + } + + private fun renderContent() { + composition.setContent { + ProvideWidgetRegistry( + registry = registry, + content = { content() }, + ) + } + } + + /** + * Installs one listener and immediately sends the latest complete tree. + */ + public fun setOnTreeChanged(listener: ((List) -> Unit)?) { + checkMainThread() + check(!disposed) { "FlareUiTreeHost is already disposed" } + onTreeChanged = listener + listener?.invoke(snapshot()) + } + + public fun snapshot(): List { + checkMainThread() + return root.snapshotChildren() + } + + public fun dispose() { + checkMainThread() + if (disposed) return + + disposed = true + onTreeChanged = null + composition.dispose() + recomposer.cancel() + recomposerScope.cancel() + } + + private fun createNode(type: WidgetType<*>): AppleTreeNode = + AppleTreeNode( + id = nextNodeId++, + type = type, + onChanged = ::schedulePublish, + onAction = ::performAction, + ) + + private fun performAction(action: () -> Unit) { + checkMainThread() + check(!disposed) { "FlareUiTreeHost is already disposed" } + action() + } + + private fun schedulePublish() { + if (disposed || publishPending || onTreeChanged == null) return + + publishPending = true + dispatch_async(dispatch_get_main_queue()) { + publishPending = false + if (!disposed) { + onTreeChanged?.invoke(snapshot()) + } + } + } +} + +/** + * Creates an Apple tree host without exposing Compose function types to Objective-C. + * + * Public Kotlin modules can use this to provide a concrete, Swift-visible factory. + */ +@HiddenFromObjC +public fun createFlareUiTreeHost(content: FlareUiContent): FlareUiTreeHost = FlareUiTreeHost(content) + +internal class AppleTreeNode( + internal val id: Long, + type: WidgetType<*>?, + private val onChanged: () -> Unit, + private val onAction: (() -> Unit) -> Unit, +) : WidgetNode(type) { + private var props: Any? = null + private set + private val children = mutableListOf() + + internal fun setProps(value: Any) { + props = value + onChanged() + } + + override fun insert( + index: Int, + child: WidgetNode, + ) { + children.add(index, child.requireAppleTreeNode()) + onChanged() + } + + override fun move( + from: Int, + to: Int, + count: Int, + ) { + if (from == to || count == 0) return + + val moved = children.subList(from, from + count).toList() + children.subList(from, from + count).clear() + val destination = if (from > to) to else to - count + children.addAll(destination, moved) + onChanged() + } + + override fun remove( + index: Int, + count: Int, + ) { + children.subList(index, index + count).clear() + onChanged() + } + + override fun clear() { + children.clear() + onChanged() + } + + internal fun snapshotChildren(): List = children.map(AppleTreeNode::generatedSnapshot) + + internal fun dispatchAction(action: () -> Unit) { + onAction(action) + } + + @Suppress("UNCHECKED_CAST") + internal fun

requireProps(): P = + props as? P + ?: error("Invalid props for ${type?.debugName}") + + private fun WidgetNode.requireAppleTreeNode(): AppleTreeNode = + this as? AppleTreeNode + ?: error("Cannot mix Apple tree nodes with another backend") +} + +private object AppleSnapshotManager { + private var started = false + + fun ensureStarted() { + if (started) return + started = true + + Snapshot.registerGlobalWriteObserver { + dispatch_async(dispatch_get_main_queue()) { + Snapshot.sendApplyNotifications() + } + } + } +} + +private fun appleRecomposerContext(): CoroutineContext { + lateinit var frameClock: BroadcastFrameClock + frameClock = + BroadcastFrameClock { + dispatch_async(dispatch_get_main_queue()) { + frameClock.sendFrame((CACurrentMediaTime() * NANOS_PER_SECOND).toLong()) + } + } + return Dispatchers.Main.immediate + frameClock + SupervisorJob() +} + +private fun checkMainThread() { + check(NSThread.isMainThread) { + "FlareUiTreeHost must be used from the Apple main thread" + } +} + +private const val NANOS_PER_SECOND = 1_000_000_000.0 diff --git a/flareUI/apple-runtime/src/macosArm64Test/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHostTest.kt b/flareUI/apple-runtime/src/macosArm64Test/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHostTest.kt new file mode 100644 index 000000000..f97927eb6 --- /dev/null +++ b/flareUI/apple-runtime/src/macosArm64Test/kotlin/dev/dimension/flare/flareui/apple/FlareUiTreeHostTest.kt @@ -0,0 +1,55 @@ +@file:OptIn( + kotlinx.cinterop.BetaInteropApi::class, + kotlinx.cinterop.ExperimentalForeignApi::class, +) + +package dev.dimension.flare.flareui.apple + +import androidx.compose.runtime.mutableIntStateOf +import dev.dimension.flare.flareui.Button +import dev.dimension.flare.flareui.Column +import dev.dimension.flare.flareui.Text +import platform.Foundation.NSDate +import platform.Foundation.NSRunLoop +import platform.Foundation.NSTimer +import platform.Foundation.create +import platform.Foundation.runUntilDate +import kotlin.test.Test +import kotlin.test.assertEquals + +class FlareUiTreeHostTest { + @Test + fun recomposesExternallyOwnedState() { + val count = mutableIntStateOf(0) + val host = + FlareUiTreeHost { + Column { + Text("Count: ${count.intValue}") + Button("+") { + count.intValue += 1 + } + } + } + var nodes = emptyList() + host.setOnTreeChanged { nodes = it } + drainMainRunLoop() + + (nodes.single().children[1].payload as FlareButtonPayload).performClick() + assertEquals(1, count.intValue) + drainMainRunLoop() + + assertEquals( + "Count: 1", + (nodes.single().children[0].payload as FlareTextPayload).value, + ) + host.dispose() + } + + private fun drainMainRunLoop() { + NSTimer.scheduledTimerWithTimeInterval( + interval = 0.05, + repeats = false, + ) {} + NSRunLoop.currentRunLoop.runUntilDate(NSDate.create(timeIntervalSinceNow = 0.1)) + } +} diff --git a/flareUI/apple/Sources/AppKit/FlareAppKitHostView.swift b/flareUI/apple/Sources/AppKit/FlareAppKitHostView.swift new file mode 100644 index 000000000..4fd9cd41f --- /dev/null +++ b/flareUI/apple/Sources/AppKit/FlareAppKitHostView.swift @@ -0,0 +1,57 @@ +@preconcurrency import FlareUIDemoKit +import AppKit + +/// Renders a Flare UI tree with native AppKit views. +@MainActor +public final class FlareAppKitHostView: NSView { + private let host: FlareUiTreeHost + private let rootStack = NSStackView() + + public init(host: FlareUiTreeHost) { + self.host = host + super.init(frame: .zero) + configureRootStack() + host.setOnTreeChanged { [weak self] nodes in + self?.render(nodes) + } + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("Use init(host:) instead") + } + + public override var intrinsicContentSize: NSSize { + rootStack.fittingSize + } + + deinit { + host.setOnTreeChanged(listener: nil) + host.dispose() + } + + private func configureRootStack() { + rootStack.orientation = .vertical + rootStack.alignment = .leading + rootStack.spacing = 0 + rootStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(rootStack) + + NSLayoutConstraint.activate([ + rootStack.leadingAnchor.constraint(equalTo: leadingAnchor), + rootStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + rootStack.topAnchor.constraint(equalTo: topAnchor), + rootStack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + ]) + } + + private func render(_ nodes: [FlareUiNodeSnapshot]) { + rootStack.setViews( + nodes.map { + makeFlareAppKitNodeView(for: $0) + }, + in: .center + ) + invalidateIntrinsicContentSize() + } +} diff --git a/flareUI/apple/Sources/AppKit/Generated/FlareAppKitNodeFactory.generated.swift b/flareUI/apple/Sources/AppKit/Generated/FlareAppKitNodeFactory.generated.swift new file mode 100644 index 000000000..c9a5a35fe --- /dev/null +++ b/flareUI/apple/Sources/AppKit/Generated/FlareAppKitNodeFactory.generated.swift @@ -0,0 +1,31 @@ +// Generated by :flareUI:codegen:generateFlareUiCode. Do not edit. + +@preconcurrency import FlareUIDemoKit +import AppKit + +func makeFlareAppKitNodeView(for node: FlareUiNodeSnapshot) -> NSView { + switch node.kind { + case .button: + return makeFlareAppKitButtonView( + payload: node.payload as! FlareButtonPayload, + children: node.children + ) + case .column: + return makeFlareAppKitColumnView( + payload: node.payload as! FlareColumnPayload, + children: node.children + ) + case .row: + return makeFlareAppKitRowView( + payload: node.payload as! FlareRowPayload, + children: node.children + ) + case .text: + return makeFlareAppKitTextView( + payload: node.payload as! FlareTextPayload, + children: node.children + ) + default: + return NSView() + } +} diff --git a/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitButtonRenderer.swift b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitButtonRenderer.swift new file mode 100644 index 000000000..56c544aee --- /dev/null +++ b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitButtonRenderer.swift @@ -0,0 +1,43 @@ +@preconcurrency import FlareUIDemoKit +import AppKit + +func makeFlareAppKitButtonView( + payload: FlareButtonPayload, + children: [FlareUiNodeSnapshot] +) -> NSView { + let button = FlareAppKitButton( + title: payload.label, + onClick: { + payload.performClick() + } + ) + button.isEnabled = payload.enabled + return button +} + +private final class FlareAppKitButton: NSButton { + private let onClick: () -> Void + + init( + title: String, + onClick: @escaping () -> Void + ) { + self.onClick = onClick + super.init(frame: .zero) + self.title = title + bezelStyle = .rounded + setButtonType(.momentaryPushIn) + target = self + action = #selector(handleClick) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("Use init(title:onClick:) instead") + } + + @objc + private func handleClick() { + onClick() + } +} diff --git a/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitColumnRenderer.swift b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitColumnRenderer.swift new file mode 100644 index 000000000..b0e5f8ab7 --- /dev/null +++ b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitColumnRenderer.swift @@ -0,0 +1,17 @@ +@preconcurrency import FlareUIDemoKit +import AppKit + +func makeFlareAppKitColumnView( + payload: FlareColumnPayload, + children: [FlareUiNodeSnapshot] +) -> NSView { + let stack = NSStackView( + views: children.map { + makeFlareAppKitNodeView(for: $0) + } + ) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 0 + return stack +} diff --git a/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitRowRenderer.swift b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitRowRenderer.swift new file mode 100644 index 000000000..b006b04f4 --- /dev/null +++ b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitRowRenderer.swift @@ -0,0 +1,17 @@ +@preconcurrency import FlareUIDemoKit +import AppKit + +func makeFlareAppKitRowView( + payload: FlareRowPayload, + children: [FlareUiNodeSnapshot] +) -> NSView { + let stack = NSStackView( + views: children.map { + makeFlareAppKitNodeView(for: $0) + } + ) + stack.orientation = .horizontal + stack.alignment = .top + stack.spacing = 0 + return stack +} diff --git a/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitTextRenderer.swift b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitTextRenderer.swift new file mode 100644 index 000000000..e3a1cfa18 --- /dev/null +++ b/flareUI/apple/Sources/AppKit/Renderers/FlareAppKitTextRenderer.swift @@ -0,0 +1,9 @@ +@preconcurrency import FlareUIDemoKit +import AppKit + +func makeFlareAppKitTextView( + payload: FlareTextPayload, + children: [FlareUiNodeSnapshot] +) -> NSView { + NSTextField(wrappingLabelWithString: payload.value) +} diff --git a/flareUI/apple/Sources/SwiftUI/FlareSwiftUIChildren.swift b/flareUI/apple/Sources/SwiftUI/FlareSwiftUIChildren.swift new file mode 100644 index 000000000..1a3f2b945 --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/FlareSwiftUIChildren.swift @@ -0,0 +1,12 @@ +@preconcurrency import FlareUIDemoKit +import SwiftUI + +struct FlareSwiftUIChildren: View { + let nodes: [FlareUiNodeSnapshot] + + var body: some View { + ForEach(nodes, id: \.id) { node in + FlareSwiftUINode(node: node) + } + } +} diff --git a/flareUI/apple/Sources/SwiftUI/FlareSwiftUIHost.swift b/flareUI/apple/Sources/SwiftUI/FlareSwiftUIHost.swift new file mode 100644 index 000000000..e090af08a --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/FlareSwiftUIHost.swift @@ -0,0 +1,17 @@ +@preconcurrency import FlareUIDemoKit +import SwiftUI + +/// Renders a Flare UI tree with native SwiftUI primitives. +public struct FlareSwiftUIHost: View { + @StateObject private var model: FlareTreeModel + + public init(host: FlareUiTreeHost) { + _model = StateObject(wrappedValue: FlareTreeModel(host: host)) + } + + public var body: some View { + VStack(alignment: .leading, spacing: FlareSwiftUILayout.itemSpacing) { + FlareSwiftUIChildren(nodes: model.nodes) + } + } +} diff --git a/flareUI/apple/Sources/SwiftUI/FlareSwiftUILayout.swift b/flareUI/apple/Sources/SwiftUI/FlareSwiftUILayout.swift new file mode 100644 index 000000000..14e9fc049 --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/FlareSwiftUILayout.swift @@ -0,0 +1,5 @@ +import SwiftUI + +enum FlareSwiftUILayout { + static let itemSpacing: CGFloat = 0 +} diff --git a/flareUI/apple/Sources/SwiftUI/FlareTreeModel.swift b/flareUI/apple/Sources/SwiftUI/FlareTreeModel.swift new file mode 100644 index 000000000..0cfccaa8e --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/FlareTreeModel.swift @@ -0,0 +1,23 @@ +import Combine +@preconcurrency import FlareUIDemoKit + +/// Observation glue used by the SwiftUI renderer. +@MainActor +final class FlareTreeModel: ObservableObject { + @Published private(set) var nodes: [FlareUiNodeSnapshot] + + private let host: FlareUiTreeHost + + init(host: FlareUiTreeHost) { + self.host = host + nodes = host.snapshot() + host.setOnTreeChanged { [weak self] nodes in + self?.nodes = nodes + } + } + + deinit { + host.setOnTreeChanged(listener: nil) + host.dispose() + } +} diff --git a/flareUI/apple/Sources/SwiftUI/Generated/FlareSwiftUINode.generated.swift b/flareUI/apple/Sources/SwiftUI/Generated/FlareSwiftUINode.generated.swift new file mode 100644 index 000000000..bb1e18a61 --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/Generated/FlareSwiftUINode.generated.swift @@ -0,0 +1,36 @@ +// Generated by :flareUI:codegen:generateFlareUiCode. Do not edit. + +@preconcurrency import FlareUIDemoKit +import SwiftUI + +struct FlareSwiftUINode: View { + let node: FlareUiNodeSnapshot + + @ViewBuilder + var body: some View { + switch node.kind { + case .button: + FlareSwiftUIButtonRenderer( + payload: node.payload as! FlareButtonPayload, + children: node.children + ) + case .column: + FlareSwiftUIColumnRenderer( + payload: node.payload as! FlareColumnPayload, + children: node.children + ) + case .row: + FlareSwiftUIRowRenderer( + payload: node.payload as! FlareRowPayload, + children: node.children + ) + case .text: + FlareSwiftUITextRenderer( + payload: node.payload as! FlareTextPayload, + children: node.children + ) + default: + EmptyView() + } + } +} diff --git a/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIButtonRenderer.swift b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIButtonRenderer.swift new file mode 100644 index 000000000..2f96e5ff9 --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIButtonRenderer.swift @@ -0,0 +1,14 @@ +@preconcurrency import FlareUIDemoKit +import SwiftUI + +struct FlareSwiftUIButtonRenderer: View { + let payload: FlareButtonPayload + let children: [FlareUiNodeSnapshot] + + var body: some View { + Button(payload.label) { + payload.performClick() + } + .disabled(!payload.enabled) + } +} diff --git a/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIColumnRenderer.swift b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIColumnRenderer.swift new file mode 100644 index 000000000..45b100d08 --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIColumnRenderer.swift @@ -0,0 +1,13 @@ +@preconcurrency import FlareUIDemoKit +import SwiftUI + +struct FlareSwiftUIColumnRenderer: View { + let payload: FlareColumnPayload + let children: [FlareUiNodeSnapshot] + + var body: some View { + VStack(alignment: .leading, spacing: FlareSwiftUILayout.itemSpacing) { + FlareSwiftUIChildren(nodes: children) + } + } +} diff --git a/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIRowRenderer.swift b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIRowRenderer.swift new file mode 100644 index 000000000..86034af1b --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUIRowRenderer.swift @@ -0,0 +1,13 @@ +@preconcurrency import FlareUIDemoKit +import SwiftUI + +struct FlareSwiftUIRowRenderer: View { + let payload: FlareRowPayload + let children: [FlareUiNodeSnapshot] + + var body: some View { + HStack(alignment: .top, spacing: FlareSwiftUILayout.itemSpacing) { + FlareSwiftUIChildren(nodes: children) + } + } +} diff --git a/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUITextRenderer.swift b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUITextRenderer.swift new file mode 100644 index 000000000..da8f7f1d9 --- /dev/null +++ b/flareUI/apple/Sources/SwiftUI/Renderers/FlareSwiftUITextRenderer.swift @@ -0,0 +1,12 @@ +@preconcurrency import FlareUIDemoKit +import SwiftUI + +struct FlareSwiftUITextRenderer: View { + let payload: FlareTextPayload + let children: [FlareUiNodeSnapshot] + + var body: some View { + Text(payload.value) + .multilineTextAlignment(.leading) + } +} diff --git a/flareUI/apple/Sources/UIKit/FlareUIKitHostView.swift b/flareUI/apple/Sources/UIKit/FlareUIKitHostView.swift new file mode 100644 index 000000000..1e375d784 --- /dev/null +++ b/flareUI/apple/Sources/UIKit/FlareUIKitHostView.swift @@ -0,0 +1,55 @@ +@preconcurrency import FlareUIDemoKit +import UIKit + +/// Renders a Flare UI tree with native UIKit views. +@MainActor +public final class FlareUIKitHostView: UIView { + private let host: FlareUiTreeHost + private let rootStack = UIStackView() + + public init(host: FlareUiTreeHost) { + self.host = host + super.init(frame: .zero) + configureRootStack() + host.setOnTreeChanged { [weak self] nodes in + self?.render(nodes) + } + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("Use init(host:) instead") + } + + deinit { + host.setOnTreeChanged(listener: nil) + host.dispose() + } + + private func configureRootStack() { + rootStack.axis = .vertical + rootStack.alignment = .leading + rootStack.spacing = 0 + rootStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(rootStack) + + NSLayoutConstraint.activate([ + rootStack.leadingAnchor.constraint(equalTo: leadingAnchor), + rootStack.trailingAnchor.constraint(equalTo: trailingAnchor), + rootStack.topAnchor.constraint(equalTo: topAnchor), + rootStack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + ]) + } + + private func render(_ nodes: [FlareUiNodeSnapshot]) { + for view in rootStack.arrangedSubviews { + rootStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + for node in nodes { + rootStack.addArrangedSubview( + makeFlareUIKitNodeView(for: node) + ) + } + } +} diff --git a/flareUI/apple/Sources/UIKit/Generated/FlareUIKitNodeFactory.generated.swift b/flareUI/apple/Sources/UIKit/Generated/FlareUIKitNodeFactory.generated.swift new file mode 100644 index 000000000..544875611 --- /dev/null +++ b/flareUI/apple/Sources/UIKit/Generated/FlareUIKitNodeFactory.generated.swift @@ -0,0 +1,31 @@ +// Generated by :flareUI:codegen:generateFlareUiCode. Do not edit. + +@preconcurrency import FlareUIDemoKit +import UIKit + +func makeFlareUIKitNodeView(for node: FlareUiNodeSnapshot) -> UIView { + switch node.kind { + case .button: + return makeFlareUIKitButtonView( + payload: node.payload as! FlareButtonPayload, + children: node.children + ) + case .column: + return makeFlareUIKitColumnView( + payload: node.payload as! FlareColumnPayload, + children: node.children + ) + case .row: + return makeFlareUIKitRowView( + payload: node.payload as! FlareRowPayload, + children: node.children + ) + case .text: + return makeFlareUIKitTextView( + payload: node.payload as! FlareTextPayload, + children: node.children + ) + default: + return UIView() + } +} diff --git a/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitButtonRenderer.swift b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitButtonRenderer.swift new file mode 100644 index 000000000..a1adba45e --- /dev/null +++ b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitButtonRenderer.swift @@ -0,0 +1,36 @@ +@preconcurrency import FlareUIDemoKit +import UIKit + +func makeFlareUIKitButtonView( + payload: FlareButtonPayload, + children: [FlareUiNodeSnapshot] +) -> UIView { + let button = FlareUIKitButton(type: .system) + button.setTitle(payload.label, for: .normal) + button.isEnabled = payload.enabled + button.addAction( + UIAction { _ in + payload.performClick() + }, + for: .touchUpInside + ) + return button +} + +/// `UIButton` on recent iOS SDKs includes style-dependent padding in its +/// intrinsic size. Flare rows follow Compose's zero-spacing layout contract, +/// so that padding must not become spacing between sibling widgets. +private final class FlareUIKitButton: UIButton { + override var intrinsicContentSize: CGSize { + titleLabel?.intrinsicContentSize ?? super.intrinsicContentSize + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + titleLabel?.sizeThatFits(size) ?? super.sizeThatFits(size) + } + + override func layoutSubviews() { + super.layoutSubviews() + titleLabel?.frame = bounds + } +} diff --git a/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitColumnRenderer.swift b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitColumnRenderer.swift new file mode 100644 index 000000000..342e6df57 --- /dev/null +++ b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitColumnRenderer.swift @@ -0,0 +1,17 @@ +@preconcurrency import FlareUIDemoKit +import UIKit + +func makeFlareUIKitColumnView( + payload: FlareColumnPayload, + children: [FlareUiNodeSnapshot] +) -> UIView { + let stack = UIStackView( + arrangedSubviews: children.map { + makeFlareUIKitNodeView(for: $0) + } + ) + stack.axis = .vertical + stack.alignment = .leading + stack.spacing = 0 + return stack +} diff --git a/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitRowRenderer.swift b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitRowRenderer.swift new file mode 100644 index 000000000..e696913d3 --- /dev/null +++ b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitRowRenderer.swift @@ -0,0 +1,17 @@ +@preconcurrency import FlareUIDemoKit +import UIKit + +func makeFlareUIKitRowView( + payload: FlareRowPayload, + children: [FlareUiNodeSnapshot] +) -> UIView { + let stack = UIStackView( + arrangedSubviews: children.map { + makeFlareUIKitNodeView(for: $0) + } + ) + stack.axis = .horizontal + stack.alignment = .top + stack.spacing = 0 + return stack +} diff --git a/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitTextRenderer.swift b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitTextRenderer.swift new file mode 100644 index 000000000..003ff1fed --- /dev/null +++ b/flareUI/apple/Sources/UIKit/Renderers/FlareUIKitTextRenderer.swift @@ -0,0 +1,13 @@ +@preconcurrency import FlareUIDemoKit +import UIKit + +func makeFlareUIKitTextView( + payload: FlareTextPayload, + children: [FlareUiNodeSnapshot] +) -> UIView { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .natural + label.text = payload.value + return label +} diff --git a/flareUI/apple/project.yml b/flareUI/apple/project.yml new file mode 100644 index 000000000..68183b900 --- /dev/null +++ b/flareUI/apple/project.yml @@ -0,0 +1,112 @@ +name: FlareUIApple + +options: + minimumXcodeGenVersion: 2.45.4 + developmentLanguage: en + defaultConfig: Debug + deploymentTarget: + iOS: "17.0" + macOS: "14.0" + +configs: + Debug: debug + Release: release + +settings: + base: + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: NO + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 + "EXCLUDED_ARCHS[sdk=macosx*]": x86_64 + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor + SWIFT_VERSION: "6.0" + +targetTemplates: + FlareUIAppleRenderer: + type: framework.static + settings: + base: + DEFINES_MODULE: YES + FRAMEWORK_SEARCH_PATHS: + - "$(inherited)" + - "$(SRCROOT)/../demo/apple-framework/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)" + GENERATE_INFOPLIST_FILE: YES + SKIP_INSTALL: YES + +targets: + FlareUISwiftUI: + templates: [FlareUIAppleRenderer] + platform: [iOS, macOS] + sources: + - path: Sources/SwiftUI + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.swiftui + PRODUCT_MODULE_NAME: FlareUISwiftUI + PRODUCT_NAME: FlareUISwiftUI + + FlareUIUIKit: + templates: [FlareUIAppleRenderer] + platform: iOS + sources: + - path: Sources/UIKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.uikit + PRODUCT_MODULE_NAME: FlareUIUIKit + PRODUCT_NAME: FlareUIUIKit + + FlareUIAppKit: + templates: [FlareUIAppleRenderer] + platform: macOS + sources: + - path: Sources/AppKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.dimension.flare.flareui.appkit + PRODUCT_MODULE_NAME: FlareUIAppKit + PRODUCT_NAME: FlareUIAppKit + +schemes: + FlareUISwiftUI-iOS: + build: + targets: + FlareUISwiftUI_iOS: all + preActions: + - name: Generate Flare UI code and compile Kotlin framework + settingsTarget: FlareUISwiftUI_iOS + script: &flareUiBuildScript | + cd "$SRCROOT/../.." + export ARCHS=arm64 + if [ -n "${GRADLE_JVM_ARGS:-}" ]; then + ./gradlew "-Dorg.gradle.jvmargs=$GRADLE_JVM_ARGS" :flareUI:demo:apple-framework:embedAndSignAppleFrameworkForXcode + else + ./gradlew :flareUI:demo:apple-framework:embedAndSignAppleFrameworkForXcode + fi + + FlareUISwiftUI-macOS: + build: + targets: + FlareUISwiftUI_macOS: all + preActions: + - name: Generate Flare UI code and compile Kotlin framework + settingsTarget: FlareUISwiftUI_macOS + script: *flareUiBuildScript + + FlareUIUIKit-iOS: + build: + targets: + FlareUIUIKit: all + preActions: + - name: Generate Flare UI code and compile Kotlin framework + settingsTarget: FlareUIUIKit + script: *flareUiBuildScript + + FlareUIAppKit-macOS: + build: + targets: + FlareUIAppKit: all + preActions: + - name: Generate Flare UI code and compile Kotlin framework + settingsTarget: FlareUIAppKit + script: *flareUiBuildScript diff --git a/flareUI/codegen/build.gradle.kts b/flareUI/codegen/build.gradle.kts new file mode 100644 index 000000000..70e4f12db --- /dev/null +++ b/flareUI/codegen/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(libs.versions.java.get().toInt()) +} + +dependencies { + implementation(libs.ksp.symbol.processing.api) + + testImplementation(kotlin("test")) +} + +val repositoryRoot = rootProject.layout.projectDirectory + +val generateFlareUiCode = + tasks.register("generateFlareUiCode") { + group = "flare ui" + description = "Generates Flare UI registries, Apple payloads, and native router glue." + + dependsOn(":flareUI:core:kspKotlinJvm") + } + +val rendererDirectories = + listOf( + repositoryRoot.dir( + "flareUI/android-compose/src/androidMain/kotlin/" + + "dev/dimension/flare/flareui/compose/renderers", + ), + repositoryRoot.dir( + "flareUI/android-view/src/androidMain/kotlin/" + + "dev/dimension/flare/flareui/view/renderers", + ), + repositoryRoot.dir("flareUI/apple/Sources/SwiftUI/Renderers"), + repositoryRoot.dir("flareUI/apple/Sources/UIKit/Renderers"), + repositoryRoot.dir("flareUI/apple/Sources/AppKit/Renderers"), + ) + +val verifyFlareUiRenderers = + tasks.register("verifyFlareUiRenderers") { + group = "verification" + description = "Fails when a generated Flare UI renderer scaffold is still incomplete." + + dependsOn(generateFlareUiCode) + + inputs.files(rendererDirectories) + doLast { + val incomplete = + rendererDirectories + .flatMap { directory -> + directory.asFile + .walkTopDown() + .filter(File::isFile) + .filter { file -> + file.readText().contains("FLARE_UI_RENDERER_TODO") + }.map { file -> file.relativeTo(repositoryRoot.asFile) } + .toList() + } + check(incomplete.isEmpty()) { + incomplete.joinToString( + prefix = "Incomplete Flare UI renderers:\n- ", + separator = "\n- ", + ) + } + } + } + +tasks.named("check") { + dependsOn(verifyFlareUiRenderers) +} diff --git a/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGenerator.kt b/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGenerator.kt new file mode 100644 index 000000000..f66cd7609 --- /dev/null +++ b/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGenerator.kt @@ -0,0 +1,503 @@ +package dev.dimension.flare.flareui.codegen + +import java.io.File + +internal const val INCOMPLETE_RENDERER_MARKER = "FLARE_UI_RENDERER_TODO" + +internal class GeneratorWorkspace( + private val repositoryRoot: File, +) { + fun generate(components: List) { + writeGenerated( + relativePath = + "flareUI/android-compose/build/generated/flareui/kotlin/" + + "dev/dimension/flare/flareui/compose/GeneratedComposeWidgets.kt", + content = renderComposeGlue(components), + ) + writeGenerated( + relativePath = + "flareUI/android-view/build/generated/flareui/kotlin/" + + "dev/dimension/flare/flareui/view/GeneratedViewWidgets.kt", + content = renderAndroidViewGlue(components), + ) + writeGenerated( + relativePath = + "flareUI/apple-runtime/build/generated/flareui/kotlin/" + + "dev/dimension/flare/flareui/apple/GeneratedAppleWidgets.kt", + content = renderAppleRuntimeGlue(components), + ) + writeGenerated( + relativePath = + "flareUI/apple/Sources/SwiftUI/Generated/" + + "FlareSwiftUINode.generated.swift", + content = renderSwiftUiRouter(components), + ) + writeGenerated( + relativePath = + "flareUI/apple/Sources/UIKit/Generated/" + + "FlareUIKitNodeFactory.generated.swift", + content = + renderAppleViewFactory( + components = components, + toolkit = "UIKit", + viewType = "UIView", + ), + ) + writeGenerated( + relativePath = + "flareUI/apple/Sources/AppKit/Generated/" + + "FlareAppKitNodeFactory.generated.swift", + content = + renderAppleViewFactory( + components = components, + toolkit = "AppKit", + viewType = "NSView", + ), + ) + + components + .flatMap(::rendererTargets) + .forEach { target -> + writeScaffold( + relativePath = target.path, + content = target.content, + ) + } + } + + private fun rendererTargets(component: ComponentMetadata): List = + listOf( + RendererTarget( + path = + "flareUI/android-compose/src/androidMain/kotlin/" + + "dev/dimension/flare/flareui/compose/renderers/" + + "${component.className}ComposeRenderer.kt", + content = renderComposeScaffold(component), + ), + RendererTarget( + path = + "flareUI/android-view/src/androidMain/kotlin/" + + "dev/dimension/flare/flareui/view/renderers/" + + "${component.className}ViewRenderer.kt", + content = renderAndroidViewScaffold(component), + ), + RendererTarget( + path = + "flareUI/apple/Sources/SwiftUI/Renderers/" + + "FlareSwiftUI${component.className}Renderer.swift", + content = renderSwiftUiScaffold(component), + ), + RendererTarget( + path = + "flareUI/apple/Sources/UIKit/Renderers/" + + "FlareUIKit${component.className}Renderer.swift", + content = + renderAppleViewScaffold( + component = component, + toolkit = "UIKit", + viewType = "UIView", + ), + ), + RendererTarget( + path = + "flareUI/apple/Sources/AppKit/Renderers/" + + "FlareAppKit${component.className}Renderer.swift", + content = + renderAppleViewScaffold( + component = component, + toolkit = "AppKit", + viewType = "NSView", + ), + ), + ) + + private fun writeGenerated( + relativePath: String, + content: String, + ) { + val file = File(repositoryRoot, relativePath) + file.parentFile.mkdirs() + if (!file.exists() || file.readText() != content) { + file.writeText(content) + } + } + + private fun writeScaffold( + relativePath: String, + content: String, + ) { + val file = File(repositoryRoot, relativePath) + if (file.exists()) return + file.parentFile.mkdirs() + file.writeText(content) + } +} + +private data class RendererTarget( + val path: String, + val content: String, +) + +private fun renderComposeGlue(components: List): String = + buildString { + appendGeneratedHeader() + appendLine("package dev.dimension.flare.flareui.compose") + appendLine() + appendLine("import androidx.compose.runtime.Composable") + appendLine("import androidx.compose.ui.UiComposable") + appendLine("import dev.dimension.flare.flareui.WidgetRegistry") + appendLine() + appendLine("internal fun generatedComposeWidgetRegistry(): WidgetRegistry =") + appendLine(" WidgetRegistry.build {") + components.forEach { component -> + appendLine(" bind(") + appendLine(" ${component.type},") + appendLine(" { ComposeTreeNode(${component.type}) },") + appendLine(" ) { value = it }") + } + appendLine(" }") + appendLine() + appendLine("@Composable") + appendLine("@UiComposable") + appendLine("internal fun renderGeneratedComposeNode(node: ComposeTreeNode) {") + appendLine(" when (node.type) {") + components.forEach { component -> + appendLine(" ${component.type} ->") + appendLine( + " render${component.className}Compose(", + ) + appendLine( + " props = node.requireValue<${component.propsType}>(),", + ) + appendLine(" children = node.children,") + appendLine(" )") + } + appendLine() + appendLine( + " else -> error(\"Unsupported Compose widget: \${node.type?.debugName}\")", + ) + appendLine(" }") + appendLine("}") + } + +private fun renderAndroidViewGlue(components: List): String = + buildString { + appendGeneratedHeader() + appendLine("package dev.dimension.flare.flareui.view") + appendLine() + appendLine("import android.content.Context") + appendLine("import dev.dimension.flare.flareui.WidgetRegistry") + appendLine() + appendLine( + "public fun androidViewWidgetRegistry(context: Context): WidgetRegistry =", + ) + appendLine(" WidgetRegistry.build {") + components.forEach { component -> + appendLine(" bind(") + appendLine(" type = ${component.type},") + appendLine( + " create = { create${component.className}View(context) },", + ) + appendLine( + " update = { props: ${component.propsType} ->", + ) + appendLine( + " update${component.className}View(this, props)", + ) + appendLine(" },") + appendLine(" )") + } + appendLine(" }") + } + +private fun renderAppleRuntimeGlue(components: List): String = + buildString { + appendGeneratedHeader() + appendLine("package dev.dimension.flare.flareui.apple") + appendLine() + appendLine("import dev.dimension.flare.flareui.WidgetRegistry") + appendLine("import dev.dimension.flare.flareui.WidgetType") + appendLine() + appendLine("public enum class FlareUiNodeKind {") + components.forEach { component -> + appendLine(" ${component.kindName},") + } + appendLine("}") + appendLine() + appendLine( + "public abstract class FlareUiNodePayload internal constructor()", + ) + components.forEach { component -> + appendLine() + appendPayload(component) + } + appendLine() + appendLine("internal fun generatedAppleWidgetRegistry(") + appendLine( + " createNode: (WidgetType<*>) -> AppleTreeNode,", + ) + appendLine("): WidgetRegistry =") + appendLine(" WidgetRegistry.build {") + components.forEach { component -> + appendLine(" bind(") + appendLine(" type = ${component.type},") + appendLine(" create = { createNode(${component.type}) },") + appendLine(" update = { setProps(it) },") + appendLine(" )") + } + appendLine(" }") + appendLine() + appendLine( + "internal fun AppleTreeNode.generatedSnapshot(): FlareUiNodeSnapshot =", + ) + appendLine(" when (type) {") + components.forEach { component -> + appendLine(" ${component.type} -> {") + if (component.properties.isNotEmpty()) { + appendLine( + " val props = requireProps<${component.propsType}>()", + ) + } + appendLine(" FlareUiNodeSnapshot(") + appendLine(" id = id,") + appendLine( + " kind = FlareUiNodeKind.${component.kindName},", + ) + if (component.properties.isEmpty()) { + appendLine( + " payload = ${component.payloadName}(),", + ) + } else { + appendLine(" payload =") + appendLine( + " ${component.payloadName}(", + ) + component.properties.forEach { property -> + appendLine( + " ${property.name} = ${property.mapperExpression()},", + ) + } + appendLine(" ),") + } + appendLine(" children = snapshotChildren(),") + appendLine(" )") + appendLine(" }") + } + appendLine() + appendLine( + " else -> error(\"Unsupported Apple widget: \${type?.debugName}\")", + ) + appendLine(" }") + } + +private fun StringBuilder.appendPayload(component: ComponentMetadata) { + if (component.properties.isEmpty()) { + appendLine( + "public class ${component.payloadName} internal constructor() : " + + "FlareUiNodePayload()", + ) + return + } + appendLine("public class ${component.payloadName} internal constructor(") + component.properties.forEach { property -> + if (property.eventParameters == null) { + appendLine(" public val ${property.name}: ${property.type},") + } else { + appendLine(" private val ${property.name}: ${property.type},") + } + } + val events = + component.properties.filter { property -> + property.eventParameters != null + } + if (events.isEmpty()) { + appendLine(") : FlareUiNodePayload()") + return + } + appendLine(") : FlareUiNodePayload() {") + events.forEach { property -> + val parameters = requireNotNull(property.eventParameters) + if (parameters.isEmpty()) { + appendLine( + " public fun ${property.eventMethodName()}(): Unit = ${property.name}()", + ) + } else { + appendLine( + " public fun ${property.eventMethodName()}(", + ) + parameters.forEach { parameter -> + appendLine(" ${parameter.name}: ${parameter.type},") + } + appendLine(" ): Unit = ${property.name}(") + parameters.forEach { parameter -> + appendLine(" ${parameter.name},") + } + appendLine(" )") + } + } + appendLine("}") +} + +private fun PropertyMetadata.mapperExpression(): String { + val parameters = eventParameters ?: return "props.$name" + val arguments = parameters.joinToString { parameter -> parameter.name } + return if (parameters.isEmpty()) { + "{ dispatchAction { props.$name() } }" + } else { + "{ $arguments -> dispatchAction { props.$name($arguments) } }" + } +} + +private fun PropertyMetadata.eventMethodName(): String { + val stem = + name + .removePrefix("on") + .ifEmpty { name } + return "perform${stem.replaceFirstChar(Char::uppercaseChar)}" +} + +private fun renderSwiftUiRouter(components: List): String = + buildString { + appendGeneratedHeader() + appendLine("@preconcurrency import FlareUIDemoKit") + appendLine("import SwiftUI") + appendLine() + appendLine("struct FlareSwiftUINode: View {") + appendLine(" let node: FlareUiNodeSnapshot") + appendLine() + appendLine(" @ViewBuilder") + appendLine(" var body: some View {") + appendLine(" switch node.kind {") + components.forEach { component -> + appendLine(" case .${component.id}:") + appendLine( + " FlareSwiftUI${component.className}Renderer(", + ) + appendLine( + " payload: node.payload as! ${component.payloadName},", + ) + appendLine(" children: node.children") + appendLine(" )") + } + appendLine(" default:") + appendLine(" EmptyView()") + appendLine(" }") + appendLine(" }") + appendLine("}") + } + +private fun renderAppleViewFactory( + components: List, + toolkit: String, + viewType: String, +): String = + buildString { + appendGeneratedHeader() + appendLine("@preconcurrency import FlareUIDemoKit") + appendLine("import $toolkit") + appendLine() + appendLine( + "func makeFlare${toolkit}NodeView(for node: FlareUiNodeSnapshot) -> $viewType {", + ) + appendLine(" switch node.kind {") + components.forEach { component -> + appendLine(" case .${component.id}:") + appendLine( + " return makeFlare${toolkit}${component.className}View(", + ) + appendLine( + " payload: node.payload as! ${component.payloadName},", + ) + appendLine(" children: node.children") + appendLine(" )") + } + appendLine(" default:") + appendLine(" return $viewType()") + appendLine(" }") + appendLine("}") + } + +private fun renderComposeScaffold(component: ComponentMetadata): String = + buildString { + appendLine("package dev.dimension.flare.flareui.compose") + appendLine() + appendLine("import androidx.compose.runtime.Composable") + appendLine("import androidx.compose.ui.UiComposable") + appendLine() + appendLine("// $INCOMPLETE_RENDERER_MARKER") + appendLine("@Composable") + appendLine("@UiComposable") + appendLine("internal fun render${component.className}Compose(") + appendLine(" props: ${component.propsType},") + appendLine(" children: List,") + appendLine(") {") + appendLine( + " error(\"Implement the ${component.id} Compose renderer\")", + ) + appendLine("}") + } + +private fun renderAndroidViewScaffold(component: ComponentMetadata): String = + buildString { + appendLine("package dev.dimension.flare.flareui.view") + appendLine() + appendLine("import android.content.Context") + appendLine() + appendLine("// $INCOMPLETE_RENDERER_MARKER") + appendLine( + "internal fun create${component.className}View(context: Context): AndroidViewNode =", + ) + appendLine( + " error(\"Implement the ${component.id} Android View renderer\")", + ) + appendLine() + appendLine("internal fun update${component.className}View(") + appendLine(" node: AndroidViewNode,") + appendLine(" props: ${component.propsType},") + appendLine(") {") + appendLine( + " error(\"Implement the ${component.id} Android View update\")", + ) + appendLine("}") + } + +private fun renderSwiftUiScaffold(component: ComponentMetadata): String = + buildString { + appendLine("@preconcurrency import FlareUIDemoKit") + appendLine("import SwiftUI") + appendLine() + appendLine("// $INCOMPLETE_RENDERER_MARKER") + appendLine( + "struct FlareSwiftUI${component.className}Renderer: View {", + ) + appendLine(" let payload: ${component.payloadName}") + appendLine(" let children: [FlareUiNodeSnapshot]") + appendLine() + appendLine(" var body: some View {") + appendLine(" EmptyView()") + appendLine(" }") + appendLine("}") + } + +private fun renderAppleViewScaffold( + component: ComponentMetadata, + toolkit: String, + viewType: String, +): String = + buildString { + appendLine("@preconcurrency import FlareUIDemoKit") + appendLine("import $toolkit") + appendLine() + appendLine("// $INCOMPLETE_RENDERER_MARKER") + appendLine("func makeFlare${toolkit}${component.className}View(") + appendLine(" payload: ${component.payloadName},") + appendLine(" children: [FlareUiNodeSnapshot]") + appendLine(") -> $viewType {") + appendLine(" $viewType()") + appendLine("}") + } + +private fun StringBuilder.appendGeneratedHeader() { + appendLine("// Generated by :flareUI:codegen:generateFlareUiCode. Do not edit.") + appendLine() +} diff --git a/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiMetadata.kt b/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiMetadata.kt new file mode 100644 index 000000000..8fbf7d985 --- /dev/null +++ b/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiMetadata.kt @@ -0,0 +1,33 @@ +package dev.dimension.flare.flareui.codegen + +internal data class ComponentMetadata( + val type: String, + val propsType: String, + val properties: List, +) + +internal data class PropertyMetadata( + val name: String, + val type: String, + val eventParameters: List? = null, +) + +internal data class EventParameterMetadata( + val name: String, + val type: String, +) + +internal val ComponentMetadata.className: String + get() = type.substringAfterLast('.').removeSuffix("Type") + +internal val ComponentMetadata.id: String + get() = className.replaceFirstChar(Char::lowercaseChar) + +internal val ComponentMetadata.kindName: String + get() = + className + .replace(Regex("([a-z0-9])([A-Z])"), "$1_$2") + .uppercase() + +internal val ComponentMetadata.payloadName: String + get() = "Flare${className}Payload" diff --git a/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiProcessorProvider.kt b/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiProcessorProvider.kt new file mode 100644 index 000000000..944883a4e --- /dev/null +++ b/flareUI/codegen/src/main/kotlin/dev/dimension/flare/flareui/codegen/FlareUiProcessorProvider.kt @@ -0,0 +1,201 @@ +package dev.dimension.flare.flareui.codegen + +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueParameter +import com.google.devtools.ksp.validate +import java.io.File + +public class FlareUiProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + FlareUiProcessor( + logger = environment.logger, + options = environment.options, + ) +} + +private class FlareUiProcessor( + private val logger: KSPLogger, + private val options: Map, +) : SymbolProcessor { + private var generated = false + + override fun process(resolver: Resolver): List { + if (generated) return emptyList() + + val symbols = resolver.getSymbolsWithAnnotation(FLARE_COMPONENT_ANNOTATION).toList() + val invalid = symbols.filterNot(KSAnnotated::validate) + if (invalid.isNotEmpty()) return invalid + + val components = + symbols + .filterIsInstance() + .mapNotNull(::parseComponent) + .sortedBy(ComponentMetadata::className) + + if (components.isEmpty()) return emptyList() + + val repositoryRoot = options[REPOSITORY_ROOT_OPTION] + if (repositoryRoot.isNullOrBlank()) { + logger.error("KSP option $REPOSITORY_ROOT_OPTION is required.") + return emptyList() + } + + GeneratorWorkspace(File(repositoryRoot)).generate(components) + generated = true + return emptyList() + } + + private fun parseComponent(declaration: KSClassDeclaration): ComponentMetadata? { + if (declaration.classKind != ClassKind.OBJECT) { + logger.error("@FlareComponent can only annotate an object.", declaration) + return null + } + + val typeName = declaration.simpleName.asString() + if (typeName == "Type" || !typeName.endsWith("Type")) { + logger.error( + "@FlareComponent object names must end in Type.", + declaration, + ) + return null + } + + val widgetSuperType = + declaration.superTypes + .map { reference -> reference.resolve() } + .firstOrNull { type -> + type.declaration.qualifiedName?.asString() == WIDGET_TYPE + } + val propsType = + widgetSuperType + ?.arguments + ?.singleOrNull() + ?.type + ?.resolve() + if (propsType == null) { + logger.error( + "@FlareComponent objects must directly extend WidgetType.", + declaration, + ) + return null + } + + val propsTypeName = propsType.renderType() + val properties = + if (propsTypeName == "kotlin.Unit") { + emptyList() + } else { + parseProperties(propsType, declaration) ?: return null + } + + return ComponentMetadata( + type = + declaration.qualifiedName?.asString() + ?: error("Flare component must have a qualified name"), + propsType = propsTypeName, + properties = properties, + ) + } + + private fun parseProperties( + propsType: KSType, + component: KSClassDeclaration, + ): List? { + val propsDeclaration = propsType.declaration as? KSClassDeclaration + val constructor = propsDeclaration?.primaryConstructor + if (propsDeclaration == null || constructor == null) { + logger.error( + "Flare component props must be a class with a primary constructor.", + component, + ) + return null + } + + val propertyNames = + propsDeclaration + .getAllProperties() + .map { property -> property.simpleName.asString() } + .toSet() + return constructor.parameters.map { parameter -> + val name = parameter.name?.asString() + if (name == null || name !in propertyNames) { + logger.error( + "Every Flare props constructor parameter must be a property.", + parameter, + ) + return null + } + parseProperty(name, parameter) ?: return null + } + } + + private fun parseProperty( + name: String, + parameter: KSValueParameter, + ): PropertyMetadata? { + val type = parameter.type.resolve() + val declarationName = + type.declaration.qualifiedName + ?.asString() + .orEmpty() + if (declarationName.startsWith("kotlin.Function")) { + if (type.isMarkedNullable) { + logger.error("Nullable Flare UI events are not supported.", parameter) + return null + } + val arguments = type.arguments.mapNotNull { argument -> argument.type?.resolve() } + val returnType = arguments.lastOrNull() + if (returnType?.renderType() != "kotlin.Unit") { + logger.error("Flare UI events must return Unit.", parameter) + return null + } + return PropertyMetadata( + name = name, + type = type.renderType(), + eventParameters = + arguments + .dropLast(1) + .mapIndexed { index, eventType -> + EventParameterMetadata( + name = "value$index", + type = eventType.renderType(), + ) + }, + ) + } + return PropertyMetadata( + name = name, + type = type.renderType(), + ) + } + + private fun KSType.renderType(): String { + val declarationName = + declaration.qualifiedName?.asString() + ?: error("Flare UI types must have qualified names") + val arguments = + arguments.mapNotNull { argument -> + argument.type?.resolve()?.renderType() + } + return buildString { + append(declarationName) + if (arguments.isNotEmpty()) { + append(arguments.joinToString(prefix = "<", postfix = ">")) + } + if (isMarkedNullable) append('?') + } + } +} + +private const val FLARE_COMPONENT_ANNOTATION = + "dev.dimension.flare.flareui.FlareComponent" +private const val WIDGET_TYPE = "dev.dimension.flare.flareui.WidgetType" +private const val REPOSITORY_ROOT_OPTION = "flareUiRepositoryRoot" diff --git a/flareUI/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/flareUI/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 000000000..ad847fd31 --- /dev/null +++ b/flareUI/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +dev.dimension.flare.flareui.codegen.FlareUiProcessorProvider diff --git a/flareUI/codegen/src/test/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGeneratorTest.kt b/flareUI/codegen/src/test/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGeneratorTest.kt new file mode 100644 index 000000000..01e71a2a3 --- /dev/null +++ b/flareUI/codegen/src/test/kotlin/dev/dimension/flare/flareui/codegen/FlareUiGeneratorTest.kt @@ -0,0 +1,53 @@ +package dev.dimension.flare.flareui.codegen + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class FlareUiGeneratorTest { + @Test + fun generatesGlueAndNeverOverwritesRendererImplementations() { + val repositoryRoot = Files.createTempDirectory("flare-ui-codegen-test").toFile() + try { + val components = + listOf( + ComponentMetadata( + type = "example.TextType", + propsType = "example.TextProps", + properties = + listOf( + PropertyMetadata( + name = "value", + type = "kotlin.String", + ), + ), + ), + ) + val workspace = GeneratorWorkspace(repositoryRoot) + + workspace.generate(components) + + val composeGlue = + repositoryRoot.resolve( + "flareUI/android-compose/build/generated/flareui/kotlin/" + + "dev/dimension/flare/flareui/compose/GeneratedComposeWidgets.kt", + ) + assertContains(composeGlue.readText(), "example.TextType") + + val renderer = + repositoryRoot.resolve( + "flareUI/android-compose/src/androidMain/kotlin/" + + "dev/dimension/flare/flareui/compose/renderers/" + + "TextComposeRenderer.kt", + ) + assertContains(renderer.readText(), INCOMPLETE_RENDERER_MARKER) + + renderer.writeText("handwritten renderer") + workspace.generate(components) + assertEquals("handwritten renderer", renderer.readText()) + } finally { + repositoryRoot.deleteRecursively() + } + } +} diff --git a/flareUI/core/build.gradle.kts b/flareUI/core/build.gradle.kts new file mode 100644 index 000000000..cea014d3d --- /dev/null +++ b/flareUI/core/build.gradle.kts @@ -0,0 +1,73 @@ +import dev.dimension.flare.buildlogic.FlarePlatform +import dev.dimension.flare.buildlogic.flare + +plugins { + id("dev.dimension.flare.multiplatform-library") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ksp) +} + +val repositoryRoot = rootProject.layout.projectDirectory +val generatedComposeSources = + project(":flareUI:android-compose").layout.buildDirectory + .dir("generated/flareui/kotlin") +val generatedViewSources = + project(":flareUI:android-view").layout.buildDirectory + .dir("generated/flareui/kotlin") +val generatedAppleSources = + project(":flareUI:apple-runtime").layout.buildDirectory + .dir("generated/flareui/kotlin") + +kotlin { + flare { + namespace = "dev.dimension.flare.flareui.core" + platforms( + FlarePlatform.ANDROID, + FlarePlatform.JVM, + FlarePlatform.IOS, + FlarePlatform.MACOS, + ) + } + + sourceSets { + val commonMain by getting { + dependencies { + api(dependencies.platform(libs.compose.bom)) + api(libs.compose.runtime) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +dependencies { + add("kspJvm", projects.flareUI.codegen) +} + +ksp { + arg("flareUiRepositoryRoot", repositoryRoot.asFile.absolutePath) +} + +tasks.matching { task -> task.name == "kspKotlinJvm" } + .configureEach { + outputs.dir(generatedComposeSources) + outputs.dir(generatedViewSources) + outputs.dir(generatedAppleSources) + outputs.files( + repositoryRoot.file( + "flareUI/apple/Sources/SwiftUI/Generated/FlareSwiftUINode.generated.swift", + ), + repositoryRoot.file( + "flareUI/apple/Sources/UIKit/Generated/FlareUIKitNodeFactory.generated.swift", + ), + repositoryRoot.file( + "flareUI/apple/Sources/AppKit/Generated/FlareAppKitNodeFactory.generated.swift", + ), + ) + } diff --git a/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Button.kt b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Button.kt new file mode 100644 index 000000000..e3412403e --- /dev/null +++ b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Button.kt @@ -0,0 +1,33 @@ +package dev.dimension.flare.flareui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable + +@Immutable +public data class ButtonProps( + public val label: String, + public val enabled: Boolean, + public val onClick: () -> Unit, +) + +@FlareComponent +public data object ButtonType : WidgetType("Button") + +@Composable +@FlareUiComposable +public fun Button( + label: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + EmitWidget( + type = ButtonType, + props = + ButtonProps( + label = label, + enabled = enabled, + onClick = onClick, + ), + content = {}, + ) +} diff --git a/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Column.kt b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Column.kt new file mode 100644 index 000000000..5c7c19289 --- /dev/null +++ b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Column.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.flareui + +import androidx.compose.runtime.Composable + +@FlareComponent +public data object ColumnType : WidgetType("Column") + +@Composable +@FlareUiComposable +public fun Column(content: FlareUiContent) { + EmitWidget( + type = ColumnType, + props = Unit, + content = content, + ) +} diff --git a/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/FlareComponent.kt b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/FlareComponent.kt new file mode 100644 index 000000000..1d8f0f9d2 --- /dev/null +++ b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/FlareComponent.kt @@ -0,0 +1,10 @@ +package dev.dimension.flare.flareui + +/** + * Marks one [WidgetType] as part of the generated Flare UI component vocabulary. + * + * The code generator discovers the props type from [WidgetType]'s generic argument. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.BINARY) +public annotation class FlareComponent diff --git a/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Row.kt b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Row.kt new file mode 100644 index 000000000..bd7141358 --- /dev/null +++ b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Row.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.flareui + +import androidx.compose.runtime.Composable + +@FlareComponent +public data object RowType : WidgetType("Row") + +@Composable +@FlareUiComposable +public fun Row(content: FlareUiContent) { + EmitWidget( + type = RowType, + props = Unit, + content = content, + ) +} diff --git a/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Text.kt b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Text.kt new file mode 100644 index 000000000..255edbf16 --- /dev/null +++ b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/Text.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.flareui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable + +@Immutable +public data class TextProps( + public val value: String, +) + +@FlareComponent +public data object TextType : WidgetType("Text") + +@Composable +@FlareUiComposable +public fun Text(value: String) { + EmitWidget( + type = TextType, + props = TextProps(value), + content = {}, + ) +} diff --git a/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/WidgetRegistry.kt b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/WidgetRegistry.kt new file mode 100644 index 000000000..d4d08a177 --- /dev/null +++ b/flareUI/core/src/commonMain/kotlin/dev/dimension/flare/flareui/WidgetRegistry.kt @@ -0,0 +1,188 @@ +@file:Suppress("ktlint:standard:annotation") + +package dev.dimension.flare.flareui + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableTargetMarker +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf + +@Retention(AnnotationRetention.BINARY) +@ComposableTargetMarker(description = "Flare UI") +@Target( + AnnotationTarget.FILE, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, +) +public annotation class FlareUiComposable + +public typealias FlareUiContent = @Composable @FlareUiComposable () -> Unit + +/** + * A stable, typed identifier for one host widget. + * + * Adding a widget creates another [WidgetType]; it does not add a method to a central interface. + */ +public abstract class WidgetType

( + public val debugName: String, +) + +/** + * A node managed by [FlareUiApplier]. Native backends can wrap their real platform widget here. + */ +public abstract class WidgetNode( + public val type: WidgetType<*>?, +) { + public open fun insert( + index: Int, + child: WidgetNode, + ): Unit = unsupportedChildren() + + public open fun move( + from: Int, + to: Int, + count: Int, + ): Unit = unsupportedChildren() + + public open fun remove( + index: Int, + count: Int, + ): Unit = unsupportedChildren() + + public open fun clear(): Unit = unsupportedChildren() + + private fun unsupportedChildren(): Nothing { + error("${type?.debugName ?: "Root"} cannot contain children") + } +} + +/** + * The one Applier target used by all shared Flare UI declarations. + */ +public class FlareUiApplier( + root: WidgetNode, +) : AbstractApplier(root) { + override fun insertTopDown( + index: Int, + instance: WidgetNode, + ) { + current.insert(index, instance) + } + + override fun insertBottomUp( + index: Int, + instance: WidgetNode, + ): Unit = Unit + + override fun remove( + index: Int, + count: Int, + ) { + current.remove(index, count) + } + + override fun move( + from: Int, + to: Int, + count: Int, + ) { + current.move(from, to, count) + } + + override fun onClear() { + root.clear() + } +} + +/** + * An immutable, typed link table from widget descriptors to backend node factories. + */ +public class WidgetRegistry private constructor( + private val entries: Map, Entry>, +) { + public class Builder { + private val entries = mutableMapOf, Entry>() + + @Suppress("UNCHECKED_CAST") + public fun

bind( + type: WidgetType

, + create: () -> N, + update: N.(P) -> Unit, + ) { + val entry = + Entry( + create = create, + update = { node, props -> update(node as N, props as P) }, + ) + check(entries.put(type, entry) == null) { + "A factory for ${type.debugName} is already registered" + } + } + + internal fun build(): WidgetRegistry = WidgetRegistry(entries.toMap()) + } + + public companion object { + public fun build(configure: Builder.() -> Unit): WidgetRegistry = Builder().apply(configure).build() + } + + internal fun

create(type: WidgetType

): WidgetNode = entry(type).create() + + internal fun

update( + type: WidgetType

, + node: WidgetNode, + props: P, + ) { + entry(type).update(node, props) + } + + private fun entry(type: WidgetType<*>): Entry = entries[type] ?: error("No factory registered for ${type.debugName}") + + private class Entry( + val create: () -> WidgetNode, + val update: (WidgetNode, Any) -> Unit, + ) +} + +private val LocalWidgetRegistry = + staticCompositionLocalOf { + error("No WidgetRegistry was provided") + } + +/** + * Installs one immutable backend registry for [content]. + */ +@Composable +@FlareUiComposable +public fun ProvideWidgetRegistry( + registry: WidgetRegistry, + content: FlareUiContent, +) { + CompositionLocalProvider( + LocalWidgetRegistry provides registry, + content = { content() }, + ) +} + +@Composable +@FlareUiComposable +internal fun

EmitWidget( + type: WidgetType

, + props: P, + content: FlareUiContent, +) { + val registry = LocalWidgetRegistry.current + ComposeNode( + factory = { registry.create(type) }, + update = { + set(props) { + registry.update(type, this, it) + } + }, + content = { content() }, + ) +} diff --git a/flareUI/core/src/jvmTest/kotlin/dev/dimension/flare/flareui/WidgetRegistryTest.kt b/flareUI/core/src/jvmTest/kotlin/dev/dimension/flare/flareui/WidgetRegistryTest.kt new file mode 100644 index 000000000..0c74d5858 --- /dev/null +++ b/flareUI/core/src/jvmTest/kotlin/dev/dimension/flare/flareui/WidgetRegistryTest.kt @@ -0,0 +1,99 @@ +package dev.dimension.flare.flareui + +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class WidgetRegistryTest { + @Test + fun buildsTypedTreeAndDispatchesButtonEvent() { + val root = RecordingNode(type = null) + val registry = recordingRegistry() + var clicks = 0 + val composition = + Composition( + applier = FlareUiApplier(root), + parent = Recomposer(EmptyCoroutineContext), + ) + + composition.setContent { + ProvideWidgetRegistry(registry) { + Column { + Text("Title") + Row { + Text("Value") + Button( + label = "Tap", + onClick = { clicks += 1 }, + ) + } + } + } + } + + val column = root.children.single() + assertEquals(ColumnType, column.type) + assertEquals("Title", assertIs(column.children[0].value).value) + + val row = column.children[1] + assertEquals(RowType, row.type) + val button = assertIs(row.children[1].value) + assertEquals("Tap", button.label) + + button.onClick() + assertEquals(1, clicks) + + composition.dispose() + } + + private fun recordingRegistry(): WidgetRegistry = + WidgetRegistry.build { + bind(ColumnType, { RecordingNode(ColumnType) }) { value = it } + bind(RowType, { RecordingNode(RowType) }) { value = it } + bind(TextType, { RecordingNode(TextType) }) { value = it } + bind(ButtonType, { RecordingNode(ButtonType) }) { value = it } + } +} + +private class RecordingNode( + type: WidgetType<*>?, +) : WidgetNode(type) { + var value: Any? = null + val children = mutableListOf() + + override fun insert( + index: Int, + child: WidgetNode, + ) { + children.add(index, child as RecordingNode) + } + + override fun move( + from: Int, + to: Int, + count: Int, + ) { + val moved = children.subList(from, from + count).toList() + repeat(count) { + children.removeAt(from) + } + val destination = if (from > to) to else to - count + children.addAll(destination, moved) + } + + override fun remove( + index: Int, + count: Int, + ) { + repeat(count) { + children.removeAt(index) + } + } + + override fun clear() { + children.clear() + } +} diff --git a/flareUI/demo/androidApp/build.gradle.kts b/flareUI/demo/androidApp/build.gradle.kts new file mode 100644 index 000000000..89b392b4a --- /dev/null +++ b/flareUI/demo/androidApp/build.gradle.kts @@ -0,0 +1,30 @@ +import dev.dimension.flare.buildlogic.flare + +plugins { + id("dev.dimension.flare.android-application") + alias(libs.plugins.compose.compiler) +} + +flare { + namespace = "dev.dimension.flare.flareui.demo.android" + applicationId = "dev.dimension.flare.flareui.demo" +} + +android { + defaultConfig { + versionCode = 1 + versionName = "1.0" + } +} + +dependencies { + implementation(projects.flareUI.demo.shared) + implementation(projects.flareUI.androidCompose) + implementation(projects.flareUI.androidView) + implementation(libs.activity.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.ui) + implementation(libs.material3) + implementation(libs.material.components) + coreLibraryDesugaring(libs.desugar.jdk.libs) +} diff --git a/flareUI/demo/androidApp/src/main/AndroidManifest.xml b/flareUI/demo/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f3f4b5333 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ComposeDemoActivity.kt b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ComposeDemoActivity.kt new file mode 100644 index 000000000..00a06c0ae --- /dev/null +++ b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ComposeDemoActivity.kt @@ -0,0 +1,31 @@ +package dev.dimension.flare.flareui.demo.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.dimension.flare.flareui.compose.FlareComposeContent +import dev.dimension.flare.flareui.demo.FlareUiDemo + +public class ComposeDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.padding(24.dp)) { + FlareComposeContent { + FlareUiDemo() + } + } + } + } + } + } +} diff --git a/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt new file mode 100644 index 000000000..065c96b49 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/MainActivity.kt @@ -0,0 +1,67 @@ +package dev.dimension.flare.flareui.demo.android + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.Gravity +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import com.google.android.material.button.MaterialButton +import com.google.android.material.textview.MaterialTextView + +public class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val padding = 24.dp + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(padding, padding, padding, padding) + + addView( + MaterialTextView(context).apply { + text = "Choose the Android renderer" + textSize = 22f + }, + matchWidth(), + ) + addView( + MaterialButton(context).apply { + text = "Compose UI" + setOnClickListener { + open(ComposeDemoActivity::class.java) + } + }, + matchWidth(), + ) + addView( + MaterialButton(context).apply { + text = "Android Views" + setOnClickListener { + open(ViewDemoActivity::class.java) + } + }, + matchWidth(), + ) + }, + ) + } + + private fun open(activityClass: Class) { + startActivity(Intent(this, activityClass)) + } + + private fun matchWidth(): LayoutParams = + LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = 12.dp + } + + private val Int.dp: Int + get() = (this * resources.displayMetrics.density).toInt() +} diff --git a/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ViewDemoActivity.kt b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ViewDemoActivity.kt new file mode 100644 index 000000000..954c8b4b8 --- /dev/null +++ b/flareUI/demo/androidApp/src/main/kotlin/dev/dimension/flare/flareui/demo/android/ViewDemoActivity.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.flareui.demo.android + +import android.app.Activity +import android.os.Bundle +import dev.dimension.flare.flareui.demo.FlareUiDemo +import dev.dimension.flare.flareui.view.FlareViewHost + +public class ViewDemoActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val padding = (24 * resources.displayMetrics.density).toInt() + setContentView( + FlareViewHost(this).apply { + setPadding(padding, padding, padding, padding) + setContent { + FlareUiDemo() + } + }, + ) + } +} diff --git a/flareUI/demo/androidApp/src/main/res/resources.properties b/flareUI/demo/androidApp/src/main/res/resources.properties new file mode 100644 index 000000000..92481bb0b --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en diff --git a/flareUI/demo/androidApp/src/main/res/values/themes.xml b/flareUI/demo/androidApp/src/main/res/values/themes.xml new file mode 100644 index 000000000..e61e7d44a --- /dev/null +++ b/flareUI/demo/androidApp/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +