mirror of
https://github.com/AdrianKuta/Tree-Data-Structure.git
synced 2026-06-19 19:00:14 +02:00
feat: tree-structure-compose (LazyTree) + O(n) addChild cycle check
- New published module tree-structure-compose: a LazyTree composable for Compose Multiplatform (JVM/desktop, iOS, Wasm) with lazy rendering and expand/collapse. - Fix an O(n^2) regression in addChild(): only walk ancestors for cycle detection when the child already has a subtree (a fresh leaf can never form a cycle), so building deep trees is O(n) again. Caught by the deep-chain stack-safety test on JS. - README: Compose usage section; align all install snippets to 4.0.0. - Version catalog: Compose Multiplatform + compose-compiler plugins. Verified locally: JVM, JS(node), Wasm(node), iOS-simulator tests + apiCheck all green; Compose module compiles for JVM, Wasm and iOS.
This commit is contained in:
28
README.md
28
README.md
@@ -20,14 +20,14 @@ Gradle (Kotlin DSL):
|
|||||||
```kotlin
|
```kotlin
|
||||||
// commonMain for KMP projects, or any sourceSet/module where you need it
|
// commonMain for KMP projects, or any sourceSet/module where you need it
|
||||||
dependencies {
|
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):
|
Gradle (Groovy):
|
||||||
```groovy
|
```groovy
|
||||||
dependencies {
|
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:
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.adriankuta</groupId>
|
<groupId>com.github.adriankuta</groupId>
|
||||||
<artifactId>tree-structure</artifactId>
|
<artifactId>tree-structure</artifactId>
|
||||||
<version>3.1.5</version>
|
<version>4.0.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ separate, opt-in artifacts that depend on the core.
|
|||||||
be `@Serializable` directly — convert to/from the acyclic `TreeNodeDto` instead.
|
be `@Serializable` directly — convert to/from the acyclic `TreeNodeDto` instead.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
implementation("com.github.adriankuta:tree-structure-serialization:3.4.0")
|
implementation("com.github.adriankuta:tree-structure-serialization:4.0.0")
|
||||||
```
|
```
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
@@ -192,7 +192,7 @@ val restored = Json.decodeFromString<TreeNodeDto<String>>(json).toTreeNode()
|
|||||||
Traverse a tree as a cold `Flow` (handy in coroutine/`ViewModel` pipelines).
|
Traverse a tree as a cold `Flow` (handy in coroutine/`ViewModel` pipelines).
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
implementation("com.github.adriankuta:tree-structure-coroutines:3.4.0")
|
implementation("com.github.adriankuta:tree-structure-coroutines:4.0.0")
|
||||||
```
|
```
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
@@ -200,6 +200,24 @@ tree.preOrderFlow().collect { println(it.value) }
|
|||||||
tree.asFlow(TreeNodeIterators.LevelOrder).map { 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)
|
## Publishing to Maven Central (central.sonatype.com)
|
||||||
|
|
||||||
This project is configured to publish artifacts to Maven Central via the Sonatype Central Portal.
|
This project is configured to publish artifacts to Maven Central via the Sonatype Central Portal.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ binaryCompatibilityValidator = "0.16.3"
|
|||||||
kover = "0.8.3"
|
kover = "0.8.3"
|
||||||
coroutines = "1.9.0"
|
coroutines = "1.9.0"
|
||||||
kotlinxSerialization = "1.7.3"
|
kotlinxSerialization = "1.7.3"
|
||||||
|
composeMultiplatform = "1.7.3"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
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" }
|
mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
|
||||||
binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" }
|
binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" }
|
||||||
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
|
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]
|
[libraries]
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ rootProject.name = "tree-structure"
|
|||||||
|
|
||||||
include(":tree-structure-serialization")
|
include(":tree-structure-serialization")
|
||||||
include(":tree-structure-coroutines")
|
include(":tree-structure-coroutines")
|
||||||
|
include(":tree-structure-compose")
|
||||||
|
|||||||
@@ -64,12 +64,19 @@ public open class TreeNode<T>(public val value: T, public val treeIterator: Tree
|
|||||||
if (child._parent != null) {
|
if (child._parent != null) {
|
||||||
throw TreeNodeException("$child already has a parent; call detach() before re-attaching it.")
|
throw TreeNodeException("$child already has a parent; call detach() before re-attaching it.")
|
||||||
}
|
}
|
||||||
var ancestor: TreeNode<T>? = this
|
if (child === this) {
|
||||||
while (ancestor != null) {
|
throw TreeNodeException("Adding $child here would create a cycle.")
|
||||||
if (ancestor === child) {
|
}
|
||||||
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<T>? = _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
|
child._parent = this
|
||||||
_children.add(child)
|
_children.add(child)
|
||||||
|
|||||||
4
tree-structure-compose/api/tree-structure-compose.api
Normal file
4
tree-structure-compose/api/tree-structure-compose.api
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
74
tree-structure-compose/build.gradle.kts
Normal file
74
tree-structure-compose/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T> LazyTree(
|
||||||
|
root: TreeNode<T>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
initiallyExpanded: Boolean = true,
|
||||||
|
nodeContent: @Composable (node: TreeNode<T>, depth: Int, expanded: Boolean, toggle: () -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
val expansion = remember(root) { mutableStateMapOf<TreeNode<T>, Boolean>() }
|
||||||
|
val isExpanded: (TreeNode<T>) -> 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 <T> flattenVisible(
|
||||||
|
root: TreeNode<T>,
|
||||||
|
isExpanded: (TreeNode<T>) -> Boolean,
|
||||||
|
): List<Pair<TreeNode<T>, Int>> {
|
||||||
|
val result = mutableListOf<Pair<TreeNode<T>, Int>>()
|
||||||
|
val stack = ArrayDeque<Pair<TreeNode<T>, 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user