From 1a22c7612ee6dffd36c8af4ef5f6f15adbcd71bf Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 12 Feb 2024 01:11:42 +0800 Subject: [PATCH 1/2] update JSON prettifier style --- .../hellohttp/manager/PrettifierManager.kt | 3 +- .../hellohttp/parser/JsonParser.kt | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt index f6fc36d6..82d8b2f5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt @@ -2,6 +2,7 @@ package com.sunnychung.application.multiplatform.hellohttp.manager import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication +import com.sunnychung.application.multiplatform.hellohttp.parser.JsonParser class PrettifierManager { private val registrations: MutableSet = mutableSetOf() @@ -17,7 +18,7 @@ class PrettifierManager { prettifier = Prettifier( formatName = "JSON (Prettified)", prettify = { - jacksonObjectMapper().readTree(it).toPrettyString() + JsonParser(it).prettify() } ) ) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt new file mode 100644 index 00000000..4208d278 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt @@ -0,0 +1,69 @@ +package com.sunnychung.application.multiplatform.hellohttp.parser + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +class JsonParser(jsonBytes: ByteArray) { + val tree = jacksonObjectMapper().readTree(jsonBytes) + + constructor(json: String) : this(json.encodeToByteArray()) + + fun prettify(): String { + return buildString { + fun indent(level: Int) { + if (level > 0) { + append(" ".repeat(2 * level)) + } + } + + fun StringBuilder.transverse(node: JsonNode, indentLevel: Int) { + if (node.isArray) { + append('[') + if (node.size() <= 20 && node.all { it.isValueNode }) { + node.forEachIndexed { i, it -> + if (i > 0) { + append(", ") + } + transverse(it, 0) + } + } else { + append("\n") + node.forEachIndexed { i, it -> + if (i > 0) { + append(",\n") + } + indent(indentLevel + 1) + transverse(it, indentLevel + 1) + } + append("\n") + indent(indentLevel) + } + append(']') + } else if (node.isObject) { + if (node.isEmpty) { + append("{}") + } else { + append("{\n") + node.fields().withIndex().forEach { (i, it) -> + if (i > 0) { + append(",\n") + } + indent(indentLevel + 1) + append("\"${it.key}\": ") + transverse(it.value, indentLevel + 1) + } + append("\n") + indent(indentLevel) + append('}') + } + } else if (node.isValueNode) { + append(node.toPrettyString()) + } else { + throw RuntimeException("what is this? -- $node") + } + } + + transverse(tree, 0) + } + } +} From 32f0beef32c88ec530160464159a5ef27844e477 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 12 Feb 2024 15:55:00 +0800 Subject: [PATCH 2/2] add collapse/expand buttons to JSON response view --- .../hellohttp/manager/PrettifierManager.kt | 3 +- .../hellohttp/model/PrettifyResult.kt | 7 + .../hellohttp/parser/JsonParser.kt | 123 +++++++++++------- .../hellohttp/ux/AppImageButton.kt | 6 +- .../hellohttp/ux/CodeEditorView.kt | 123 +++++++++++++++--- .../hellohttp/ux/ResponseViewerView.kt | 29 +++-- .../hellohttp/ux/local/AppColor.kt | 3 + .../transformation/CollapseTransformation.kt | 106 +++++++++++++++ src/jvmMain/resources/image/collapse.svg | 7 + src/jvmMain/resources/image/expand.svg | 7 + ...CollapseTransformationOffsetMappingTest.kt | 30 +++++ 11 files changed, 362 insertions(+), 82 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/PrettifyResult.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt create mode 100644 src/jvmMain/resources/image/collapse.svg create mode 100644 src/jvmMain/resources/image/expand.svg create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/CollapseTransformationOffsetMappingTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt index 82d8b2f5..c1519323 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/manager/PrettifierManager.kt @@ -1,6 +1,7 @@ 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 @@ -42,7 +43,7 @@ class PrettifierManager { class Prettifier( val formatName: String, - val prettify: (ByteArray) -> String, + val prettify: (ByteArray) -> PrettifyResult, ) data class PrettifierRegistration( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/PrettifyResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/PrettifyResult.kt new file mode 100644 index 00000000..6ddfe3f9 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/PrettifyResult.kt @@ -0,0 +1,7 @@ +package com.sunnychung.application.multiplatform.hellohttp.model + +data class PrettifyResult( + val prettyString: String, + val collapsableLineRange: List = emptyList(), + val collapsableCharRange: List = emptyList(), +) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt index 4208d278..61ae9e34 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/parser/JsonParser.kt @@ -2,68 +2,97 @@ 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(): String { - return buildString { - fun indent(level: Int) { - if (level > 0) { - append(" ".repeat(2 * level)) + fun prettify(): PrettifyResult { + var lineIndex = 0 + val lineGroups = mutableListOf() + val startLineStack = mutableListOf() + val charGroups = mutableListOf() + val startCharStack = mutableListOf() + + 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) { - append('[') - if (node.size() <= 20 && node.all { it.isValueNode }) { - node.forEachIndexed { i, it -> - if (i > 0) { - append(", ") + 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) } - transverse(it, 0) + append(']') + lineGroups += startLineStack.removeLast() .. lineIndex + charGroups += startCharStack.removeLast() .. lastIndex } - } else { - append("\n") - node.forEachIndexed { i, it -> - if (i > 0) { - append(",\n") + } 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) } - indent(indentLevel + 1) - transverse(it, indentLevel + 1) + append("\n") + ++lineIndex + indent(indentLevel) + append('}') + lineGroups += startLineStack.removeLast() .. lineIndex + charGroups += startCharStack.removeLast() .. lastIndex } - append("\n") - indent(indentLevel) - } - append(']') - } else if (node.isObject) { - if (node.isEmpty) { - append("{}") + } else if (node.isValueNode) { + append(node.toPrettyString()) } else { - append("{\n") - node.fields().withIndex().forEach { (i, it) -> - if (i > 0) { - append(",\n") - } - indent(indentLevel + 1) - append("\"${it.key}\": ") - transverse(it.value, indentLevel + 1) - } - append("\n") - indent(indentLevel) - append('}') + throw RuntimeException("what is this? -- $node") } - } else if (node.isValueNode) { - append(node.toPrettyString()) - } else { - throw RuntimeException("what is this? -- $node") } - } - transverse(tree, 0) - } + transverse(tree, 0) + }, + collapsableLineRange = lineGroups, + collapsableCharRange = charGroups, + ) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppImageButton.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppImageButton.kt index 94bf76cf..41531fd0 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppImageButton.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppImageButton.kt @@ -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 @@ -14,11 +16,12 @@ 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) @@ -26,6 +29,7 @@ fun AppImageButton( it } } + .padding(innerPadding) AppImage( resource = resource, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 71bc4295..84d5d192 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -3,9 +3,12 @@ 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 @@ -13,6 +16,7 @@ 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 @@ -20,6 +24,7 @@ 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 @@ -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 @@ -75,6 +82,8 @@ fun CodeEditorView( isReadOnly: Boolean = false, text: String, onTextChange: ((String) -> Unit)? = null, + collapsableLines: List = emptyList(), + collapsableChars: List = emptyList(), textColor: Color = LocalColor.current.text, transformations: List = emptyList(), isEnableVariables: Boolean = false, @@ -115,6 +124,9 @@ fun CodeEditorView( cursorDelta = 0 } + var collapsedLines = rememberLast(newText) { mutableStateMapOf() } + var collapsedChars = rememberLast(newText) { mutableStateMapOf() } + log.d { "CodeEditorView recompose" } fun onPressEnterAddIndent() { @@ -252,6 +264,11 @@ fun CodeEditorView( ) } else { emptyList() + } + + if (isReadOnly) { + listOf(CollapseTransformation(themeColours, collapsedChars.values.toList())) + } else { + emptyList() } textLayoutResult?.let { tl -> @@ -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( @@ -510,7 +539,16 @@ data class SearchOptions( ) @Composable -fun LineNumbersView(modifier: Modifier = Modifier, scrollState: ScrollState, textLayoutResult: TextLayoutResult?, lineTops: List?) = with(LocalDensity.current) { +fun LineNumbersView( + modifier: Modifier = Modifier, + scrollState: ScrollState, + textLayoutResult: TextLayoutResult?, + lineTops: List?, + collapsableLines: List, + collapsedLines: List, + onCollapseLine: (Int) -> Unit, + onExpandLine: (Int) -> Unit, +) = with(LocalDensity.current) { val colours = LocalColor.current var size by remember { mutableStateOf(null) } val textMeasurer = rememberTextMeasurer() @@ -530,11 +568,14 @@ 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) @@ -542,36 +583,76 @@ fun LineNumbersView(modifier: Modifier = Modifier, scrollState: ScrollState, tex .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 } } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index 474327e7..2fe110ac 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -45,6 +45,7 @@ import com.sunnychung.application.multiplatform.hellohttp.manager.Prettifier import com.sunnychung.application.multiplatform.hellohttp.model.Certificate import com.sunnychung.application.multiplatform.hellohttp.model.ConnectionSecurityType import com.sunnychung.application.multiplatform.hellohttp.model.PayloadMessage +import com.sunnychung.application.multiplatform.hellohttp.model.PrettifyResult import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication import com.sunnychung.application.multiplatform.hellohttp.model.RawExchange import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse @@ -496,17 +497,21 @@ fun BodyViewerView( } isJsonPathError = hasError + val prettifyResult = try { + if (isRaw) { + selectedView.prettifier!!.prettify(contentToUse) + } else { + PrettifyResult(contentToUse.decodeToString()) + } + } catch (e: Throwable) { + PrettifyResult(contentToUse.decodeToString() ?: "") + } + CodeEditorView( isReadOnly = true, - text = try { - if (isRaw) { - selectedView.prettifier!!.prettify(contentToUse) - } else { - contentToUse.decodeToString() - } - } catch (e: Throwable) { - contentToUse.decodeToString() ?: "" - }, + text = prettifyResult.prettyString, + collapsableLines = prettifyResult.collapsableLineRange, + collapsableChars = prettifyResult.collapsableCharRange, transformations = if (selectedView.prettifier!!.formatName.contains("JSON")) { listOf(JsonSyntaxHighlightTransformation(colours = colours)) } else { @@ -558,7 +563,7 @@ fun ResponseBodyView(response: UserResponse) { emptyList() } .map { PrettifierDropDownValue(it.formatName, it) } + - PrettifierDropDownValue(ORIGINAL, Prettifier(ORIGINAL) { it.decodeToString() }) + PrettifierDropDownValue(ORIGINAL, Prettifier(ORIGINAL) { PrettifyResult(it.decodeToString()) }) } else { listOf(PrettifierDropDownValue(CLIENT_ERROR, null)) } @@ -599,11 +604,11 @@ fun ResponseStreamView(response: UserResponse) { val prettifiers = if ((response.isError && displayMessage == null) || displayMessage?.type == PayloadMessage.Type.Error) { listOf(PrettifierDropDownValue(CLIENT_ERROR, null)) } else if (displayMessage?.type in setOf(PayloadMessage.Type.Connected, PayloadMessage.Type.Disconnected)) { - listOf(PrettifierDropDownValue(ORIGINAL, Prettifier(ORIGINAL) { it.decodeToString() })) + listOf(PrettifierDropDownValue(ORIGINAL, Prettifier(ORIGINAL) { PrettifyResult(it.decodeToString()) })) } else { AppContext.PrettifierManager.allPrettifiers() .map { PrettifierDropDownValue(it.formatName, it) } + - PrettifierDropDownValue(ORIGINAL, Prettifier(ORIGINAL) { it.decodeToString() }) + PrettifierDropDownValue(ORIGINAL, Prettifier(ORIGINAL) { PrettifyResult(it.decodeToString()) }) } val detailData = displayMessage?.data diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppColor.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppColor.kt index 64507192..1dd34b4e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppColor.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppColor.kt @@ -16,6 +16,7 @@ data class AppColor( val backgroundHoverDroppable: Color, val backgroundInputFieldHighlight: Color, val backgroundInputFieldHighlightEmphasize: Color, + val backgroundCollapsed: Color, val primary: Color, val bright: Color, @@ -94,6 +95,7 @@ fun darkColorScheme(): AppColor = AppColor( backgroundHoverDroppable = Color(0f, 0f, 0.6f), backgroundInputFieldHighlight = Color(red = 0.3f, green = 0.3f, blue = 0.78f), backgroundInputFieldHighlightEmphasize = Color(red = 0.6f, green = 0.38f, blue = 0f), + backgroundCollapsed = Color(red = 0.4f, green = 0.4f, blue = 0.4f), primary = Color(red = 0.8f, green = 0.8f, blue = 1.0f), unimportant = Color(red = 0.45f, green = 0.45f, blue = 0.65f), @@ -164,6 +166,7 @@ fun lightColorScheme(): AppColor = AppColor( backgroundHoverDroppable = Color(0f, 0f, 0.4f), backgroundInputFieldHighlight = Color(red = 0.6f, green = 0.6f, blue = 0.8f), backgroundInputFieldHighlightEmphasize = Color(red = 0.8f, green = 0.8f, blue = 0.3f), + backgroundCollapsed = Color(red = 0.6f, green = 0.6f, blue = 0.6f), primary = Color(red = 0.2f, green = 0.2f, blue = 0.3f), unimportant = Color(red = 0.4f, green = 0.4f, blue = 0.5f), diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt new file mode 100644 index 00000000..d3dafe34 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt @@ -0,0 +1,106 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor + +/** + * This class assumes offset before this filter is not modified + */ +class CollapseTransformation(colours: AppColor, collapsedCharRanges: List) : VisualTransformation { + val collapsedStyle = SpanStyle(background = colours.backgroundCollapsed) + + // TODO optimize to use binary tree + val collapsedCharRanges = collapsedCharRanges.sortedBy { it.first } + + override fun filter(text: AnnotatedString): TransformedText { + var lastIndex = 0 + val modifiedText = buildAnnotatedString { + collapsedCharRanges.forEach { + if (it.first < lastIndex) return@forEach + append(text.subSequence(lastIndex .. it.start)) + + append(" ") + append(AnnotatedString("...", collapsedStyle)) + append(" ") + + append(text.subSequence(it.last .. it.last)) + + lastIndex = it.last + 1 + } + append(text.subSequence(lastIndex, text.length)) + } + + return TransformedText(modifiedText, CollapseTransformationOffsetMapping(collapsedCharRanges)) + } +} + +/** + * 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 + * a b c { d e f g h i j } k l { m n } o p + * collapsedCharRanges [3, 11] [14, 17] + * transformedText a b c { . . . } k l { . . . } o p + * originalToTransformed 0 1 2 3 9 9 9 9 9 9 9 9 10 11 12 18 18 18 18 19 20 + * transformedToOriginal 0 1 2 3 11 11 12 13 14 17 17 18 19 + */ +class CollapseTransformationOffsetMapping(collapsedCharRanges: List) : OffsetMapping { + // TODO optimize to use binary tree + private val collapsedCharRanges = collapsedCharRanges.sortedBy { it.start } + override fun originalToTransformed(offset: Int): Int { + if (collapsedCharRanges.isEmpty() || offset <= collapsedCharRanges.first().first) { + return offset + } + + var accumulatedEatenChars = 0 + var lastIndex = 0 + for (it in collapsedCharRanges) { + if (it.first > offset) break + if (it.first < lastIndex) continue + if (offset > it.first) { + if(offset <= it.last) { + val newOffset = it.first + " ... ".length + 1 - accumulatedEatenChars +// log.v { "newOffset[$offset] = $newOffset" } + return newOffset + } else { + accumulatedEatenChars += (it.last) - (it.first + 1) - " ... ".length +// log.v { "accumulatedEatenChars = $accumulatedEatenChars" } + } + } + lastIndex = it.last + 1 + } + val newOffset = offset - accumulatedEatenChars +// log.v { "new offset[$offset] = $newOffset" } + return newOffset + } + + override fun transformedToOriginal(offset: Int): Int { + if (collapsedCharRanges.isEmpty() || offset < collapsedCharRanges.first().first + 1) { + return offset + } + + var accumulatedEatenChars = 0 + var lastIndex = 0 + for (it in collapsedCharRanges) { + if (it.first > offset + accumulatedEatenChars) break + if (it.first < lastIndex) continue + if (offset + accumulatedEatenChars >= it.first + 1) { + if (offset + accumulatedEatenChars <= it.first + " ... }".length) { +// log.v { "toOrg[$offset] = ${it.last}" } + return it.last + } else { + accumulatedEatenChars += it.last - (it.first + 1) - " ... ".length +// log.v { "accumulatedEatenChars = $accumulatedEatenChars" } + } + } + lastIndex = it.last + 1 + } + val oldOffset = offset + accumulatedEatenChars +// log.v { "toOrg [$offset] = $oldOffset" } + return oldOffset + } +} diff --git a/src/jvmMain/resources/image/collapse.svg b/src/jvmMain/resources/image/collapse.svg new file mode 100644 index 00000000..1d9d956d --- /dev/null +++ b/src/jvmMain/resources/image/collapse.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/jvmMain/resources/image/expand.svg b/src/jvmMain/resources/image/expand.svg new file mode 100644 index 00000000..14661432 --- /dev/null +++ b/src/jvmMain/resources/image/expand.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/CollapseTransformationOffsetMappingTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/CollapseTransformationOffsetMappingTest.kt new file mode 100644 index 00000000..d3231792 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/CollapseTransformationOffsetMappingTest.kt @@ -0,0 +1,30 @@ +package com.sunnychung.application.multiplatform.hellohttp.test + +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.CollapseTransformationOffsetMapping +import kotlin.test.Test +import kotlin.test.assertEquals + +class CollapseTransformationOffsetMappingTest { + /** + * 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 + * a b c { d e f g h i j } k l { m n } o p + * collapsedCharRanges [3, 11] [14, 17] + * transformedText a b c { . . . } k l { . . . } o p + * originalToTransformed 0 1 2 3 9 9 9 9 9 9 9 9 10 11 12 18 18 18 19 20 + * transformedToOriginal 0 1 2 3 11 11 12 13 14 17 17 18 19 + */ + @Test + fun test() { + val mapping = CollapseTransformationOffsetMapping( + listOf(3..11, 14..17) + ) + assertEquals( + listOf(0, 1, 2, 3, 9, 9, 9, 9, 9, 9, 9, 9, 10, 11, 12, 18, 18, 18, 19, 20), + (0..19).map { mapping.originalToTransformed(it) } + ) + assertEquals( + listOf(0, 1, 2, 3, 11, 11, 11, 11, 11, 11, 12, 13, 14, 17, 17, 17, 17, 17, 17, 18, 19), + (0..20).map { mapping.transformedToOriginal(it) } + ) + } +}