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 +}