Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,24 @@ jobs:

- name: Build and test
run: ./gradlew :library:check

samples:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 17

- uses: gradle/actions/setup-gradle@v4

- name: Build shared sample
run: ./gradlew :sample:assemble

- name: Build iOS framework
run: ./gradlew :sample:linkDebugFrameworkIosSimulatorArm64

- name: Build Android sample
run: cd sample/android && ./gradlew assembleDebug
2 changes: 1 addition & 1 deletion library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group = "io.github.linde9821"
version = "0.3.0"
version = "0.3.1"

kotlin {
explicitApi()
Expand Down
6 changes: 6 additions & 0 deletions sample/android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ dependencyResolutionManagement {
}

rootProject.name = "TreeLayoutKMP-Android-Sample"

includeBuild("../..") {
dependencySubstitution {
substitute(module("io.github.linde9821:treelayout-kmp")).using(project(":library"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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.gestures.detectTransformGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -31,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
Expand All @@ -45,12 +46,21 @@ 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.Point
import io.github.linde9821.treelayout.TreeAdapter
import io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementConfiguration
import io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementLayout
import io.github.linde9821.treelayout.radial.walker.RadialWalkerLayoutConfiguration
import io.github.linde9821.treelayout.radial.walker.RadialWalkerTreeLayout
import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration
import io.github.linde9821.treelayout.walker.WalkerTreeLayout
import kotlin.math.PI
import kotlin.math.roundToInt

private const val DEFAULT_INPUT: String = "Not all those who wander are lost"

private enum class LayoutType { Walker, RadialWalker, DirectAngular }

private class PrefixNode(val label: String, val children: MutableList<PrefixNode> = mutableListOf())

private fun buildPrefixTree(words: List<String>): PrefixNode {
Expand Down Expand Up @@ -97,12 +107,17 @@ private fun insertWord(root: PrefixNode, word: String) {
@Composable
internal fun TreeVisualizationScreen() {
var input by remember { mutableStateOf(DEFAULT_INPUT) }
var layoutType by remember { mutableStateOf(LayoutType.Walker) }
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 layerDistance by remember { mutableStateOf(80f) }
var margin by remember { mutableStateOf(0.5f) }
var rotation by remember { mutableStateOf(0f) }
var orientationExpanded by remember { mutableStateOf(false) }
var layoutTypeExpanded by remember { mutableStateOf(false) }

val words = input.lowercase().split("\\s+".toRegex())
.map { it.filter(Char::isLetter) }
Expand Down Expand Up @@ -143,13 +158,31 @@ internal fun TreeVisualizationScreen() {
(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 positions: Map<PrefixNode, Point> = when (layoutType) {
LayoutType.Walker -> {
val config = WalkerLayoutConfiguration(
horizontalDistance = horizontalDistance,
verticalDistance = verticalDistance,
orientation = orientation,
)
WalkerTreeLayout(adapter, config, extents).layout().getPositions()
}
LayoutType.RadialWalker -> {
val config = RadialWalkerLayoutConfiguration(
layerDistance = layerDistance,
margin = margin,
rotation = rotation,
)
RadialWalkerTreeLayout(adapter, config, extents).layout().getPositions()
}
LayoutType.DirectAngular -> {
val config = DirectAngularPlacementConfiguration(
layerDistance = layerDistance,
rotation = rotation,
)
DirectAngularPlacementLayout(adapter, config).layout().getPositions()
}
}

val focusManager = LocalFocusManager.current

Expand All @@ -161,9 +194,7 @@ internal fun TreeVisualizationScreen() {
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
focusManager.clearFocus()
}
) { focusManager.clearFocus() }
.padding(16.dp)
) {
Column(
Expand All @@ -173,52 +204,76 @@ internal fun TreeVisualizationScreen() {
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
)

// Layout type selector
Box {
OutlinedButton(onClick = { layoutTypeExpanded = true }) { Text(layoutType.name) }
DropdownMenu(expanded = layoutTypeExpanded, onDismissRequest = { layoutTypeExpanded = false }) {
LayoutType.entries.forEach { lt ->
DropdownMenuItem(onClick = { layoutType = lt; layoutTypeExpanded = false }) { Text(lt.name) }
}
}
Column(modifier = Modifier.weight(1f)) {
Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp)
Slider(
value = verticalDistance,
onValueChange = { verticalDistance = it },
valueRange = 0f..200f
)
}

when (layoutType) {
LayoutType.Walker -> {
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)
}
}
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) }
}
}
}
}
LayoutType.RadialWalker -> {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text("Layer: ${layerDistance.toInt()}", fontSize = 12.sp)
Slider(value = layerDistance, onValueChange = { layerDistance = it }, valueRange = 10f..200f)
}
Column(modifier = Modifier.weight(1f)) {
Text("Margin: ${(margin * 100).roundToInt() / 100f}", fontSize = 12.sp)
Slider(value = margin, onValueChange = { margin = it }, valueRange = 0f..PI.toFloat())
}
}
Column {
Text("Rotation: ${(rotation * 100).roundToInt() / 100f}", fontSize = 12.sp)
Slider(value = rotation, onValueChange = { rotation = it }, valueRange = 0f..2f * PI.toFloat())
}
}
LayoutType.DirectAngular -> {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text("Layer: ${layerDistance.toInt()}", fontSize = 12.sp)
Slider(value = layerDistance, onValueChange = { layerDistance = it }, valueRange = 10f..200f)
}
Column(modifier = Modifier.weight(1f)) {
Text("Rotation: ${(rotation * 100).roundToInt() / 100f}", fontSize = 12.sp)
Slider(value = rotation, onValueChange = { rotation = it }, valueRange = 0f..2f * PI.toFloat())
}
}
}
}

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
)
}
}
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) }
}
Slider(value = nodePaddingV, onValueChange = { nodePaddingV = it }, valueRange = 0f..40f)
}
}
}
Expand All @@ -228,64 +283,66 @@ 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),
)

Divider(modifier = Modifier.padding(vertical = 8.dp))

var panOffset by remember { mutableStateOf(Offset.Zero) }
var zoom by remember { mutableStateOf(1f) }

Canvas(
modifier = Modifier
.fillMaxWidth()
.weight(0.6f)
.clipToBounds()
.background(Color.White)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
panOffset += dragAmount
detectTransformGestures { _, pan, gestureZoom, _ ->
panOffset += pan
zoom = (zoom * gestureZoom).coerceIn(0.1f, 5f)
}
}
) {
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,
)
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
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
),
)
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,
),
)
}
}
}
}
Expand Down
Loading