Skip to content

Commit

Permalink
Merge branch 'feature/json-tree-view' into 'main'
Browse files Browse the repository at this point in the history
json tree view

See merge request products/hello-http!15
  • Loading branch information
Sunny Chung committed Feb 12, 2024
2 parents 9f9c73f + 32f0bee commit f92826a
Show file tree
Hide file tree
Showing 11 changed files with 386 additions and 36 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.sunnychung.application.multiplatform.hellohttp.manager

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sunnychung.application.multiplatform.hellohttp.model.PrettifyResult
import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication
import com.sunnychung.application.multiplatform.hellohttp.parser.JsonParser

class PrettifierManager {
private val registrations: MutableSet<PrettifierRegistration> = mutableSetOf()
Expand All @@ -17,7 +19,7 @@ class PrettifierManager {
prettifier = Prettifier(
formatName = "JSON (Prettified)",
prettify = {
jacksonObjectMapper().readTree(it).toPrettyString()
JsonParser(it).prettify()
}
)
)
Expand All @@ -41,7 +43,7 @@ class PrettifierManager {

class Prettifier(
val formatName: String,
val prettify: (ByteArray) -> String,
val prettify: (ByteArray) -> PrettifyResult,
)

data class PrettifierRegistration(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.sunnychung.application.multiplatform.hellohttp.model

data class PrettifyResult(
val prettyString: String,
val collapsableLineRange: List<IntRange> = emptyList(),
val collapsableCharRange: List<IntRange> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.sunnychung.application.multiplatform.hellohttp.parser

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.sunnychung.application.multiplatform.hellohttp.model.PrettifyResult

class JsonParser(jsonBytes: ByteArray) {
val tree = jacksonObjectMapper().readTree(jsonBytes)

constructor(json: String) : this(json.encodeToByteArray())

fun prettify(): PrettifyResult {
var lineIndex = 0
val lineGroups = mutableListOf<IntRange>()
val startLineStack = mutableListOf<Int>()
val charGroups = mutableListOf<IntRange>()
val startCharStack = mutableListOf<Int>()

return PrettifyResult(
prettyString = buildString {
fun indent(level: Int) {
if (level > 0) {
append(" ".repeat(2 * level))
}
}

fun StringBuilder.transverse(node: JsonNode, indentLevel: Int) {
if (node.isArray) {
if (node.isEmpty) {
append("[]")
} else {
startCharStack += lastIndex + 1
append('[')
startLineStack += lineIndex
if (node.size() <= 20 && node.all { it.isValueNode }) {
node.forEachIndexed { i, it ->
if (i > 0) {
append(", ")
}
transverse(it, 0)
}
} else {
append("\n")
++lineIndex
node.forEachIndexed { i, it ->
if (i > 0) {
append(",\n")
++lineIndex
}
indent(indentLevel + 1)
transverse(it, indentLevel + 1)
}
append("\n")
++lineIndex
indent(indentLevel)
}
append(']')
lineGroups += startLineStack.removeLast() .. lineIndex
charGroups += startCharStack.removeLast() .. lastIndex
}
} else if (node.isObject) {
if (node.isEmpty) {
append("{}")
} else {
startCharStack += lastIndex + 1
startLineStack += lineIndex
append("{\n")
++lineIndex
node.fields().withIndex().forEach { (i, it) ->
if (i > 0) {
append(",\n")
++lineIndex
}
indent(indentLevel + 1)
append("\"${it.key}\": ")
transverse(it.value, indentLevel + 1)
}
append("\n")
++lineIndex
indent(indentLevel)
append('}')
lineGroups += startLineStack.removeLast() .. lineIndex
charGroups += startCharStack.removeLast() .. lastIndex
}
} else if (node.isValueNode) {
append(node.toPrettyString())
} else {
throw RuntimeException("what is this? -- $node")
}
}

transverse(tree, 0)
},
collapsableLineRange = lineGroups,
collapsableCharRange = charGroups,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.sunnychung.application.multiplatform.hellohttp.ux

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
Expand All @@ -14,18 +16,20 @@ fun AppImageButton(
modifier: Modifier = Modifier,
resource: String,
size: Dp = 32.dp,
innerPadding: PaddingValues = PaddingValues(),
color: Color = LocalColor.current.image,
enabled: Boolean = true,
onClick: () -> Unit
) {
var modifierToUse = modifier.size(size)
val modifierToUse = modifier.size(size)
.let {
if (enabled) {
it.clickable(onClick = onClick)
} else {
it
}
}
.padding(innerPadding)

AppImage(
resource = resource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,28 @@ package com.sunnychung.application.multiplatform.hellohttp.ux
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.onClick
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
Expand Down Expand Up @@ -62,6 +67,8 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDe
import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast
import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor
import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.CollapseTransformation
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.CollapseTransformationOffsetMapping
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.EnvironmentVariableTransformation
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation
Expand All @@ -75,6 +82,8 @@ fun CodeEditorView(
isReadOnly: Boolean = false,
text: String,
onTextChange: ((String) -> Unit)? = null,
collapsableLines: List<IntRange> = emptyList(),
collapsableChars: List<IntRange> = emptyList(),
textColor: Color = LocalColor.current.text,
transformations: List<VisualTransformation> = emptyList(),
isEnableVariables: Boolean = false,
Expand Down Expand Up @@ -115,6 +124,9 @@ fun CodeEditorView(
cursorDelta = 0
}

var collapsedLines = rememberLast(newText) { mutableStateMapOf<IntRange, IntRange>() }
var collapsedChars = rememberLast(newText) { mutableStateMapOf<IntRange, IntRange>() }

log.d { "CodeEditorView recompose" }

fun onPressEnterAddIndent() {
Expand Down Expand Up @@ -252,6 +264,11 @@ fun CodeEditorView(
)
} else {
emptyList()
} +
if (isReadOnly) {
listOf(CollapseTransformation(themeColours, collapsedChars.values.toList()))
} else {
emptyList()
}

textLayoutResult?.let { tl ->
Expand Down Expand Up @@ -392,6 +409,18 @@ fun CodeEditorView(
scrollState = scrollState,
textLayoutResult = textLayoutResult,
lineTops = lineTops,
collapsableLines = collapsableLines,
collapsedLines = collapsedLines.values.toList(),
onCollapseLine = { i ->
val index = collapsableLines.indexOfFirst { it.start == i }
collapsedLines[collapsableLines[index]] = collapsableLines[index]
collapsedChars[collapsableChars[index]] = collapsableChars[index]
},
onExpandLine = { i ->
val index = collapsableLines.indexOfFirst { it.start == i }
collapsedLines -= collapsableLines[index]
collapsedChars -= collapsableChars[index]
},
modifier = Modifier.fillMaxHeight(),
)
AppTextField(
Expand Down Expand Up @@ -510,7 +539,16 @@ data class SearchOptions(
)

@Composable
fun LineNumbersView(modifier: Modifier = Modifier, scrollState: ScrollState, textLayoutResult: TextLayoutResult?, lineTops: List<Float>?) = with(LocalDensity.current) {
fun LineNumbersView(
modifier: Modifier = Modifier,
scrollState: ScrollState,
textLayoutResult: TextLayoutResult?,
lineTops: List<Float>?,
collapsableLines: List<IntRange>,
collapsedLines: List<IntRange>,
onCollapseLine: (Int) -> Unit,
onExpandLine: (Int) -> Unit,
) = with(LocalDensity.current) {
val colours = LocalColor.current
var size by remember { mutableStateOf<IntSize?>(null) }
val textMeasurer = rememberTextMeasurer()
Expand All @@ -530,48 +568,91 @@ fun LineNumbersView(modifier: Modifier = Modifier, scrollState: ScrollState, tex
lastLineTops = lineTops

val lineNumDigits = lineTops?.let { "${it.lastIndex}".length } ?: 1
val width = rememberLast(lineNumDigits) {
val width = rememberLast(lineNumDigits, collapsableLines.isEmpty()) {
maxOf(textMeasurer.measure("8".repeat(lineNumDigits), textStyle, maxLines = 1).size.width.toDp(), 20.dp) +
4.dp + 8.dp
4.dp + (if (collapsableLines.isNotEmpty()) 24.dp else 0.dp) + 4.dp
}

val collapsableLinesMap = collapsableLines.associateBy { it.start }
val collapsedLines = collapsedLines.associateBy { it.first }.toSortedMap() // TODO optimize using range tree

Box(
modifier = modifier
.width(width)
.fillMaxHeight()
.clipToBounds()
.onGloballyPositioned { size = it.size }
.background(colours.backgroundLight)
.padding(top = 6.dp, end = 8.dp, start = 4.dp), // see AppTextField
.padding(top = 6.dp, start = 4.dp, end = 4.dp), // see AppTextField
) {
if (size != null && textLayoutResult != null && lineTops != null) {
val viewportTop = scrollState.value.toFloat()
val viewportBottom = viewportTop + size!!.height
log.d { "LineNumbersView before calculation" }
// 0-based line index
val firstLine = lineTops.binarySearchForInsertionPoint { if (it <= viewportTop) -1 else 1 } - 1
// include the partially visible line before the first line that is entirely visible
val firstLine = maxOf(0, lineTops.binarySearchForInsertionPoint { if (it >= viewportTop) 1 else -1 } - 1)
val lastLine = lineTops.binarySearchForInsertionPoint { if (it > viewportBottom) 1 else -1 }
log.v { "LineNumbersView $firstLine ~ <$lastLine / $viewportTop ~ $viewportBottom" }
log.v { "lineTops = $lineTops" }
log.v { "collapsedLines = $collapsedLines" }
log.d { "LineNumbersView after calculation" }
val lineHeight = textLayoutResult.getLineBottom(0) - textLayoutResult.getLineTop(0)
for (i in firstLine until minOf(lastLine, lineTops.size - 1)) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.height(lineHeight.toDp())
.offset(y = (lineTops[i] - viewportTop).toDp()),
) {
AppText(
text = "${i + 1}",
style = textStyle,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
color = colours.unimportant,
)

var ii: Int = firstLine
while (ii < minOf(lastLine, lineTops.size - 1)) {
val i: Int = ii // `ii` is passed by ref

if (i > firstLine && lineTops[i] - lineTops[i-1] < 1) {
// optimization: there is an instant that collapsedLines is empty but lineTops = [0, 0, ..., 0, 1234]
// skip drawing if there are duplicated lineTops
} else {
Row(
modifier = Modifier
.height(lineHeight.toDp())
.offset(y = (lineTops[i] - viewportTop).toDp()),
) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.weight(1f)
) {
AppText(
text = "${i + 1}",
style = textStyle,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
color = colours.unimportant,
)
}
if (collapsableLinesMap.contains(i)) {
AppImageButton(
resource = if (collapsedLines.containsKey(i)) "expand.svg" else "collapse.svg",
size = 12.dp,
innerPadding = PaddingValues(horizontal = 4.dp),
onClick = {
if (collapsedLines.containsKey(i)) {
onExpandLine(i)
} else {
onCollapseLine(i)
}
},
modifier = modifier
.width(24.dp)
.padding(start = 4.dp),
)
} else if (collapsableLines.isNotEmpty()) {
Spacer(modifier.width(24.dp))
}
}
}
collapsedLines.headMap(i + 1).forEach {
if (it.value.contains(i)) {
ii = maxOf(ii, it.value.last)
}
}
++ii
}
}
}
Expand Down
Loading

0 comments on commit f92826a

Please sign in to comment.