From 50f93b3cac02fc49265b3eedb084e64d76d3d2a1 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 13:24:12 +0200 Subject: [PATCH 01/12] change sample setup --- sample/build.gradle.kts | 11 +- .../linde9821/treelayout/sample/PrefixTree.kt | 66 ++++++++++ .../treelayout/sample/TreeVisualization.kt | 117 ++++++++---------- .../treelayout/sample/PrefixTreeTest.kt | 79 ++++++++++++ .../treelayout/sample/SampleNodeTest.kt | 0 .../linde9821/treelayout/sample/Main.kt | 2 +- .../linde9821/treelayout/sample/SampleNode.kt | 3 - 7 files changed, 211 insertions(+), 67 deletions(-) create mode 100644 sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt rename sample/src/{jvmMain => commonMain}/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt (73%) create mode 100644 sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/PrefixTreeTest.kt create mode 100644 sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt delete mode 100644 sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/SampleNode.kt diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 8fdab0b..0c6942a 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -8,9 +8,18 @@ kotlin { jvm() sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.ui) + implementation(project(":library")) + } + commonTest.dependencies { + implementation(kotlin("test")) + } jvmMain.dependencies { implementation(compose.desktop.currentOs) - implementation(project(":library")) } } } diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt new file mode 100644 index 0000000..b62f941 --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt @@ -0,0 +1,66 @@ +package io.github.linde9821.treelayout.sample + +import io.github.linde9821.treelayout.TreeAdapter + +public class PrefixNode( + public val label: String, + public val children: MutableList = mutableListOf(), +) + +public fun buildPrefixTree(words: List): PrefixNode { + val root = PrefixNode("") + for (word in words) { + if (word.isBlank()) continue + insertWord(root, word) + } + return root +} + +private fun insertWord(root: PrefixNode, word: String) { + var current = root + var i = 0 + while (i < word.length) { + val child = current.children.find { word.startsWith(it.label, i) } + if (child != null) { + i += child.label.length + current = child + } else { + // Check for partial match with an existing child + val partial = current.children.find { it.label.isNotEmpty() && word[i] == it.label[0] } + if (partial != null) { + // Find common prefix length + var common = 0 + while (common < partial.label.length && i + common < word.length && partial.label[common] == word[i + common]) { + common++ + } + // Split the existing node + val splitNode = PrefixNode(partial.label.substring(0, common), mutableListOf( + PrefixNode(partial.label.substring(common), partial.children) + )) + current.children[current.children.indexOf(partial)] = splitNode + if (i + common < word.length) { + splitNode.children.add(PrefixNode(word.substring(i + common))) + } + return + } else { + current.children.add(PrefixNode(word.substring(i))) + return + } + } + } +} + +public fun prefixTreeAdapter(root: PrefixNode): TreeAdapter { + val parentMap = buildMap { + fun walk(node: PrefixNode, parent: PrefixNode?) { + put(node, parent) + node.children.forEach { walk(it, node) } + } + walk(root, null) + } + return object : TreeAdapter { + override fun root(): PrefixNode = root + override fun children(node: PrefixNode): List = node.children + override fun parent(node: PrefixNode): PrefixNode? = parentMap[node] + } +} diff --git a/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt similarity index 73% rename from sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt rename to sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt index 03472d6..883fc6b 100644 --- a/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt @@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -41,47 +44,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.linde9821.treelayout.NodeExtentProvider import io.github.linde9821.treelayout.Orientation -import io.github.linde9821.treelayout.TreeAdapter import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration import io.github.linde9821.treelayout.walker.WalkerTreeLayout +private const val DEFAULT_INPUT: String = "Not all those who wander are lost" + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun TreeVisualization() { - val tree = SampleNode( - "CEO", listOf( - SampleNode( - "Engineering", listOf( - SampleNode("Frontend"), - SampleNode("Backend"), - SampleNode("Infra"), - ) - ), - SampleNode("Design"), - SampleNode( - "Marketing", listOf( - SampleNode("Growth"), - SampleNode("Brand"), - ) - ), - ) - ) - - val adapter = remember { - object : TreeAdapter { - private val parentMap = buildMap { - fun walk(node: SampleNode, parent: SampleNode?) { - put(node, parent) - node.children.forEach { walk(it, node) } - } - walk(tree, null) - } - - override fun root(): SampleNode = tree - override fun children(node: SampleNode): List = node.children - override fun parent(node: SampleNode): SampleNode? = parentMap[node] - } - } - + var input by remember { mutableStateOf(DEFAULT_INPUT) } var horizontalDistance by remember { mutableStateOf(40f) } var verticalDistance by remember { mutableStateOf(60f) } var orientation by remember { mutableStateOf(Orientation.TopToBottom) } @@ -89,26 +60,32 @@ fun TreeVisualization() { var nodePaddingV by remember { mutableStateOf(8f) } var orientationExpanded by remember { mutableStateOf(false) } + val words = input.lowercase().split("\\s+".toRegex()) + .map { it.filter(Char::isLetter) } + .filter { it.isNotEmpty() } + val tree = buildPrefixTree(words) + val adapter = prefixTreeAdapter(tree) + val textMeasurer = rememberTextMeasurer() - val textStyle = TextStyle(fontSize = 12.sp, color = Color.Black) + val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black) - // Pre-measure all labels to get actual text sizes val textLayouts = remember(tree) { buildMap { - fun walk(node: SampleNode) { - put(node, textMeasurer.measure(node.label, textStyle)) + fun walk(node: PrefixNode) { + val display = node.label.ifEmpty { "·" } + put(node, textMeasurer.measure(display, textStyle)) node.children.forEach { walk(it) } } walk(tree) } } - val extents = object : NodeExtentProvider { - override fun width(node: SampleNode): Float = - textLayouts[node]!!.size.width.toFloat() + nodePaddingH * 2 + val extents = object : NodeExtentProvider { + override fun width(node: PrefixNode): Float = + (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 - override fun height(node: SampleNode): Float = - textLayouts[node]!!.size.height.toFloat() + nodePaddingV * 2 + override fun height(node: PrefixNode): Float = + (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 } val config = WalkerLayoutConfiguration( @@ -121,35 +98,35 @@ fun TreeVisualization() { val positions = result.getPositions() Row(modifier = Modifier.fillMaxSize()) { - // Controls panel + // Left: Controls Column( - modifier = Modifier.width(260.dp).fillMaxHeight().padding(16.dp), + modifier = Modifier.widthIn(min = 180.dp).width(220.dp).fillMaxHeight().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("Layout Controls", style = MaterialTheme.typography.h6) + Text("Layout Controls", style = MaterialTheme.typography.subtitle1) - Text("Horizontal Distance: ${"%.0f".format(horizontalDistance)}") + Text("Horizontal Distance: ${horizontalDistance.toInt()}") Slider( value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f, ) - Text("Vertical Distance: ${"%.0f".format(verticalDistance)}") + Text("Vertical Distance: ${verticalDistance.toInt()}") Slider( value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f, ) - Text("Node Padding H: ${"%.0f".format(nodePaddingH)}") + Text("Node Padding H: ${nodePaddingH.toInt()}") Slider( value = nodePaddingH, onValueChange = { nodePaddingH = it }, valueRange = 0f..40f, ) - Text("Node Padding V: ${"%.0f".format(nodePaddingV)}") + Text("Node Padding V: ${nodePaddingV.toInt()}") Slider( value = nodePaddingV, onValueChange = { nodePaddingV = it }, @@ -179,12 +156,27 @@ fun TreeVisualization() { Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) - // Tree canvas + // Middle: Input + Column( + modifier = Modifier.widthIn(min = 150.dp).width(200.dp).fillMaxHeight().padding(16.dp), + ) { + Text("Words (space-separated)", style = MaterialTheme.typography.subtitle1) + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().weight(1f), + textStyle = TextStyle(fontSize = 13.sp), + ) + } + + Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) + + // Right: Canvas + var panOffset by remember { mutableStateOf(Offset.Zero) } var zoom by remember { mutableStateOf(1f) } - @OptIn(ExperimentalComposeUiApi::class) - (Canvas( + Canvas( modifier = Modifier .fillMaxSize() .background(Color.White) @@ -204,10 +196,9 @@ fun TreeVisualization() { val centerY = size.height / 2f + panOffset.y scale(zoom, pivot = Offset(size.width / 2f, size.height / 2f)) { - // Draw edges positions.forEach { (node, pos) -> node.children.forEach { child -> - val childPos = positions[child]!! + val childPos = positions[child] ?: return@forEach drawLine( color = Color.Gray, start = Offset(pos.x + centerX, pos.y + centerY), @@ -217,11 +208,10 @@ fun TreeVisualization() { } } - // Draw nodes positions.forEach { (node, pos) -> + val textLayout = textLayouts[node] ?: return@forEach val x = pos.x + centerX val y = pos.y + centerY - val textLayout = textLayouts[node]!! val rectW = textLayout.size.width + nodePaddingH * 2 val rectH = textLayout.size.height + nodePaddingV * 2 @@ -234,10 +224,13 @@ fun TreeVisualization() { drawText( textLayoutResult = textLayout, - topLeft = Offset(x - textLayout.size.width / 2f, y - textLayout.size.height / 2f), + topLeft = Offset( + x - textLayout.size.width / 2f, + y - textLayout.size.height / 2f, + ), ) } } - }) + } } -} \ No newline at end of file +} diff --git a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/PrefixTreeTest.kt b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/PrefixTreeTest.kt new file mode 100644 index 0000000..e916063 --- /dev/null +++ b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/PrefixTreeTest.kt @@ -0,0 +1,79 @@ +package io.github.linde9821.treelayout.sample + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +public class PrefixTreeTest { + + @Test + public fun emptyInputProducesRootOnly() { + val tree = buildPrefixTree(emptyList()) + assertEquals("", tree.label) + assertTrue(tree.children.isEmpty()) + } + + @Test + public fun singleWordCreatesSingleChild() { + val tree = buildPrefixTree(listOf("cat")) + assertEquals(1, tree.children.size) + assertEquals("cat", tree.children[0].label) + } + + @Test + public fun sharedPrefixSplitsNode() { + val tree = buildPrefixTree(listOf("car", "cat")) + // root -> "ca" -> ("r", "t") + val ca = tree.children[0] + assertEquals("ca", ca.label) + assertEquals(2, ca.children.size) + assertEquals("r", ca.children[0].label) + assertEquals("t", ca.children[1].label) + } + + @Test + public fun disjointWordsCreateSeparateBranches() { + val tree = buildPrefixTree(listOf("ab", "cd")) + assertEquals(2, tree.children.size) + assertEquals("ab", tree.children[0].label) + assertEquals("cd", tree.children[1].label) + } + + @Test + public fun threWordsWithSharedPrefix() { + val tree = buildPrefixTree(listOf("tree", "trie", "try")) + // root -> "tr" -> ("ee", "ie", "y") + val tr = tree.children[0] + assertEquals("tr", tr.label) + assertEquals(3, tr.children.size) + } + + @Test + public fun adapterRootMatchesTree() { + val tree = buildPrefixTree(listOf("hi")) + val adapter = prefixTreeAdapter(tree) + assertEquals(tree, adapter.root()) + } + + @Test + public fun adapterParentOfRootIsNull() { + val tree = buildPrefixTree(listOf("hi")) + val adapter = prefixTreeAdapter(tree) + assertNull(adapter.parent(tree)) + } + + @Test + public fun adapterParentIsCorrect() { + val tree = buildPrefixTree(listOf("hi")) + val adapter = prefixTreeAdapter(tree) + val child = tree.children[0] + assertEquals(tree, adapter.parent(child)) + } + + @Test + public fun blankWordsAreIgnored() { + val tree = buildPrefixTree(listOf("", " ", "a")) + assertEquals(1, tree.children.size) + } +} diff --git a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt new file mode 100644 index 0000000..e69de29 diff --git a/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt b/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt index f125605..20c2a7c 100644 --- a/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt +++ b/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt @@ -3,7 +3,7 @@ package io.github.linde9821.treelayout.sample import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -fun main() = application { +fun main(): Unit = application { Window(onCloseRequest = ::exitApplication, title = "TreeLayoutKMP Sample") { TreeVisualization() } diff --git a/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/SampleNode.kt b/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/SampleNode.kt deleted file mode 100644 index 2614631..0000000 --- a/sample/src/jvmMain/kotlin/io/github/linde9821/treelayout/sample/SampleNode.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.github.linde9821.treelayout.sample - -data class SampleNode(val label: String, val children: List = emptyList()) \ No newline at end of file From 1bf539c04ffd5b387f35d3caebb4a3cdaa480909 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 13:56:55 +0200 Subject: [PATCH 02/12] add ios app --- sample/build.gradle.kts | 10 + .../iosApp/iosApp.xcodeproj/project.pbxproj | 308 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + sample/iosApp/iosApp/AppDelegate.swift | 18 + sample/iosApp/iosApp/Info.plist | 38 +++ .../treelayout/sample/TreeVisualization.kt | 2 +- .../treelayout/sample/MainViewController.kt | 12 + .../sample/TreeVisualizationVertical.kt | 226 +++++++++++++ 8 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 sample/iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 sample/iosApp/iosApp/AppDelegate.swift create mode 100644 sample/iosApp/iosApp/Info.plist create mode 100644 sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/MainViewController.kt create mode 100644 sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 0c6942a..1f4906c 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -6,6 +6,16 @@ plugins { kotlin { jvm() + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { target -> + target.binaries.framework { + baseName = "Sample" + isStatic = true + } + } sourceSets { commonMain.dependencies { diff --git a/sample/iosApp/iosApp.xcodeproj/project.pbxproj b/sample/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1fc6598 --- /dev/null +++ b/sample/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,308 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 2A1B5E01AAAA0001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1B5E02AAAA0001 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2A1B5E02AAAA0001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 2A1B5E03AAAA0001 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A1B5E04AAAA0001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A1B5E10AAAA0001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2A1B5E20AAAA0001 = { + isa = PBXGroup; + children = ( + 2A1B5E21AAAA0001 /* iosApp */, + 2A1B5E22AAAA0001 /* Products */, + ); + sourceTree = ""; + }; + 2A1B5E21AAAA0001 /* iosApp */ = { + isa = PBXGroup; + children = ( + 2A1B5E02AAAA0001 /* AppDelegate.swift */, + 2A1B5E04AAAA0001 /* Info.plist */, + ); + path = iosApp; + sourceTree = ""; + }; + 2A1B5E22AAAA0001 /* Products */ = { + isa = PBXGroup; + children = ( + 2A1B5E03AAAA0001 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A1B5E30AAAA0001 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A1B5E40AAAA0001 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + 2A1B5E31AAAA0001 /* Compile Kotlin Framework */, + 2A1B5E32AAAA0001 /* Sources */, + 2A1B5E10AAAA0001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + productName = iosApp; + productReference = 2A1B5E03AAAA0001 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2A1B5E50AAAA0001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 2650; + }; + buildConfigurationList = 2A1B5E51AAAA0001 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2A1B5E20AAAA0001; + productRefGroup = 2A1B5E22AAAA0001 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2A1B5E30AAAA0001 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + 2A1B5E31AAAA0001 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :sample:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2A1B5E32AAAA0001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A1B5E01AAAA0001 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 2A1B5E60AAAA0001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 72VHXUR3G2; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2A1B5E61AAAA0001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 72VHXUR3G2; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2A1B5E62AAAA0001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + INFOPLIST_FILE = iosApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Sample, + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.linde9821.treelayout.sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A1B5E63AAAA0001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + INFOPLIST_FILE = iosApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + Sample, + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.linde9821.treelayout.sample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2A1B5E40AAAA0001 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A1B5E62AAAA0001 /* Debug */, + 2A1B5E63AAAA0001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A1B5E51AAAA0001 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A1B5E60AAAA0001 /* Debug */, + 2A1B5E61AAAA0001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 2A1B5E50AAAA0001 /* Project object */; +} diff --git a/sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/sample/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/sample/iosApp/iosApp/AppDelegate.swift b/sample/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 0000000..296976d --- /dev/null +++ b/sample/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,18 @@ +import UIKit +import Sample + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + window = UIWindow(frame: UIScreen.main.bounds) + let viewController = MainViewControllerKt.MainViewController() + window?.rootViewController = viewController + window?.makeKeyAndVisible() + return true + } +} diff --git a/sample/iosApp/iosApp/Info.plist b/sample/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..9c2acb1 --- /dev/null +++ b/sample/iosApp/iosApp/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + CADisableMinimumFrameDurationOnPhone + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt index 883fc6b..9ca8057 100644 --- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt @@ -47,7 +47,7 @@ import io.github.linde9821.treelayout.Orientation import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration import io.github.linde9821.treelayout.walker.WalkerTreeLayout -private const val DEFAULT_INPUT: String = "Not all those who wander are lost" +const val DEFAULT_INPUT: String = "Not all those who wander are lost" @OptIn(ExperimentalComposeUiApi::class) @Composable diff --git a/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/MainViewController.kt b/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/MainViewController.kt new file mode 100644 index 0000000..4f0d1d2 --- /dev/null +++ b/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/MainViewController.kt @@ -0,0 +1,12 @@ +package io.github.linde9821.treelayout.sample + +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +@Suppress("FunctionName", "unused") +public fun MainViewController(): UIViewController = ComposeUIViewController { + MaterialTheme { + TreeVisualizationVertical() + } +} diff --git a/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt b/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt new file mode 100644 index 0000000..a67724a --- /dev/null +++ b/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt @@ -0,0 +1,226 @@ +package io.github.linde9821.treelayout.sample + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.linde9821.treelayout.NodeExtentProvider +import io.github.linde9821.treelayout.Orientation +import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration +import io.github.linde9821.treelayout.walker.WalkerTreeLayout + +@Composable +public fun TreeVisualizationVertical() { + var input by remember { mutableStateOf(DEFAULT_INPUT) } + var horizontalDistance by remember { mutableStateOf(40f) } + var verticalDistance by remember { mutableStateOf(60f) } + var orientation by remember { mutableStateOf(Orientation.TopToBottom) } + var nodePaddingH by remember { mutableStateOf(12f) } + var nodePaddingV by remember { mutableStateOf(8f) } + var orientationExpanded by remember { mutableStateOf(false) } + + val words = input.lowercase().split("\\s+".toRegex()) + .map { it.filter(Char::isLetter) } + .filter { it.isNotEmpty() } + val tree = buildPrefixTree(words) + val adapter = prefixTreeAdapter(tree) + + val textMeasurer = rememberTextMeasurer() + val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black) + + val textLayouts = remember(tree) { + buildMap { + fun walk(node: PrefixNode) { + val display = node.label.ifEmpty { "·" } + put(node, textMeasurer.measure(display, textStyle)) + node.children.forEach { walk(it) } + } + walk(tree) + } + } + + val extents = object : NodeExtentProvider { + override fun width(node: PrefixNode): Float = + (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 + + override fun height(node: PrefixNode): Float = + (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 + } + + val config = WalkerLayoutConfiguration( + horizontalDistance = horizontalDistance, + verticalDistance = verticalDistance, + orientation = orientation, + ) + + val result = WalkerTreeLayout(adapter, config, extents).layout() + val positions = result.getPositions() + + val focusManager = LocalFocusManager.current + + Column(modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .imePadding() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + focusManager.clearFocus() + } + .padding(16.dp) + ) { + // Top: Controls + Input (scrollable) + Column( + modifier = Modifier.weight(0.4f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Layout Controls", style = MaterialTheme.typography.subtitle1) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp) + Slider(value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f) + } + Column(modifier = Modifier.weight(1f)) { + Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp) + Slider(value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Padding H: ${nodePaddingH.toInt()}", fontSize = 12.sp) + Slider(value = nodePaddingH, onValueChange = { nodePaddingH = it }, valueRange = 0f..40f) + } + Column(modifier = Modifier.weight(1f)) { + Text("Padding V: ${nodePaddingV.toInt()}", fontSize = 12.sp) + Slider(value = nodePaddingV, onValueChange = { nodePaddingV = it }, valueRange = 0f..40f) + } + } + + Box { + OutlinedButton(onClick = { orientationExpanded = true }) { + Text(orientation.name) + } + DropdownMenu( + expanded = orientationExpanded, + onDismissRequest = { orientationExpanded = false }, + ) { + Orientation.entries.forEach { o -> + DropdownMenuItem(onClick = { + orientation = o + orientationExpanded = false + }) { Text(o.name) } + } + } + } + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // Middle: Input + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().height(80.dp), + label = { Text("Words (space-separated)") }, + textStyle = TextStyle(fontSize = 13.sp), + ) + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // Bottom: Canvas + var panOffset by remember { mutableStateOf(Offset.Zero) } + var zoom by remember { mutableStateOf(1f) } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .weight(0.6f) + .background(Color.White) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + panOffset += dragAmount + } + } + ) { + val centerX = size.width / 2f + panOffset.x + val centerY = size.height / 2f + panOffset.y + + scale(zoom, pivot = Offset(size.width / 2f, size.height / 2f)) { + positions.forEach { (node, pos) -> + node.children.forEach { child -> + val childPos = positions[child] ?: return@forEach + drawLine( + color = Color.Gray, + start = Offset(pos.x + centerX, pos.y + centerY), + end = Offset(childPos.x + centerX, childPos.y + centerY), + strokeWidth = 2f / zoom, + ) + } + } + + positions.forEach { (node, pos) -> + val textLayout = textLayouts[node] ?: return@forEach + val x = pos.x + centerX + val y = pos.y + centerY + val rectW = textLayout.size.width + nodePaddingH * 2 + val rectH = textLayout.size.height + nodePaddingV * 2 + + drawRoundRect( + color = Color(0xFF4CAF50), + topLeft = Offset(x - rectW / 2f, y - rectH / 2f), + size = Size(rectW, rectH), + cornerRadius = CornerRadius(6f, 6f), + ) + + drawText( + textLayoutResult = textLayout, + topLeft = Offset( + x - textLayout.size.width / 2f, + y - textLayout.size.height / 2f, + ), + ) + } + } + } + } +} From f577cebd9b8f31680ffd6d73779362b495755dd8 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:23:15 +0200 Subject: [PATCH 03/12] ensure code gets reused --- .../treelayout/sample/LayoutControls.kt | 83 +++++++ .../linde9821/treelayout/sample/TreeCanvas.kt | 84 +++++++ .../treelayout/sample/TreeVisualization.kt | 200 ++--------------- .../sample/TreeVisualizationState.kt | 98 +++++++++ .../treelayout/sample/SampleNodeTest.kt | 1 + .../sample/SharedVisualizationTest.kt | 58 +++++ .../sample/TreeVisualizationVertical.kt | 208 +++--------------- 7 files changed, 369 insertions(+), 363 deletions(-) create mode 100644 sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt create mode 100644 sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeCanvas.kt create mode 100644 sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt create mode 100644 sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SharedVisualizationTest.kt diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt new file mode 100644 index 0000000..5bd5765 --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt @@ -0,0 +1,83 @@ +package io.github.linde9821.treelayout.sample + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.linde9821.treelayout.Orientation + +@Composable +public fun LayoutControls( + state: TreeVisualizationState, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + var orientationExpanded by remember { mutableStateOf(false) } + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Layout Controls", style = MaterialTheme.typography.subtitle1) + + if (compact) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("H Distance: ${state.horizontalDistance.toInt()}", fontSize = 12.sp) + Slider(value = state.horizontalDistance, onValueChange = state.onHorizontalDistanceChange, valueRange = 0f..200f) + } + Column(modifier = Modifier.weight(1f)) { + Text("V Distance: ${state.verticalDistance.toInt()}", fontSize = 12.sp) + Slider(value = state.verticalDistance, onValueChange = state.onVerticalDistanceChange, valueRange = 0f..200f) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Padding H: ${state.nodePaddingH.toInt()}", fontSize = 12.sp) + Slider(value = state.nodePaddingH, onValueChange = state.onNodePaddingHChange, valueRange = 0f..40f) + } + Column(modifier = Modifier.weight(1f)) { + Text("Padding V: ${state.nodePaddingV.toInt()}", fontSize = 12.sp) + Slider(value = state.nodePaddingV, onValueChange = state.onNodePaddingVChange, valueRange = 0f..40f) + } + } + } else { + Text("Horizontal Distance: ${state.horizontalDistance.toInt()}") + Slider(value = state.horizontalDistance, onValueChange = state.onHorizontalDistanceChange, valueRange = 0f..200f) + Text("Vertical Distance: ${state.verticalDistance.toInt()}") + Slider(value = state.verticalDistance, onValueChange = state.onVerticalDistanceChange, valueRange = 0f..200f) + Text("Node Padding H: ${state.nodePaddingH.toInt()}") + Slider(value = state.nodePaddingH, onValueChange = state.onNodePaddingHChange, valueRange = 0f..40f) + Text("Node Padding V: ${state.nodePaddingV.toInt()}") + Slider(value = state.nodePaddingV, onValueChange = state.onNodePaddingVChange, valueRange = 0f..40f) + } + + Box { + OutlinedButton(onClick = { orientationExpanded = true }) { + Text(state.orientation.name) + } + DropdownMenu( + expanded = orientationExpanded, + onDismissRequest = { orientationExpanded = false }, + ) { + Orientation.entries.forEach { o -> + DropdownMenuItem(onClick = { + state.onOrientationChange(o) + orientationExpanded = false + }) { Text(o.name) } + } + } + } + } +} diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeCanvas.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeCanvas.kt new file mode 100644 index 0000000..60c0c63 --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeCanvas.kt @@ -0,0 +1,84 @@ +package io.github.linde9821.treelayout.sample + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.drawText +import io.github.linde9821.treelayout.Point + +@Composable +public fun TreeCanvas( + positions: Map, + textLayouts: Map, + nodePaddingH: Float, + nodePaddingV: Float, + zoom: Float, + onZoomChange: ((Float) -> Unit)?, + modifier: Modifier = Modifier, +) { + var panOffset by remember { mutableStateOf(Offset.Zero) } + + Canvas( + modifier = modifier + .background(Color.White) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + panOffset += dragAmount + } + } + ) { + val centerX = size.width / 2f + panOffset.x + val centerY = size.height / 2f + panOffset.y + + scale(zoom, pivot = Offset(size.width / 2f, size.height / 2f)) { + positions.forEach { (node, pos) -> + node.children.forEach { child -> + val childPos = positions[child] ?: return@forEach + drawLine( + color = Color.Gray, + start = Offset(pos.x + centerX, pos.y + centerY), + end = Offset(childPos.x + centerX, childPos.y + centerY), + strokeWidth = 2f / zoom, + ) + } + } + + positions.forEach { (node, pos) -> + val textLayout = textLayouts[node] ?: return@forEach + val x = pos.x + centerX + val y = pos.y + centerY + val rectW = textLayout.size.width + nodePaddingH * 2 + val rectH = textLayout.size.height + nodePaddingV * 2 + + drawRoundRect( + color = Color(0xFF4CAF50), + topLeft = Offset(x - rectW / 2f, y - rectH / 2f), + size = Size(rectW, rectH), + cornerRadius = CornerRadius(6f, 6f), + ) + + drawText( + textLayoutResult = textLayout, + topLeft = Offset( + x - textLayout.size.width / 2f, + y - textLayout.size.height / 2f, + ), + ) + } + } + } +} diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt index 9ca8057..517f5a4 100644 --- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt @@ -1,12 +1,8 @@ package io.github.linde9821.treelayout.sample -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.material.Divider import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -14,13 +10,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Divider -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,141 +20,36 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.github.linde9821.treelayout.NodeExtentProvider -import io.github.linde9821.treelayout.Orientation -import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration -import io.github.linde9821.treelayout.walker.WalkerTreeLayout -const val DEFAULT_INPUT: String = "Not all those who wander are lost" +public const val DEFAULT_INPUT: String = "Not all those who wander are lost" @OptIn(ExperimentalComposeUiApi::class) @Composable -fun TreeVisualization() { - var input by remember { mutableStateOf(DEFAULT_INPUT) } - var horizontalDistance by remember { mutableStateOf(40f) } - var verticalDistance by remember { mutableStateOf(60f) } - var orientation by remember { mutableStateOf(Orientation.TopToBottom) } - var nodePaddingH by remember { mutableStateOf(12f) } - var nodePaddingV by remember { mutableStateOf(8f) } - var orientationExpanded by remember { mutableStateOf(false) } - - val words = input.lowercase().split("\\s+".toRegex()) - .map { it.filter(Char::isLetter) } - .filter { it.isNotEmpty() } - val tree = buildPrefixTree(words) - val adapter = prefixTreeAdapter(tree) - - val textMeasurer = rememberTextMeasurer() - val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black) - - val textLayouts = remember(tree) { - buildMap { - fun walk(node: PrefixNode) { - val display = node.label.ifEmpty { "·" } - put(node, textMeasurer.measure(display, textStyle)) - node.children.forEach { walk(it) } - } - walk(tree) - } - } - - val extents = object : NodeExtentProvider { - override fun width(node: PrefixNode): Float = - (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 - - override fun height(node: PrefixNode): Float = - (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 - } - - val config = WalkerLayoutConfiguration( - horizontalDistance = horizontalDistance, - verticalDistance = verticalDistance, - orientation = orientation, - ) - - val result = WalkerTreeLayout(adapter, config, extents).layout() - val positions = result.getPositions() +public fun TreeVisualization() { + val state = rememberTreeVisualizationState() + var zoom by remember { mutableStateOf(1f) } Row(modifier = Modifier.fillMaxSize()) { - // Left: Controls - Column( + LayoutControls( + state = state, modifier = Modifier.widthIn(min = 180.dp).width(220.dp).fillMaxHeight().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Layout Controls", style = MaterialTheme.typography.subtitle1) - - Text("Horizontal Distance: ${horizontalDistance.toInt()}") - Slider( - value = horizontalDistance, - onValueChange = { horizontalDistance = it }, - valueRange = 0f..200f, - ) - - Text("Vertical Distance: ${verticalDistance.toInt()}") - Slider( - value = verticalDistance, - onValueChange = { verticalDistance = it }, - valueRange = 0f..200f, - ) - - Text("Node Padding H: ${nodePaddingH.toInt()}") - Slider( - value = nodePaddingH, - onValueChange = { nodePaddingH = it }, - valueRange = 0f..40f, - ) - - Text("Node Padding V: ${nodePaddingV.toInt()}") - Slider( - value = nodePaddingV, - onValueChange = { nodePaddingV = it }, - valueRange = 0f..40f, - ) - - Text("Orientation:") - Box { - OutlinedButton(onClick = { orientationExpanded = true }) { - Text(orientation.name) - } - DropdownMenu( - expanded = orientationExpanded, - onDismissRequest = { orientationExpanded = false }, - ) { - Orientation.entries.forEach { o -> - DropdownMenuItem(onClick = { - orientation = o - orientationExpanded = false - }) { - Text(o.name) - } - } - } - } - } + ) Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) - // Middle: Input Column( modifier = Modifier.widthIn(min = 150.dp).width(200.dp).fillMaxHeight().padding(16.dp), ) { Text("Words (space-separated)", style = MaterialTheme.typography.subtitle1) OutlinedTextField( - value = input, - onValueChange = { input = it }, + value = state.input, + onValueChange = state.onInputChange, modifier = Modifier.fillMaxWidth().weight(1f), textStyle = TextStyle(fontSize = 13.sp), ) @@ -171,66 +57,20 @@ fun TreeVisualization() { Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) - // Right: Canvas - - var panOffset by remember { mutableStateOf(Offset.Zero) } - var zoom by remember { mutableStateOf(1f) } - - Canvas( + TreeCanvas( + positions = state.positions, + textLayouts = state.textLayouts, + nodePaddingH = state.nodePaddingH, + nodePaddingV = state.nodePaddingV, + zoom = zoom, + onZoomChange = null, modifier = Modifier .fillMaxSize() - .background(Color.White) - .border(2.dp, Color.Gray) + .border(2.dp, androidx.compose.ui.graphics.Color.Gray) .onPointerEvent(PointerEventType.Scroll) { event -> val scrollDelta = event.changes.first().scrollDelta.y zoom = (zoom * if (scrollDelta > 0) 0.9f else 1.1f).coerceIn(0.1f, 5f) - } - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - panOffset += dragAmount - } - } - ) { - val centerX = size.width / 2f + panOffset.x - val centerY = size.height / 2f + panOffset.y - - scale(zoom, pivot = Offset(size.width / 2f, size.height / 2f)) { - positions.forEach { (node, pos) -> - node.children.forEach { child -> - val childPos = positions[child] ?: return@forEach - drawLine( - color = Color.Gray, - start = Offset(pos.x + centerX, pos.y + centerY), - end = Offset(childPos.x + centerX, childPos.y + centerY), - strokeWidth = 2f / zoom, - ) - } - } - - positions.forEach { (node, pos) -> - val textLayout = textLayouts[node] ?: return@forEach - val x = pos.x + centerX - val y = pos.y + centerY - val rectW = textLayout.size.width + nodePaddingH * 2 - val rectH = textLayout.size.height + nodePaddingV * 2 - - drawRoundRect( - color = Color(0xFF4CAF50), - topLeft = Offset(x - rectW / 2f, y - rectH / 2f), - size = Size(rectW, rectH), - cornerRadius = CornerRadius(6f, 6f), - ) - - drawText( - textLayoutResult = textLayout, - topLeft = Offset( - x - textLayout.size.width / 2f, - y - textLayout.size.height / 2f, - ), - ) - } - } - } + }, + ) } } diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt new file mode 100644 index 0000000..716b5be --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt @@ -0,0 +1,98 @@ +package io.github.linde9821.treelayout.sample + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import io.github.linde9821.treelayout.NodeExtentProvider +import io.github.linde9821.treelayout.Orientation +import io.github.linde9821.treelayout.Point +import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration +import io.github.linde9821.treelayout.walker.WalkerTreeLayout + +public class TreeVisualizationState( + public val input: String, + public val onInputChange: (String) -> Unit, + public val horizontalDistance: Float, + public val onHorizontalDistanceChange: (Float) -> Unit, + public val verticalDistance: Float, + public val onVerticalDistanceChange: (Float) -> Unit, + public val orientation: Orientation, + public val onOrientationChange: (Orientation) -> Unit, + public val nodePaddingH: Float, + public val onNodePaddingHChange: (Float) -> Unit, + public val nodePaddingV: Float, + public val onNodePaddingVChange: (Float) -> Unit, + public val positions: Map, + public val textLayouts: Map, +) + +@Composable +public fun rememberTreeVisualizationState(): TreeVisualizationState { + var input by remember { mutableStateOf(DEFAULT_INPUT) } + var horizontalDistance by remember { mutableStateOf(40f) } + var verticalDistance by remember { mutableStateOf(60f) } + var orientation by remember { mutableStateOf(Orientation.TopToBottom) } + var nodePaddingH by remember { mutableStateOf(12f) } + var nodePaddingV by remember { mutableStateOf(8f) } + + val words = input.lowercase().split("\\s+".toRegex()) + .map { it.filter(Char::isLetter) } + .filter { it.isNotEmpty() } + val tree = buildPrefixTree(words) + val adapter = prefixTreeAdapter(tree) + + val textMeasurer = rememberTextMeasurer() + val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black) + + val textLayouts = remember(tree) { + buildMap { + fun walk(node: PrefixNode) { + val display = node.label.ifEmpty { "·" } + put(node, textMeasurer.measure(display, textStyle)) + node.children.forEach { walk(it) } + } + walk(tree) + } + } + + val extents = object : NodeExtentProvider { + override fun width(node: PrefixNode): Float = + (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 + + override fun height(node: PrefixNode): Float = + (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 + } + + val config = WalkerLayoutConfiguration( + horizontalDistance = horizontalDistance, + verticalDistance = verticalDistance, + orientation = orientation, + ) + + val result = WalkerTreeLayout(adapter, config, extents).layout() + val positions = result.getPositions() + + return TreeVisualizationState( + input = input, + onInputChange = { input = it }, + horizontalDistance = horizontalDistance, + onHorizontalDistanceChange = { horizontalDistance = it }, + verticalDistance = verticalDistance, + onVerticalDistanceChange = { verticalDistance = it }, + orientation = orientation, + onOrientationChange = { orientation = it }, + nodePaddingH = nodePaddingH, + onNodePaddingHChange = { nodePaddingH = it }, + nodePaddingV = nodePaddingV, + onNodePaddingVChange = { nodePaddingV = it }, + positions = positions, + textLayouts = textLayouts, + ) +} diff --git a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt index e69de29..b54bdd6 100644 --- a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt +++ b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt @@ -0,0 +1 @@ +package io.github.linde9821.treelayout.sample diff --git a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SharedVisualizationTest.kt b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SharedVisualizationTest.kt new file mode 100644 index 0000000..0de479a --- /dev/null +++ b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SharedVisualizationTest.kt @@ -0,0 +1,58 @@ +package io.github.linde9821.treelayout.sample + +import io.github.linde9821.treelayout.NodeExtentProvider +import io.github.linde9821.treelayout.Orientation +import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration +import io.github.linde9821.treelayout.walker.WalkerTreeLayout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +public class SharedVisualizationTest { + + private val fixedExtents: NodeExtentProvider = object : NodeExtentProvider { + override fun width(node: PrefixNode): Float = 40f + override fun height(node: PrefixNode): Float = 20f + } + + @Test + public fun sharedLayoutProducesPositionsForAllNodes() { + val tree = buildPrefixTree(listOf("cat", "car")) + val adapter = prefixTreeAdapter(tree) + val config = WalkerLayoutConfiguration( + horizontalDistance = 40f, + verticalDistance = 60f, + orientation = Orientation.TopToBottom, + ) + val result = WalkerTreeLayout(adapter, config, fixedExtents).layout() + val positions = result.getPositions() + + // root + "ca" + "t" + "r" = 4 nodes + assertEquals(4, positions.size) + } + + @Test + public fun sharedLayoutWorksWithAllOrientations() { + val tree = buildPrefixTree(listOf("ab", "cd")) + val adapter = prefixTreeAdapter(tree) + + Orientation.entries.forEach { orientation -> + val config = WalkerLayoutConfiguration( + horizontalDistance = 40f, + verticalDistance = 60f, + orientation = orientation, + ) + val result = WalkerTreeLayout(adapter, config, fixedExtents).layout() + assertTrue(result.getPositions().isNotEmpty(), "No positions for $orientation") + } + } + + @Test + public fun defaultInputProducesNonEmptyTree() { + val words = DEFAULT_INPUT.lowercase().split("\\s+".toRegex()) + .map { it.filter(Char::isLetter) } + .filter { it.isNotEmpty() } + val tree = buildPrefixTree(words) + assertTrue(tree.children.isNotEmpty()) + } +} diff --git a/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt b/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt index a67724a..06924e8 100644 --- a/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt +++ b/sample/src/iosMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationVertical.kt @@ -1,14 +1,8 @@ package io.github.linde9821.treelayout.sample -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,147 +12,42 @@ import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.github.linde9821.treelayout.NodeExtentProvider -import io.github.linde9821.treelayout.Orientation -import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration -import io.github.linde9821.treelayout.walker.WalkerTreeLayout @Composable public fun TreeVisualizationVertical() { - var input by remember { mutableStateOf(DEFAULT_INPUT) } - var horizontalDistance by remember { mutableStateOf(40f) } - var verticalDistance by remember { mutableStateOf(60f) } - var orientation by remember { mutableStateOf(Orientation.TopToBottom) } - var nodePaddingH by remember { mutableStateOf(12f) } - var nodePaddingV by remember { mutableStateOf(8f) } - var orientationExpanded by remember { mutableStateOf(false) } - - val words = input.lowercase().split("\\s+".toRegex()) - .map { it.filter(Char::isLetter) } - .filter { it.isNotEmpty() } - val tree = buildPrefixTree(words) - val adapter = prefixTreeAdapter(tree) - - val textMeasurer = rememberTextMeasurer() - val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black) - - val textLayouts = remember(tree) { - buildMap { - fun walk(node: PrefixNode) { - val display = node.label.ifEmpty { "·" } - put(node, textMeasurer.measure(display, textStyle)) - node.children.forEach { walk(it) } - } - walk(tree) - } - } - - val extents = object : NodeExtentProvider { - override fun width(node: PrefixNode): Float = - (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 - - override fun height(node: PrefixNode): Float = - (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 - } - - val config = WalkerLayoutConfiguration( - horizontalDistance = horizontalDistance, - verticalDistance = verticalDistance, - orientation = orientation, - ) - - val result = WalkerTreeLayout(adapter, config, extents).layout() - val positions = result.getPositions() - + val state = rememberTreeVisualizationState() val focusManager = LocalFocusManager.current - Column(modifier = Modifier - .fillMaxSize() - .safeDrawingPadding() - .imePadding() - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { - focusManager.clearFocus() - } - .padding(16.dp) + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .imePadding() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + focusManager.clearFocus() + } + .padding(16.dp) ) { - // Top: Controls + Input (scrollable) - Column( + LayoutControls( + state = state, modifier = Modifier.weight(0.4f).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Layout Controls", style = MaterialTheme.typography.subtitle1) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column(modifier = Modifier.weight(1f)) { - Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp) - Slider(value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f) - } - Column(modifier = Modifier.weight(1f)) { - Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp) - Slider(value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f) - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column(modifier = Modifier.weight(1f)) { - Text("Padding H: ${nodePaddingH.toInt()}", fontSize = 12.sp) - Slider(value = nodePaddingH, onValueChange = { nodePaddingH = it }, valueRange = 0f..40f) - } - Column(modifier = Modifier.weight(1f)) { - Text("Padding V: ${nodePaddingV.toInt()}", fontSize = 12.sp) - Slider(value = nodePaddingV, onValueChange = { nodePaddingV = it }, valueRange = 0f..40f) - } - } - - Box { - OutlinedButton(onClick = { orientationExpanded = true }) { - Text(orientation.name) - } - DropdownMenu( - expanded = orientationExpanded, - onDismissRequest = { orientationExpanded = false }, - ) { - Orientation.entries.forEach { o -> - DropdownMenuItem(onClick = { - orientation = o - orientationExpanded = false - }) { Text(o.name) } - } - } - } - } + compact = true, + ) Divider(modifier = Modifier.padding(vertical = 8.dp)) - // Middle: Input OutlinedTextField( - value = input, - onValueChange = { input = it }, + value = state.input, + onValueChange = state.onInputChange, modifier = Modifier.fillMaxWidth().height(80.dp), label = { Text("Words (space-separated)") }, textStyle = TextStyle(fontSize = 13.sp), @@ -166,61 +55,14 @@ public fun TreeVisualizationVertical() { Divider(modifier = Modifier.padding(vertical = 8.dp)) - // Bottom: Canvas - var panOffset by remember { mutableStateOf(Offset.Zero) } - var zoom by remember { mutableStateOf(1f) } - - Canvas( - modifier = Modifier - .fillMaxWidth() - .weight(0.6f) - .background(Color.White) - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - panOffset += dragAmount - } - } - ) { - val centerX = size.width / 2f + panOffset.x - val centerY = size.height / 2f + panOffset.y - - scale(zoom, pivot = Offset(size.width / 2f, size.height / 2f)) { - positions.forEach { (node, pos) -> - node.children.forEach { child -> - val childPos = positions[child] ?: return@forEach - drawLine( - color = Color.Gray, - start = Offset(pos.x + centerX, pos.y + centerY), - end = Offset(childPos.x + centerX, childPos.y + centerY), - strokeWidth = 2f / zoom, - ) - } - } - - positions.forEach { (node, pos) -> - val textLayout = textLayouts[node] ?: return@forEach - val x = pos.x + centerX - val y = pos.y + centerY - val rectW = textLayout.size.width + nodePaddingH * 2 - val rectH = textLayout.size.height + nodePaddingV * 2 - - drawRoundRect( - color = Color(0xFF4CAF50), - topLeft = Offset(x - rectW / 2f, y - rectH / 2f), - size = Size(rectW, rectH), - cornerRadius = CornerRadius(6f, 6f), - ) - - drawText( - textLayoutResult = textLayout, - topLeft = Offset( - x - textLayout.size.width / 2f, - y - textLayout.size.height / 2f, - ), - ) - } - } - } + TreeCanvas( + positions = state.positions, + textLayouts = state.textLayouts, + nodePaddingH = state.nodePaddingH, + nodePaddingV = state.nodePaddingV, + zoom = 1f, + onZoomChange = null, + modifier = Modifier.fillMaxWidth().weight(0.6f), + ) } } From 4798b87a18076762d20358d6f05260653e2edc32 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:28:29 +0200 Subject: [PATCH 04/12] create wasmJs sample --- sample/build.gradle.kts | 5 +++++ .../github/linde9821/treelayout/sample/Main.kt | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 sample/src/wasmJsMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 1f4906c..ec0d5b6 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.compose.compiler) } +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) kotlin { jvm() listOf( @@ -16,6 +17,10 @@ kotlin { isStatic = true } } + wasmJs { + browser() + binaries.executable() + } sourceSets { commonMain.dependencies { diff --git a/sample/src/wasmJsMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt b/sample/src/wasmJsMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt new file mode 100644 index 0000000..f96a1a6 --- /dev/null +++ b/sample/src/wasmJsMain/kotlin/io/github/linde9821/treelayout/sample/Main.kt @@ -0,0 +1,16 @@ +package io.github.linde9821.treelayout.sample + +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import kotlinx.browser.document + +@OptIn(ExperimentalComposeUiApi::class) +public fun main() { + val root = document.getElementById("root") ?: return + ComposeViewport(root) { + MaterialTheme { + TreeVisualization() + } + } +} From 0dc57bc436ff652f29b4f41b52ea45ae76c26b34 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:29:20 +0200 Subject: [PATCH 05/12] create gitlab page from sample --- .github/workflows/deploy-web.yml | 43 ++++++++++++++++++++++ sample/src/wasmJsMain/resources/index.html | 21 +++++++++++ 2 files changed, 64 insertions(+) create mode 100644 .github/workflows/deploy-web.yml create mode 100644 sample/src/wasmJsMain/resources/index.html diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..2ab523b --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,43 @@ +name: Deploy Web Sample + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Build wasmJs distribution + run: ./gradlew :sample:wasmJsBrowserDistribution + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: sample/build/dist/wasmJs/productionExecutable + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/sample/src/wasmJsMain/resources/index.html b/sample/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..cf47835 --- /dev/null +++ b/sample/src/wasmJsMain/resources/index.html @@ -0,0 +1,21 @@ + + + + + + TreeLayoutKMP Sample + + + +
+ + + From 6a80cef5dba09adb23dbd49c9da5244170f5a353 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:31:26 +0200 Subject: [PATCH 06/12] test run webpage deployment --- .github/workflows/deploy-web.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 2ab523b..82d8320 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -3,6 +3,8 @@ name: Deploy Web Sample on: push: branches: [ main ] + pull_request: + branches: [ main ] workflow_dispatch: permissions: From 9c58bbd22dd63507f6a9a5b064a45e672c5d9cc0 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:40:19 +0200 Subject: [PATCH 07/12] increase kotlin.daemon memory --- .gitignore | 2 +- gradle.properties | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 gradle.properties diff --git a/.gitignore b/.gitignore index fd628df..cd9cd18 100644 --- a/.gitignore +++ b/.gitignore @@ -255,4 +255,4 @@ xcuserdata/ .kotlin .kiro *.gpg -gradle.properties +gradle.properties_local diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b102424 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +#Gradle +org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx4096M +org.gradle.caching=true +org.gradle.configuration-cache=true +#Kotlin +kotlin.code.style=official +#MPP +kotlin.mpp.enableCInteropCommonization=true +#Android +android.useAndroidX=true +android.nonTransitiveRClass=true +#Publishing - Maven Central (Sonatype Central Portal) +mavenCentralUsername= +mavenCentralPassword= +#Signing +signing.keyId= +signing.password= +signing.secretKeyRingFile= \ No newline at end of file From b909629a17db1867c54bf9fbd18a7b1ab2881dd5 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:52:47 +0200 Subject: [PATCH 08/12] add android sample app --- .github/workflows/deploy-web.yml | 2 - .gitignore | 4 + sample/android/.idea/.gitignore | 3 + sample/android/.idea/AndroidProjectSystem.xml | 6 + .../android/.idea/caches/deviceStreaming.xml | 1808 +++++++++++++++++ sample/android/.idea/gradle.xml | 11 + sample/android/.idea/misc.xml | 10 + sample/android/.idea/runConfigurations.xml | 17 + sample/android/.idea/vcs.xml | 6 + sample/android/build.gradle.kts | 29 + sample/android/gradle.properties | 2 + .../gradle/gradle-daemon-jvm.properties | 12 + .../gradle/wrapper/gradle-wrapper.properties | 7 + sample/android/local.properties | 8 + sample/android/settings.gradle.kts | 16 + sample/android/src/main/AndroidManifest.xml | 15 + .../treelayout/sample/android/MainActivity.kt | 19 + .../sample/android/TreeVisualizationScreen.kt | 261 +++ 18 files changed, 2234 insertions(+), 2 deletions(-) create mode 100644 sample/android/.idea/.gitignore create mode 100644 sample/android/.idea/AndroidProjectSystem.xml create mode 100644 sample/android/.idea/caches/deviceStreaming.xml create mode 100644 sample/android/.idea/gradle.xml create mode 100644 sample/android/.idea/misc.xml create mode 100644 sample/android/.idea/runConfigurations.xml create mode 100644 sample/android/.idea/vcs.xml create mode 100644 sample/android/build.gradle.kts create mode 100644 sample/android/gradle.properties create mode 100644 sample/android/gradle/gradle-daemon-jvm.properties create mode 100644 sample/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 sample/android/local.properties create mode 100644 sample/android/settings.gradle.kts create mode 100644 sample/android/src/main/AndroidManifest.xml create mode 100644 sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/MainActivity.kt create mode 100644 sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 82d8320..2ab523b 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -3,8 +3,6 @@ name: Deploy Web Sample on: push: branches: [ main ] - pull_request: - branches: [ main ] workflow_dispatch: permissions: diff --git a/.gitignore b/.gitignore index cd9cd18..6bab8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -256,3 +256,7 @@ xcuserdata/ .kiro *.gpg gradle.properties_local + +# Default ignored files +/shelf/ +/workspace.xml \ No newline at end of file diff --git a/sample/android/.idea/.gitignore b/sample/android/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/sample/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/sample/android/.idea/AndroidProjectSystem.xml b/sample/android/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/sample/android/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/sample/android/.idea/caches/deviceStreaming.xml b/sample/android/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..f9eb024 --- /dev/null +++ b/sample/android/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1808 @@ + + + + + + \ No newline at end of file diff --git a/sample/android/.idea/gradle.xml b/sample/android/.idea/gradle.xml new file mode 100644 index 0000000..7505d8d --- /dev/null +++ b/sample/android/.idea/gradle.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/sample/android/.idea/misc.xml b/sample/android/.idea/misc.xml new file mode 100644 index 0000000..3aec57f --- /dev/null +++ b/sample/android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/sample/android/.idea/runConfigurations.xml b/sample/android/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/sample/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/sample/android/.idea/vcs.xml b/sample/android/.idea/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/sample/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts new file mode 100644 index 0000000..5023fc5 --- /dev/null +++ b/sample/android/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("com.android.application") version "9.2.1" + id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" + id("org.jetbrains.compose") version "1.10.3" +} + +android { + namespace = "io.github.linde9821.treelayout.sample.android" + compileSdk = 36 + defaultConfig { + applicationId = "io.github.linde9821.treelayout.sample.android" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation("io.github.linde9821:treelayout-kmp:0.2.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.ui) +} diff --git a/sample/android/gradle.properties b/sample/android/gradle.properties new file mode 100644 index 0000000..0b88863 --- /dev/null +++ b/sample/android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/sample/android/gradle/gradle-daemon-jvm.properties b/sample/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/sample/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/sample/android/gradle/wrapper/gradle-wrapper.properties b/sample/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5dd3c01 --- /dev/null +++ b/sample/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/sample/android/local.properties b/sample/android/local.properties new file mode 100644 index 0000000..7172d45 --- /dev/null +++ b/sample/android/local.properties @@ -0,0 +1,8 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +#Tue May 19 14:49:43 CEST 2026 +sdk.dir=/Users/moritzlindner/Library/Android/sdk diff --git a/sample/android/settings.gradle.kts b/sample/android/settings.gradle.kts new file mode 100644 index 0000000..e6efcf0 --- /dev/null +++ b/sample/android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "TreeLayoutKMP-Android-Sample" diff --git a/sample/android/src/main/AndroidManifest.xml b/sample/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a237567 --- /dev/null +++ b/sample/android/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/MainActivity.kt b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/MainActivity.kt new file mode 100644 index 0000000..e6c9eae --- /dev/null +++ b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/MainActivity.kt @@ -0,0 +1,19 @@ +package io.github.linde9821.treelayout.sample.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material.MaterialTheme + +public class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + TreeVisualizationScreen() + } + } + } +} diff --git a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt new file mode 100644 index 0000000..5978f2c --- /dev/null +++ b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt @@ -0,0 +1,261 @@ +package io.github.linde9821.treelayout.sample.android + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.linde9821.treelayout.NodeExtentProvider +import io.github.linde9821.treelayout.Orientation +import io.github.linde9821.treelayout.TreeAdapter +import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration +import io.github.linde9821.treelayout.walker.WalkerTreeLayout + +private const val DEFAULT_INPUT: String = "Not all those who wander are lost" + +private class PrefixNode(val label: String, val children: MutableList = mutableListOf()) + +private fun buildPrefixTree(words: List): PrefixNode { + val root = PrefixNode("") + for (word in words) { + if (word.isBlank()) continue + insertWord(root, word) + } + return root +} + +private fun insertWord(root: PrefixNode, word: String) { + var current = root + var i = 0 + while (i < word.length) { + val child = current.children.find { word.startsWith(it.label, i) } + if (child != null) { + i += child.label.length + current = child + } else { + val partial = current.children.find { it.label.isNotEmpty() && word[i] == it.label[0] } + if (partial != null) { + var common = 0 + while (common < partial.label.length && i + common < word.length && partial.label[common] == word[i + common]) { + common++ + } + val splitNode = PrefixNode( + partial.label.substring(0, common), + mutableListOf(PrefixNode(partial.label.substring(common), partial.children)) + ) + current.children[current.children.indexOf(partial)] = splitNode + if (i + common < word.length) { + splitNode.children.add(PrefixNode(word.substring(i + common))) + } + return + } else { + current.children.add(PrefixNode(word.substring(i))) + return + } + } + } +} + +@Composable +internal fun TreeVisualizationScreen() { + var input by remember { mutableStateOf(DEFAULT_INPUT) } + var horizontalDistance by remember { mutableStateOf(40f) } + var verticalDistance by remember { mutableStateOf(60f) } + var orientation by remember { mutableStateOf(Orientation.TopToBottom) } + var nodePaddingH by remember { mutableStateOf(12f) } + var nodePaddingV by remember { mutableStateOf(8f) } + var orientationExpanded by remember { mutableStateOf(false) } + + val words = input.lowercase().split("\\s+".toRegex()) + .map { it.filter(Char::isLetter) } + .filter { it.isNotEmpty() } + val tree = buildPrefixTree(words) + + val parentMap = buildMap { + fun walk(node: PrefixNode, parent: PrefixNode?) { + put(node, parent) + node.children.forEach { walk(it, node) } + } + walk(tree, null) + } + val adapter = object : TreeAdapter { + override fun root(): PrefixNode = tree + override fun children(node: PrefixNode): List = node.children + override fun parent(node: PrefixNode): PrefixNode? = parentMap[node] + } + + val textMeasurer = rememberTextMeasurer() + val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black) + + val textLayouts = remember(tree) { + buildMap { + fun walk(node: PrefixNode) { + put(node, textMeasurer.measure(node.label.ifEmpty { "·" }, textStyle)) + node.children.forEach { walk(it) } + } + walk(tree) + } + } + + val extents = object : NodeExtentProvider { + override fun width(node: PrefixNode): Float = + (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 + override fun height(node: PrefixNode): Float = + (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 + } + + val config = WalkerLayoutConfiguration( + horizontalDistance = horizontalDistance, + verticalDistance = verticalDistance, + orientation = orientation, + ) + val result = WalkerTreeLayout(adapter, config, extents).layout() + val positions = result.getPositions() + + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .imePadding() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + focusManager.clearFocus() + } + .padding(16.dp) + ) { + Column( + modifier = Modifier.weight(0.4f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Layout Controls", style = MaterialTheme.typography.subtitle1) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp) + Slider(value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f) + } + Column(modifier = Modifier.weight(1f)) { + Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp) + Slider(value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Padding H: ${nodePaddingH.toInt()}", fontSize = 12.sp) + Slider(value = nodePaddingH, onValueChange = { nodePaddingH = it }, valueRange = 0f..40f) + } + Column(modifier = Modifier.weight(1f)) { + Text("Padding V: ${nodePaddingV.toInt()}", fontSize = 12.sp) + Slider(value = nodePaddingV, onValueChange = { nodePaddingV = it }, valueRange = 0f..40f) + } + } + Box { + OutlinedButton(onClick = { orientationExpanded = true }) { Text(orientation.name) } + DropdownMenu(expanded = orientationExpanded, onDismissRequest = { orientationExpanded = false }) { + Orientation.entries.forEach { o -> + DropdownMenuItem(onClick = { orientation = o; orientationExpanded = false }) { Text(o.name) } + } + } + } + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().height(80.dp), + label = { Text("Words (space-separated)") }, + textStyle = TextStyle(fontSize = 13.sp), + ) + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + var panOffset by remember { mutableStateOf(Offset.Zero) } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .weight(0.6f) + .background(Color.White) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + panOffset += dragAmount + } + } + ) { + val centerX = size.width / 2f + panOffset.x + val centerY = size.height / 2f + panOffset.y + + positions.forEach { (node, pos) -> + node.children.forEach { child -> + val childPos = positions[child] ?: return@forEach + drawLine( + color = Color.Gray, + start = Offset(pos.x + centerX, pos.y + centerY), + end = Offset(childPos.x + centerX, childPos.y + centerY), + strokeWidth = 2f, + ) + } + } + + positions.forEach { (node, pos) -> + val textLayout = textLayouts[node] ?: return@forEach + val x = pos.x + centerX + val y = pos.y + centerY + val rectW = textLayout.size.width + nodePaddingH * 2 + val rectH = textLayout.size.height + nodePaddingV * 2 + + drawRoundRect( + color = Color(0xFF4CAF50), + topLeft = Offset(x - rectW / 2f, y - rectH / 2f), + size = Size(rectW, rectH), + cornerRadius = CornerRadius(6f, 6f), + ) + drawText( + textLayoutResult = textLayout, + topLeft = Offset(x - textLayout.size.width / 2f, y - textLayout.size.height / 2f), + ) + } + } + } +} From 253670463f430277f9ab334732e6cdb1bb1d98f7 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 14:55:33 +0200 Subject: [PATCH 09/12] update gitignore accordingly --- .gitignore | 3 +- sample/android/.idea/.gitignore | 3 - sample/android/.idea/AndroidProjectSystem.xml | 6 - .../android/.idea/caches/deviceStreaming.xml | 1808 ----------------- sample/android/.idea/gradle.xml | 11 - sample/android/.idea/misc.xml | 10 - sample/android/.idea/runConfigurations.xml | 17 - sample/android/.idea/vcs.xml | 6 - .../sample/android/TreeVisualizationScreen.kt | 51 +- 9 files changed, 43 insertions(+), 1872 deletions(-) delete mode 100644 sample/android/.idea/.gitignore delete mode 100644 sample/android/.idea/AndroidProjectSystem.xml delete mode 100644 sample/android/.idea/caches/deviceStreaming.xml delete mode 100644 sample/android/.idea/gradle.xml delete mode 100644 sample/android/.idea/misc.xml delete mode 100644 sample/android/.idea/runConfigurations.xml delete mode 100644 sample/android/.idea/vcs.xml diff --git a/.gitignore b/.gitignore index 6bab8eb..edb8a76 100644 --- a/.gitignore +++ b/.gitignore @@ -259,4 +259,5 @@ gradle.properties_local # Default ignored files /shelf/ -/workspace.xml \ No newline at end of file +/workspace.xml +sample/android/.idea/ diff --git a/sample/android/.idea/.gitignore b/sample/android/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/sample/android/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/sample/android/.idea/AndroidProjectSystem.xml b/sample/android/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee..0000000 --- a/sample/android/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/sample/android/.idea/caches/deviceStreaming.xml b/sample/android/.idea/caches/deviceStreaming.xml deleted file mode 100644 index f9eb024..0000000 --- a/sample/android/.idea/caches/deviceStreaming.xml +++ /dev/null @@ -1,1808 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/sample/android/.idea/gradle.xml b/sample/android/.idea/gradle.xml deleted file mode 100644 index 7505d8d..0000000 --- a/sample/android/.idea/gradle.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/sample/android/.idea/misc.xml b/sample/android/.idea/misc.xml deleted file mode 100644 index 3aec57f..0000000 --- a/sample/android/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/sample/android/.idea/runConfigurations.xml b/sample/android/.idea/runConfigurations.xml deleted file mode 100644 index 16660f1..0000000 --- a/sample/android/.idea/runConfigurations.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/sample/android/.idea/vcs.xml b/sample/android/.idea/vcs.xml deleted file mode 100644 index b2bdec2..0000000 --- a/sample/android/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt index 5978f2c..a95cd60 100644 --- a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt +++ b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt @@ -138,6 +138,7 @@ internal fun TreeVisualizationScreen() { val extents = object : NodeExtentProvider { override fun width(node: PrefixNode): Float = (textLayouts[node]?.size?.width?.toFloat() ?: 0f) + nodePaddingH * 2 + override fun height(node: PrefixNode): Float = (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 } @@ -157,41 +158,66 @@ internal fun TreeVisualizationScreen() { .fillMaxSize() .safeDrawingPadding() .imePadding() - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { focusManager.clearFocus() } .padding(16.dp) ) { Column( - modifier = Modifier.weight(0.4f).verticalScroll(rememberScrollState()), + modifier = Modifier + .weight(0.4f) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text("Layout Controls", style = MaterialTheme.typography.subtitle1) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp) - Slider(value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f) + Slider( + value = horizontalDistance, + onValueChange = { horizontalDistance = it }, + valueRange = 0f..200f + ) } Column(modifier = Modifier.weight(1f)) { Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp) - Slider(value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f) + Slider( + value = verticalDistance, + onValueChange = { verticalDistance = it }, + valueRange = 0f..200f + ) } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text("Padding H: ${nodePaddingH.toInt()}", fontSize = 12.sp) - Slider(value = nodePaddingH, onValueChange = { nodePaddingH = it }, valueRange = 0f..40f) + Slider( + value = nodePaddingH, + onValueChange = { nodePaddingH = it }, + valueRange = 0f..40f + ) } Column(modifier = Modifier.weight(1f)) { Text("Padding V: ${nodePaddingV.toInt()}", fontSize = 12.sp) - Slider(value = nodePaddingV, onValueChange = { nodePaddingV = it }, valueRange = 0f..40f) + Slider( + value = nodePaddingV, + onValueChange = { nodePaddingV = it }, + valueRange = 0f..40f + ) } } Box { OutlinedButton(onClick = { orientationExpanded = true }) { Text(orientation.name) } - DropdownMenu(expanded = orientationExpanded, onDismissRequest = { orientationExpanded = false }) { + DropdownMenu( + expanded = orientationExpanded, + onDismissRequest = { orientationExpanded = false }) { Orientation.entries.forEach { o -> - DropdownMenuItem(onClick = { orientation = o; orientationExpanded = false }) { Text(o.name) } + DropdownMenuItem(onClick = { + orientation = o; orientationExpanded = false + }) { Text(o.name) } } } } @@ -202,7 +228,9 @@ internal fun TreeVisualizationScreen() { OutlinedTextField( value = input, onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth().height(80.dp), + modifier = Modifier + .fillMaxWidth() + .height(80.dp), label = { Text("Words (space-separated)") }, textStyle = TextStyle(fontSize = 13.sp), ) @@ -253,7 +281,10 @@ internal fun TreeVisualizationScreen() { ) drawText( textLayoutResult = textLayout, - topLeft = Offset(x - textLayout.size.width / 2f, y - textLayout.size.height / 2f), + topLeft = Offset( + x - textLayout.size.width / 2f, + y - textLayout.size.height / 2f + ), ) } } From 752e8d27ae11e1c835cc941209360bf297be4048 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 15:12:46 +0200 Subject: [PATCH 10/12] fix deprecations --- sample/android/src/main/AndroidManifest.xml | 12 +++++----- sample/build.gradle.kts | 8 +++---- .../treelayout/sample/LayoutControls.kt | 24 +++++++++++++++---- .../linde9821/treelayout/sample/PrefixTree.kt | 8 ++++--- .../treelayout/sample/TreeVisualization.kt | 3 +-- .../sample/TreeVisualizationState.kt | 2 +- sample/src/wasmJsMain/resources/index.html | 6 ++--- 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/sample/android/src/main/AndroidManifest.xml b/sample/android/src/main/AndroidManifest.xml index a237567..f4bb920 100644 --- a/sample/android/src/main/AndroidManifest.xml +++ b/sample/android/src/main/AndroidManifest.xml @@ -1,14 +1,14 @@ + android:label="TreeLayoutKMP" + android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:name=".MainActivity" + android:exported="true"> - - + + diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index ec0d5b6..229788f 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -24,10 +24,10 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material) - implementation(compose.ui) + implementation("org.jetbrains.compose.runtime:runtime:1.10.3") + implementation("org.jetbrains.compose.foundation:foundation:1.10.3") + implementation("org.jetbrains.compose.material:material:1.10.3") + implementation("org.jetbrains.compose.ui:ui:1.10.3") implementation(project(":library")) } commonTest.dependencies { diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt index 5bd5765..1b8964d 100644 --- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt @@ -35,11 +35,19 @@ public fun LayoutControls( Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text("H Distance: ${state.horizontalDistance.toInt()}", fontSize = 12.sp) - Slider(value = state.horizontalDistance, onValueChange = state.onHorizontalDistanceChange, valueRange = 0f..200f) + Slider( + value = state.horizontalDistance, + onValueChange = state.onHorizontalDistanceChange, + valueRange = 0f..200f + ) } Column(modifier = Modifier.weight(1f)) { Text("V Distance: ${state.verticalDistance.toInt()}", fontSize = 12.sp) - Slider(value = state.verticalDistance, onValueChange = state.onVerticalDistanceChange, valueRange = 0f..200f) + Slider( + value = state.verticalDistance, + onValueChange = state.onVerticalDistanceChange, + valueRange = 0f..200f + ) } } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -54,9 +62,17 @@ public fun LayoutControls( } } else { Text("Horizontal Distance: ${state.horizontalDistance.toInt()}") - Slider(value = state.horizontalDistance, onValueChange = state.onHorizontalDistanceChange, valueRange = 0f..200f) + Slider( + value = state.horizontalDistance, + onValueChange = state.onHorizontalDistanceChange, + valueRange = 0f..200f + ) Text("Vertical Distance: ${state.verticalDistance.toInt()}") - Slider(value = state.verticalDistance, onValueChange = state.onVerticalDistanceChange, valueRange = 0f..200f) + Slider( + value = state.verticalDistance, + onValueChange = state.onVerticalDistanceChange, + valueRange = 0f..200f + ) Text("Node Padding H: ${state.nodePaddingH.toInt()}") Slider(value = state.nodePaddingH, onValueChange = state.onNodePaddingHChange, valueRange = 0f..40f) Text("Node Padding V: ${state.nodePaddingV.toInt()}") diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt index b62f941..48fff2d 100644 --- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/PrefixTree.kt @@ -34,9 +34,11 @@ private fun insertWord(root: PrefixNode, word: String) { common++ } // Split the existing node - val splitNode = PrefixNode(partial.label.substring(0, common), mutableListOf( - PrefixNode(partial.label.substring(common), partial.children) - )) + val splitNode = PrefixNode( + partial.label.substring(0, common), mutableListOf( + PrefixNode(partial.label.substring(common), partial.children) + ) + ) current.children[current.children.indexOf(partial)] = splitNode if (i + common < word.length) { splitNode.children.add(PrefixNode(word.substring(i + common))) diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt index 517f5a4..376832a 100644 --- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualization.kt @@ -2,7 +2,6 @@ package io.github.linde9821.treelayout.sample import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column -import androidx.compose.material.Divider import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -10,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text @@ -20,7 +20,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.text.TextStyle diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt index 716b5be..b360e84 100644 --- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt +++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt @@ -5,10 +5,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import io.github.linde9821.treelayout.NodeExtentProvider import io.github.linde9821.treelayout.Orientation diff --git a/sample/src/wasmJsMain/resources/index.html b/sample/src/wasmJsMain/resources/index.html index cf47835..6bc999d 100644 --- a/sample/src/wasmJsMain/resources/index.html +++ b/sample/src/wasmJsMain/resources/index.html @@ -2,7 +2,7 @@ - + TreeLayoutKMP Sample -
- +
+ From 0fc142cbfd5bcddc01a2990f8373919b8e2c7650 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 15:13:41 +0200 Subject: [PATCH 11/12] remove unused file --- .../io/github/linde9821/treelayout/sample/SampleNodeTest.kt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt diff --git a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt b/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt deleted file mode 100644 index b54bdd6..0000000 --- a/sample/src/commonTest/kotlin/io/github/linde9821/treelayout/sample/SampleNodeTest.kt +++ /dev/null @@ -1 +0,0 @@ -package io.github.linde9821.treelayout.sample From 2c8162221fad387fbf4116eb82dc48bb43c485a0 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Tue, 19 May 2026 15:17:00 +0200 Subject: [PATCH 12/12] update android dependency --- sample/android/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts index 5023fc5..cbb43f1 100644 --- a/sample/android/build.gradle.kts +++ b/sample/android/build.gradle.kts @@ -1,12 +1,12 @@ plugins { id("com.android.application") version "9.2.1" id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" - id("org.jetbrains.compose") version "1.10.3" + id("org.jetbrains.compose") version "1.11.0" } android { namespace = "io.github.linde9821.treelayout.sample.android" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "io.github.linde9821.treelayout.sample.android" minSdk = 24 @@ -21,7 +21,7 @@ android { dependencies { implementation("io.github.linde9821:treelayout-kmp:0.2.0") - implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.activity:activity-compose:1.13.0") implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material)