diff --git a/README.md b/README.md
index a279370..553ed3d 100644
--- a/README.md
+++ b/README.md
@@ -20,14 +20,14 @@ Gradle (Kotlin DSL):
```kotlin
// commonMain for KMP projects, or any sourceSet/module where you need it
dependencies {
- implementation("com.github.adriankuta:tree-structure:3.1.5") // see badge above for the latest version
+ implementation("com.github.adriankuta:tree-structure:4.0.0") // see badge above for the latest version
}
```
Gradle (Groovy):
```groovy
dependencies {
- implementation "com.github.adriankuta:tree-structure:3.1.5" // see badge above for the latest
+ implementation "com.github.adriankuta:tree-structure:4.0.0" // see badge above for the latest
}
```
@@ -36,7 +36,7 @@ Maven:
com.github.adriankuta
tree-structure
- 3.1.5
+ 4.0.0
```
@@ -179,7 +179,7 @@ separate, opt-in artifacts that depend on the core.
be `@Serializable` directly — convert to/from the acyclic `TreeNodeDto` instead.
```kotlin
-implementation("com.github.adriankuta:tree-structure-serialization:3.4.0")
+implementation("com.github.adriankuta:tree-structure-serialization:4.0.0")
```
```kotlin
@@ -192,7 +192,7 @@ val restored = Json.decodeFromString>(json).toTreeNode()
Traverse a tree as a cold `Flow` (handy in coroutine/`ViewModel` pipelines).
```kotlin
-implementation("com.github.adriankuta:tree-structure-coroutines:3.4.0")
+implementation("com.github.adriankuta:tree-structure-coroutines:4.0.0")
```
```kotlin
@@ -200,6 +200,24 @@ tree.preOrderFlow().collect { println(it.value) }
tree.asFlow(TreeNodeIterators.LevelOrder).map { it.value }
```
+### Compose UI — `tree-structure-compose`
+
+A `LazyTree` composable for Compose Multiplatform (JVM/desktop, iOS, Wasm). Only the visible nodes
+are composed, and you decide how each node looks:
+
+```kotlin
+implementation("com.github.adriankuta:tree-structure-compose:4.0.0")
+```
+
+```kotlin
+LazyTree(root) { node, depth, expanded, toggle ->
+ Row(Modifier.padding(start = (depth * 16).dp).clickable(onClick = toggle)) {
+ if (!node.isLeaf) Text(if (expanded) "▾ " else "▸ ")
+ Text(node.value.toString())
+ }
+}
+```
+
## Publishing to Maven Central (central.sonatype.com)
This project is configured to publish artifacts to Maven Central via the Sonatype Central Portal.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4526f3e..c60ae65 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,6 +6,7 @@ binaryCompatibilityValidator = "0.16.3"
kover = "0.8.3"
coroutines = "1.9.0"
kotlinxSerialization = "1.7.3"
+composeMultiplatform = "1.7.3"
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
@@ -14,6 +15,8 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1d073c0..43aaf1b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -2,3 +2,4 @@ rootProject.name = "tree-structure"
include(":tree-structure-serialization")
include(":tree-structure-coroutines")
+include(":tree-structure-compose")
diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt
index 88f1d96..16cb231 100644
--- a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt
+++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt
@@ -64,12 +64,19 @@ public open class TreeNode(public val value: T, public val treeIterator: Tree
if (child._parent != null) {
throw TreeNodeException("$child already has a parent; call detach() before re-attaching it.")
}
- var ancestor: TreeNode? = this
- while (ancestor != null) {
- if (ancestor === child) {
- throw TreeNodeException("Adding $child here would create a cycle.")
+ if (child === this) {
+ throw TreeNodeException("Adding $child here would create a cycle.")
+ }
+ // Only a node that already has its own subtree can contain `this` and thus form a cycle.
+ // Skipping this walk for leaves keeps building deep trees O(n) instead of O(n²).
+ if (child._children.isNotEmpty()) {
+ var ancestor: TreeNode? = _parent
+ while (ancestor != null) {
+ if (ancestor === child) {
+ throw TreeNodeException("Adding $child here would create a cycle.")
+ }
+ ancestor = ancestor._parent
}
- ancestor = ancestor._parent
}
child._parent = this
_children.add(child)
diff --git a/tree-structure-compose/api/tree-structure-compose.api b/tree-structure-compose/api/tree-structure-compose.api
new file mode 100644
index 0000000..49d41d5
--- /dev/null
+++ b/tree-structure-compose/api/tree-structure-compose.api
@@ -0,0 +1,4 @@
+public final class com/github/adriankuta/datastructure/tree/compose/LazyTreeKt {
+ public static final fun LazyTree (Lcom/github/adriankuta/datastructure/tree/TreeNode;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function6;Landroidx/compose/runtime/Composer;II)V
+}
+
diff --git a/tree-structure-compose/build.gradle.kts b/tree-structure-compose/build.gradle.kts
new file mode 100644
index 0000000..817ee2b
--- /dev/null
+++ b/tree-structure-compose/build.gradle.kts
@@ -0,0 +1,74 @@
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.dokka)
+ alias(libs.plugins.mavenPublish)
+ signing
+}
+
+group = "com.github.adriankuta"
+version = rootProject.version
+
+mavenPublishing {
+ publishToMavenCentral(automaticRelease = false)
+ signAllPublications()
+
+ coordinates("com.github.adriankuta", "tree-structure-compose", version.toString())
+
+ pom {
+ name.set("Tree Data Structure — Compose Multiplatform")
+ description.set("A LazyTree composable (expand/collapse, lazy rendering) for the tree-structure library.")
+ url.set("https://github.com/AdrianKuta/Tree-Data-Structure")
+ licenses {
+ license {
+ name.set("MIT License")
+ url.set("https://opensource.org/licenses/MIT")
+ distribution.set("repo")
+ }
+ }
+ developers {
+ developer {
+ id.set("AdrianKuta")
+ name.set("Adrian Kuta")
+ email.set("adrian.kuta93@gmail.com")
+ }
+ }
+ scm {
+ url.set("https://github.com/AdrianKuta/Tree-Data-Structure")
+ connection.set("scm:git:https://github.com/AdrianKuta/Tree-Data-Structure.git")
+ developerConnection.set("scm:git:ssh://git@github.com/AdrianKuta/Tree-Data-Structure.git")
+ }
+ }
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
+
+kotlin {
+ explicitApi()
+ jvmToolchain(21)
+
+ jvm()
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs {
+ browser()
+ }
+
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ sourceSets {
+ commonMain.dependencies {
+ api(project(":"))
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ }
+ }
+}
diff --git a/tree-structure-compose/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/compose/LazyTree.kt b/tree-structure-compose/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/compose/LazyTree.kt
new file mode 100644
index 0000000..8b6cead
--- /dev/null
+++ b/tree-structure-compose/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/compose/LazyTree.kt
@@ -0,0 +1,71 @@
+package com.github.adriankuta.datastructure.tree.compose
+
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import com.github.adriankuta.datastructure.tree.TreeNode
+
+/**
+ * A lazily-rendered, expand/collapse tree for Compose Multiplatform. Only the currently-visible
+ * nodes are composed, so it scales to large trees. Expansion state is remembered internally, keyed
+ * by node identity.
+ *
+ * ```
+ * LazyTree(root) { node, depth, expanded, toggle ->
+ * Row(Modifier.padding(start = (depth * 16).dp).clickable(onClick = toggle)) {
+ * if (!node.isLeaf) Text(if (expanded) "▾" else "▸")
+ * Text(node.value.toString())
+ * }
+ * }
+ * ```
+ *
+ * @param root the root of the tree to display.
+ * @param modifier the [Modifier] applied to the underlying [LazyColumn].
+ * @param initiallyExpanded whether nodes start expanded.
+ * @param nodeContent renders a single node. Receives the node, its depth (root = 0), whether it is
+ * expanded, and a `toggle` callback that flips this node's expansion state.
+ */
+@Composable
+public fun LazyTree(
+ root: TreeNode,
+ modifier: Modifier = Modifier,
+ initiallyExpanded: Boolean = true,
+ nodeContent: @Composable (node: TreeNode, depth: Int, expanded: Boolean, toggle: () -> Unit) -> Unit,
+) {
+ val expansion = remember(root) { mutableStateMapOf, Boolean>() }
+ val isExpanded: (TreeNode) -> Boolean = { node -> expansion[node] ?: initiallyExpanded }
+
+ val visible = flattenVisible(root, isExpanded)
+
+ LazyColumn(modifier = modifier) {
+ items(visible.size) { index ->
+ val (node, depth) = visible[index]
+ nodeContent(node, depth, isExpanded(node)) {
+ expansion[node] = !isExpanded(node)
+ }
+ }
+ }
+}
+
+/**
+ * Flattens the tree into the list of currently-visible `(node, depth)` pairs in pre-order, skipping
+ * the subtrees of collapsed nodes. Iterative, so it is safe on deep trees.
+ */
+private fun flattenVisible(
+ root: TreeNode,
+ isExpanded: (TreeNode) -> Boolean,
+): List, Int>> {
+ val result = mutableListOf, Int>>()
+ val stack = ArrayDeque, Int>>()
+ stack.addLast(root to 0)
+ while (stack.isNotEmpty()) {
+ val (node, depth) = stack.removeLast()
+ result.add(node to depth)
+ if (isExpanded(node)) {
+ node.children.asReversed().forEach { child -> stack.addLast(child to depth + 1) }
+ }
+ }
+ return result
+}