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:
2026-06-07 18:55:07 +02:00
parent 69d19f89e3
commit bec1fe02a7
7 changed files with 188 additions and 10 deletions

View File

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