diff --git a/build.gradle.kts b/build.gradle.kts index b33acd2..07f57f9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -119,6 +119,9 @@ tasks.register("site") { into("basic") { from(project(":examples:basic").tasks.named("wasmJsBrowserDistribution")) } + into("diagram") { + from(project(":examples:diagram").tasks.named("wasmJsBrowserDistribution")) + } into("interactive") { from(project(":examples:interactive").tasks.named("wasmJsBrowserDistribution")) } diff --git a/examples/diagram/build.gradle.kts b/examples/diagram/build.gradle.kts new file mode 100644 index 0000000..4b0f20c --- /dev/null +++ b/examples/diagram/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + kotlin("multiplatform") + kotlin("plugin.compose") + id("org.jetbrains.compose") +} + +group = "dev.bnorm.storyboard.example" +version = "0.1-SNAPSHOT" + +kotlin { + jvm() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + binaries.executable() + browser() + } + + sourceSets { + commonMain { + dependencies { + implementation(project(":storyboard")) + implementation(project(":storyboard-easel")) + implementation(project(":storyboard-layout")) + implementation(project(":storyboard-text")) + + implementation(project(":examples:shared")) + + implementation(compose.material) + implementation(compose.components.resources) + } + } + jvmMain { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +compose { + resources.publicResClass = true + desktop.application.mainClass = "Main_desktopKt" +} diff --git a/examples/diagram/src/commonMain/kotlin/ConnectedTree.kt b/examples/diagram/src/commonMain/kotlin/ConnectedTree.kt new file mode 100644 index 0000000..e5677cf --- /dev/null +++ b/examples/diagram/src/commonMain/kotlin/ConnectedTree.kt @@ -0,0 +1,193 @@ +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import dev.bnorm.storyboard.StoryboardBuilder +import dev.bnorm.storyboard.easel.rememberSharedContentState +import dev.bnorm.storyboard.easel.sharedElement +import dev.bnorm.storyboard.example.shared.Bullet +import dev.bnorm.storyboard.layout.decorated.CubicLine +import dev.bnorm.storyboard.layout.decorated.Decoration +import dev.bnorm.storyboard.layout.decorated.DecorationLayout +import dev.bnorm.storyboard.layout.tree.HorizontalTree +import dev.bnorm.storyboard.toState + +fun StoryboardBuilder.ConnectedTree() { + val root = Node( + index = 0, + Node( + index = 1, + Node(2), Node(3) + ), + Node( + index = 4, + Node(5), Node(6) + ) + ) + + class State(val root: Node?) + + val indexes = buildList { root.collect { add(it.index) } }.sorted() + val states = buildList { + add(State(null)) + for (index in indexes) { + add(State(root.filter { it <= index })) + } + } + + scene(states) { + val state = transition.createChildTransition { it.toState() } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + Text("Connected Tree", style = MaterialTheme.typography.h3) + Box( + Modifier + .padding(bottom = 32.dp) + .fillMaxWidth() + .height(2.dp) + .background(Color.Black) + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + ProvideTextStyle(MaterialTheme.typography.h5) { + Bullet("`HorizontalTree` can be used to layout tree structures along the horizontal axis.") + Bullet("`DecorationLayout` can be used to \"decorate\" `Composables`:") + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(start = 32.dp) + ) { + ProvideTextStyle(MaterialTheme.typography.h6) { + Bullet("Can connect `Composables` with lines, curves, or arrows.") + Bullet("Can surround `Composables` with generic shapes.") + Bullet("Can outline `Composables` with specialized borders.") + Bullet("A decoration is simply a `Canvas` which knows the bounding box of keyed `Composable`s.") + } + } + } + } + Surface( + shape = RoundedCornerShape(16.dp), + border = BorderStroke(2.dp, Color.Black), + color = Color.LightGray, + elevation = 8.dp, + modifier = Modifier.weight(1f) + ) { + SharedTransitionLayout { + state.AnimatedContent( + transitionSpec = { + fadeIn(tween(300, easing = EaseIn)) + .togetherWith(fadeOut(tween(300, easing = EaseOut))) + }, + ) { + when (val root = it.root) { + null -> Box(modifier = Modifier.fillMaxSize()) + else -> NodeTree(root, modifier = Modifier.fillMaxSize()) + } + } + } + } + } + } + } +} + +private class Node(val index: Int, val children: List = emptyList()) { + constructor(index: Int, vararg children: Node) : this(index, children.toList()) +} + +private fun Node.collect(block: (Node) -> Unit) { + block(this) + children.forEach { it.collect(block) } +} + +private fun Node.filter(predicate: (Int) -> Boolean): Node? { + if (!predicate(index)) return null + + val filtered = children.mapNotNull { child -> child.filter(predicate) } + return Node(index, filtered) +} + +@Composable +context(_: AnimatedVisibilityScope, _: SharedTransitionScope) +private fun NodeTree( + root: Node, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + + @Composable + fun buildDecorations(node: Node): List = buildList { + for (child in node.children) { + add( + CubicLine( + startKey = node.index, + startAlignment = Alignment.CenterEnd, + endKey = child.index, + endAlignment = Alignment.CenterStart, + color = Color.Black, + stroke = Stroke(with(density) { 2.dp.toPx() }), + modifier = Modifier.sharedElement( + rememberSharedContentState("${node.index}-${child.index}"), + boundsTransform = BoundsTransform { _, _ -> tween(300, easing = EaseInOut) }, + ) + ) + ) + addAll(buildDecorations(child)) + } + } + + DecorationLayout( + decorations = buildDecorations(root), + modifier = modifier + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + HorizontalTree( + root = root, + getChildren = { it.children }, + horizontalArrangement = Arrangement.spacedBy(32.dp), + ) { + key(it.index) { + Surface( + border = BorderStroke(2.dp, Color.Black), + shape = RoundedCornerShape(8.dp), + elevation = 8.dp, + modifier = Modifier + .sharedElement( + rememberSharedContentState(it.index), + boundsTransform = BoundsTransform { _, _ -> tween(300, easing = EaseInOut) }, + ) + .decorate(it.index) + ) { + Text( + text = "Node : ${it.index}", + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) + ) + } + } + } + } + } +} diff --git a/examples/diagram/src/commonMain/kotlin/Storyboard.kt b/examples/diagram/src/commonMain/kotlin/Storyboard.kt new file mode 100644 index 0000000..1bf65f1 --- /dev/null +++ b/examples/diagram/src/commonMain/kotlin/Storyboard.kt @@ -0,0 +1,27 @@ +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Typography +import androidx.compose.material.lightColors +import dev.bnorm.storyboard.SceneDecorator +import dev.bnorm.storyboard.SceneFormat +import dev.bnorm.storyboard.Storyboard + +fun createStoryboard(): Storyboard { + return Storyboard.build( + title = "Diagram Storyboard", + format = SceneFormat.Default, + decorator = theme, + ) { + ConnectedTree() + } +} + +private val theme = SceneDecorator { content -> + val colors = lightColors() + val typography = Typography() + MaterialTheme(colors, typography) { + Surface { + content() + } + } +} diff --git a/examples/diagram/src/jvmMain/kotlin/main.desktop.kt b/examples/diagram/src/jvmMain/kotlin/main.desktop.kt new file mode 100644 index 0000000..ae515ff --- /dev/null +++ b/examples/diagram/src/jvmMain/kotlin/main.desktop.kt @@ -0,0 +1,13 @@ + import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.ui.window.application +import dev.bnorm.storyboard.easel.DesktopStoryEasel + +fun main() { + val storyboard = createStoryboard() + application { + MaterialTheme(colors = darkColors()) { + DesktopStoryEasel(storyboard) + } + } +} diff --git a/examples/diagram/src/wasmJsMain/kotlin/main.web.kt b/examples/diagram/src/wasmJsMain/kotlin/main.web.kt new file mode 100644 index 0000000..45547d5 --- /dev/null +++ b/examples/diagram/src/wasmJsMain/kotlin/main.web.kt @@ -0,0 +1,19 @@ +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import dev.bnorm.storyboard.easel.WebStoryEasel +import kotlinx.browser.document +import org.w3c.dom.HTMLCanvasElement + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + val storyboard = createStoryboard() + val element = document.getElementById("ComposeTarget") as HTMLCanvasElement + element.focus() // Focus is required for keyboard navigation. + CanvasBasedWindow(canvasElementId = element.id, title = storyboard.title) { + MaterialTheme(colors = darkColors()) { + WebStoryEasel(storyboard) + } + } +} diff --git a/examples/diagram/src/wasmJsMain/resources/index.html b/examples/diagram/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..0dc416c --- /dev/null +++ b/examples/diagram/src/wasmJsMain/resources/index.html @@ -0,0 +1,12 @@ + + + + + + Basic Storyboard + + + + + + diff --git a/examples/shared/src/commonMain/kotlin/dev/bnorm/storyboard/example/shared/Bullet.kt b/examples/shared/src/commonMain/kotlin/dev/bnorm/storyboard/example/shared/Bullet.kt new file mode 100644 index 0000000..22d57d4 --- /dev/null +++ b/examples/shared/src/commonMain/kotlin/dev/bnorm/storyboard/example/shared/Bullet.kt @@ -0,0 +1,15 @@ +package dev.bnorm.storyboard.example.shared + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp + +@Composable +fun Bullet(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("•") + Text(text) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3c8e0c0..c9fcbd5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,8 +31,10 @@ rootProject.name = "storyboard-root" include(":storyboard") include(":storyboard-easel") +include(":storyboard-layout") include(":storyboard-text") include(":examples:basic") +include(":examples:diagram") include(":examples:interactive") include(":examples:shared") diff --git a/storyboard-layout/build.gradle.kts b/storyboard-layout/build.gradle.kts new file mode 100644 index 0000000..7c6e3a9 --- /dev/null +++ b/storyboard-layout/build.gradle.kts @@ -0,0 +1,27 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + kotlin("multiplatform") + kotlin("plugin.compose") + id("org.jetbrains.compose") + id("com.vanniktech.maven.publish") +} + +group = "dev.bnorm.storyboard" + +kotlin { + jvm() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + commonMain { + dependencies { + implementation(compose.material) + } + } + } +} diff --git a/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/DecorationLayout.kt b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/DecorationLayout.kt new file mode 100644 index 0000000..4994c38 --- /dev/null +++ b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/DecorationLayout.kt @@ -0,0 +1,97 @@ +package dev.bnorm.storyboard.layout.decorated + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo + +class Decoration( + internal val modifier: Modifier = Modifier, + private val onDraw: DrawScope.(container: DecoratedElementContainer) -> Unit, +) { + internal fun draw(scope: DrawScope, container: DecoratedElementContainer) { + scope.onDraw(container) + } +} + +sealed interface DecorationScope { + fun Modifier.decorate(key: Any): Modifier +} + +sealed interface DecoratedElementContainer { + fun getBoundingBox(key: Any): Rect? +} + +@Composable +fun DecorationLayout( + modifier: Modifier = Modifier, + decorations: List = emptyList(), + content: @Composable DecorationScope.() -> Unit, +) { + val container = remember { DecoratedElementContainerImpl() } + Box(modifier.onPlaced { container.root = it }) { + container.content() + + for (decoration in decorations) { + Canvas(decoration.modifier.matchParentSize()) { + decoration.draw(this@Canvas, container) + } + } + } +} + +private class DecoratedElementContainerImpl : DecoratedElementContainer, DecorationScope { + var root by mutableStateOf(null) + val elements = mutableStateMapOf() + + override fun getBoundingBox(key: Any): Rect? { + val coordinates = elements[key] ?: return null + return root?.localBoundingBoxOf(coordinates) + } + + override fun Modifier.decorate(key: Any): Modifier { + return this then DecoratedElement( + onPlaced = { + when (it) { + null -> elements.remove(key) + else -> elements.put(key, it) + } + } + ) + } +} + +private data class DecoratedElement( + val onPlaced: (LayoutCoordinates?) -> Unit, +) : ModifierNodeElement() { + override fun create() = DecoratedNode(callback = onPlaced) + + override fun update(node: DecoratedNode) { + node.callback = onPlaced + } + + override fun InspectorInfo.inspectableProperties() { + name = "onPlaced" + properties["onPlaced"] = onPlaced + } +} + +private class DecoratedNode( + var callback: (LayoutCoordinates?) -> Unit, +) : LayoutAwareModifierNode, Modifier.Node() { + + override fun onPlaced(coordinates: LayoutCoordinates) { + callback(coordinates) + } + + override fun onDetach() { + callback(null) + } +} diff --git a/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Line.kt b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Line.kt new file mode 100644 index 0000000..65161d5 --- /dev/null +++ b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Line.kt @@ -0,0 +1,107 @@ +package dev.bnorm.storyboard.layout.decorated + +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.toIntSize +import androidx.compose.ui.unit.toOffset +import kotlin.math.absoluteValue + +fun Line( + startKey: Any, + startAlignment: Alignment, + endKey: Any, + endAlignment: Alignment, + color: Color, + stroke: Stroke, + modifier: Modifier = Modifier, +): Decoration = Decoration(modifier) { layout -> + val startRect = layout.getBoundingBox(startKey) ?: return@Decoration + val endRect = layout.getBoundingBox(endKey) ?: return@Decoration + + val startPosition = borderPosition(startRect, startAlignment, layoutDirection) + val endPosition = borderPosition(endRect, endAlignment, layoutDirection) + + val path = Path().apply { + moveTo(startPosition.x, startPosition.y) + lineTo(endPosition.x, endPosition.y) + } + + drawPath( + path = path, + color = color, + style = stroke, + ) +} + +fun CubicLine( + startKey: Any, + startAlignment: Alignment, + endKey: Any, + endAlignment: Alignment, + color: Color, + stroke: Stroke, + colorFilter: ColorFilter? = null, + blendMode: BlendMode = DefaultBlendMode, + modifier: Modifier = Modifier, +): Decoration = Decoration(modifier) { layout -> + val startRect = layout.getBoundingBox(startKey) ?: return@Decoration + val endRect = layout.getBoundingBox(endKey) ?: return@Decoration + + val startPosition = borderPosition(startRect, startAlignment, layoutDirection) + val endPosition = borderPosition(endRect, endAlignment, layoutDirection) + + // TODO what should the projection distance be? + val bounds = (endPosition - startPosition) + val distance = when { + startAlignment is BiasAlignment && endAlignment is BiasAlignment -> when { + startAlignment.horizontalBias == 0f && endAlignment.horizontalBias == 0f + -> bounds.y.absoluteValue + + startAlignment.verticalBias == 0f && endAlignment.verticalBias == 0f + -> bounds.x.absoluteValue + + else -> minOf(bounds.x.absoluteValue, bounds.y.absoluteValue) + } + + else -> minOf(bounds.x.absoluteValue, bounds.y.absoluteValue) + } + val startProjection = startPosition.project(startPosition - startRect.center, distance / 2f) + val endProjection = endPosition.project(endPosition - endRect.center, distance / 2f) + + val path = Path().apply { + moveTo(startPosition.x, startPosition.y) + cubicTo( + startProjection.x, startProjection.y, + endProjection.x, endProjection.y, + endPosition.x, endPosition.y, + ) + } + + drawPath( + path = path, + color = color, + style = stroke, + colorFilter = colorFilter, + blendMode = blendMode, + ) +} + +private fun Offset.project(vector: Offset, distance: Float): Offset { + return this + vector * (distance / vector.getDistance()) +} + +private fun borderPosition(rect: Rect, alignment: Alignment, layoutDirection: LayoutDirection): Offset { + val offset = alignment.align(IntSize.Zero, rect.size.toIntSize(), layoutDirection) + return rect.topLeft + offset.toOffset() +} diff --git a/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Outline.kt b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Outline.kt new file mode 100644 index 0000000..e87501a --- /dev/null +++ b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Outline.kt @@ -0,0 +1,30 @@ +package dev.bnorm.storyboard.layout.decorated + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke + +fun Outline( + key: Any, + color: Color, + stroke: Stroke, + modifier: Modifier = Modifier, +): Decoration { + return Decoration(modifier) { layout -> + val rect = layout.getBoundingBox(key) ?: return@Decoration + drawRect(color, rect.topLeft, rect.size, style = stroke) + } +} + +fun Outline( + key: Any, + brush: Brush, + stroke: Stroke, + modifier: Modifier = Modifier, +): Decoration { + return Decoration(modifier) { layout -> + val rect = layout.getBoundingBox(key) ?: return@Decoration + drawRect(brush, rect.topLeft, rect.size, style = stroke) + } +} diff --git a/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Surround.kt b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Surround.kt new file mode 100644 index 0000000..9d10843 --- /dev/null +++ b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/decorated/Surround.kt @@ -0,0 +1,33 @@ +package dev.bnorm.storyboard.layout.decorated + +import androidx.annotation.FloatRange +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.DrawStyle +import androidx.compose.ui.graphics.drawscope.Fill + +fun Surround( + key: Any, + shape: Shape, + color: Color, + modifier: Modifier = Modifier, + @FloatRange(from = 0.0, fromInclusive = false) scale: Float = 1.0f, + @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, + style: DrawStyle = Fill, + colorFilter: ColorFilter? = null, + blendMode: BlendMode = DrawScope.DefaultBlendMode, +): Decoration { + return Decoration(modifier) { layout -> + val rect = layout.getBoundingBox(key) ?: return@Decoration + val outline = shape.createOutline(rect.size * scale, layoutDirection, this) + drawOutline( + outline = outline, + color = color, + alpha = alpha, + style = style, + colorFilter = colorFilter, + blendMode = blendMode, + ) + } +} \ No newline at end of file diff --git a/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/tree/HorizontalTree.kt b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/tree/HorizontalTree.kt new file mode 100644 index 0000000..350b23e --- /dev/null +++ b/storyboard-layout/src/commonMain/kotlin/dev/bnorm/storyboard/layout/tree/HorizontalTree.kt @@ -0,0 +1,202 @@ +package dev.bnorm.storyboard.layout.tree + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun HorizontalTree( + root: T, + getChildren: (node: T) -> Collection, + modifier: Modifier = Modifier, + + // How the nodes are spaced out horizontally. + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(64.dp), + // How child nodes are aligned with each other. + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + // Ensures the size of the tree will - at minimum - support this spacing between horizontal nodes. + // Useful when combined with non-SpacedBy arrangements. + // TODO is this actually useful? + horizontalMinimumSpacing: Dp = 0.dp, + + // How nodes are spaced out vertically. + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp), + // How parent nodes are aligned to their children. + // Also, how children with a taller parent are aligned. + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + // Ensures the size of the tree will - at minimum - support this spacing between horizontal nodes. + // Useful when combined with non-SpacedBy arrangements. + // TODO is this actually useful? + verticalMinimumSpacing: Dp = 0.dp, + + // TODO support packing? + // - support top/bottom packing nodes at the same depth? + // - support left/right pack nodes against their parent? null horizontalArrangement? + // - these may need to be mutually-exclusive? + + // TODO support staggering nodes at the same depth? + // - more important in a top-down tree + + content: @Composable (node: T) -> Unit, +) { + class Node( + val value: T, + val children: List>, + ) + + val nodes = remember(root, getChildren) { + buildList { + fun collect(value: T): Node { + val children = getChildren(value).map { collect(it) } + val node = Node(value, children) + add(node) + return node + } + + collect(root) + } + } + + Layout( + content = { + for (node in nodes) { + Box { content(node.value) } + } + }, + modifier = modifier, + ) { measurables, constraints -> + class PlaceableNode( + val placeable: Placeable, + val depth: Int, + val children: List, + ) { + var x = 0 + var y = 0 + var minHeight = 0 + } + + val placeables = buildList { + val iter = measurables.iterator() + + fun collect(node: Node, depth: Int): PlaceableNode { + val children = node.children.map { collect(it, depth + 1) } + val placeable = iter.next().measure(Constraints()) + val node = PlaceableNode(placeable, depth, children) + add(node) + return node + } + + collect(nodes.last(), 0) + } + + // ==================================== + // Horizontal arrangement and alignment + // ==================================== + val byDepth = Array(placeables.maxOf { it.depth } + 1) { mutableListOf() } + for (node in placeables) { + byDepth[node.depth].add(node) + } + + val xSpacing = maxOf(horizontalArrangement.spacing, horizontalMinimumSpacing).roundToPx() + + val xSizes = IntArray(byDepth.size) { byDepth[it].maxOf { it.placeable.width } } + val minWidth = maxOf(xSizes.sumOf { it + xSpacing } - xSpacing, constraints.minWidth) + + val xPositions = IntArray(byDepth.size) + with(horizontalArrangement) { arrange(minWidth, xSizes, layoutDirection, xPositions) } + for ((i, nodes) in byDepth.withIndex()) { + val size = xSizes[i] + for (node in nodes) { + node.x = xPositions[i] + horizontalAlignment.align(node.placeable.width, size, layoutDirection) + } + } + + // ================================== + // Vertical arrangement and alignment + // ================================== + // TODO there's still some weird things go on here: + // - SpacedBetween looks weird + // - SpaceEvenly results in double space between cousins + // TODO do we need to use arrangement within minHeight calculation? + + val ySpacing = maxOf(verticalArrangement.spacing, verticalMinimumSpacing).roundToPx() + + fun heightUp(node: PlaceableNode): Int { + var childHeight = -ySpacing + for (node in node.children) { + childHeight += heightUp(node) + ySpacing + } + node.minHeight = maxOf(node.placeable.height, childHeight) + return node.minHeight + } + + fun heightDown(node: PlaceableNode, height: Int) { + val original = node.minHeight + node.minHeight = height + + val childSpacing = ySpacing * (node.children.size - 1) + for (child in node.children) { + val childHeight = (child.minHeight - childSpacing) * height / original + childSpacing + child.minHeight = childHeight + heightDown(child, childHeight) + } + } + + fun alignChildren(children: List, yOffset: Int, height: Int) { + if (children.isEmpty()) return + + val ySizes = IntArray(children.size) { children[it].minHeight } + val yPositions = IntArray(children.size) + with(verticalArrangement) { arrange(height, ySizes, yPositions) } + + val childrenOffset = if (verticalArrangement.spacing.value > 0f) { + var min = height + var max = 0 + for (i in children.indices) { + min = minOf(min, yPositions[i]) + max = maxOf(max, yPositions[i] + ySizes[i]) + } + verticalAlignment.align(max - min, height) + } else { + 0 + } + + for (i in yPositions.indices) { + val child = children[i] + val yPosition = yOffset + yPositions[i] + + val childOffset = if (child.placeable.height < child.minHeight) { + verticalAlignment.align(child.placeable.height, child.minHeight) + } else { + 0 + } + + child.y = yPosition + childrenOffset + childOffset + alignChildren(child.children, yPosition, ySizes[i]) + } + } + + val root = placeables.last() + var height = heightUp(root) + if (verticalArrangement.spacing.value <= 0f) { + height = maxOf(height, constraints.minHeight) + heightDown(root, height) + } + + alignChildren(listOf(root), 0, height) + + layout(minWidth, maxOf(height, constraints.minHeight)) { + for (node in placeables) { + node.placeable.place(x = node.x, y = node.y) + } + } + } +}