diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a3999324..08667b88e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -97,7 +97,6 @@ turbine = "1.0.0" vanniktech-publish = "0.32.0" [plugins] - kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md index 77cfc65ae..9f5fd1896 100644 --- a/workflow-trace-viewer/README.md +++ b/workflow-trace-viewer/README.md @@ -1,6 +1,8 @@ # Workflow Trace Viewer -A Compose for Desktop app that can be used to view Workflow traces. +A Compose for Desktop application that visualizes and debugs Workflow execution traces. This tool +helps developers understand the hierarchical structure and execution flow of their Workflow +applications by providing both file-based and live streaming trace visualization. ## Running @@ -10,20 +12,75 @@ It can be run via Gradle using: ./gradlew :workflow-trace-viewer:run ``` -By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data. +## Usage Guide -By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. A connection can only happen once. If there needs to be rerecording of the trace, the emulator must first be restarted, and then the app must be restarted as well. This is due to the fact that any open socket will consume all render pass data, meaning there is nothing to read from the emulator. +By default, the app will be in file parsing mode, where you are able to select a previously recorded +workflow trace file for it to visualize the data. Once the workflow tree is rendered +in [File](#file-mode) or [Live](#live-mode) mode, you can switch frames ( +see [Terminology](#Terminology)) to see different events that fired. All nodes are color coded based +on what had happened during this frame, and a text diff will show the specific changes. You can open +the right node panel and left click a box get a more detailed view of the specific node, or right +click to expand/collapse a specific node's children. -It is ***important*** to run the emulator first before toggling to live mode. +Demo + +#### File Mode + +Once a file of the live data is saved, it can easily be uploaded to retrace the steps taken during +the live session. Currently, text/json files that are saved from recordings only contain raw data, +meaning it is simply a list of lists of node renderings. + +File Mode + +#### Live Mode + +By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly +pulled from the emulator into the visualizer. To do so: + +1. Start the app (on any device) +2. Start the app, and toggle the switch to enter Live mode +3. Select the desired device + +Once in Live mode, frames will appear as you interact with the app. You may also save the current +data into a file saved in `~/Downloads` to be used later (this action will take some time, so it may +not appear immediately) + +Render pass data is passively stored in a buffer before being sent to the visualizer, so you do not +need to immediately open/run the app to "catch" everything. However, since the the buffer has +limited size, it's strongly recommended to avoid interacting with the app — beyond starting it — +before Live mode has been triggered; this helps to avoid losing data. + +Live Mode + +### Note + +A connection can only happen once. There is currently no support for a recording of the trace data +due to the fact that an open socket will consume all render pass data when a connection begins. To +restart the recording: + +1. (optional) Save the current trace +2. Switch out of Live mode +3. Restart the app +4. Switch back to Live mode, and the ### Terminology -**Trace**: A trace is a file — made up of frames — that contains the execution history of a Workflow. It includes information about render passes, how states have changed within workflows, and the specific props being passed through. The data collected to generate these should be in chronological order, and allows developers to step through the process easily. +`Trace`: A trace is a file — made up of frames — that contains the execution history of a Workflow. +It includes information about render passes, how states have changed within workflows, and the +specific props being passed through. -**Frame**: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains relevant information about the changes in workflow states and how props are passed throughout. +`Frame`: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains +relevant information about the changes in workflow states and how props are passed throughout. -- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are idiomatic to the Workflow library. +- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are + idiomatic to the Workflow library. ### External Libraries -[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations on Kotlin and KMP projects. It's purpose in this app is to allow developers to upload their own json trace files. The motivation for its use is to quickly implement a file picker. +[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations +on Kotlin and KMP projects. This simplified the development process of allowing file selection + +## Future + +This app can be integrated into the process of anyone working with Workflow, so it's highly +encouraged for anyone to make improvements that makes their life a little easier using this app. diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api index 377827f5c..bc0615ea8 100644 --- a/workflow-trace-viewer/api/workflow-trace-viewer.api +++ b/workflow-trace-viewer/api/workflow-trace-viewer.api @@ -13,24 +13,23 @@ public final class com/squareup/workflow1/traceviewer/MainKt { public final class com/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt { public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; - public static field lambda-2 Lkotlin/jvm/functions/Function3; public fun ()V public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-2$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3; } -public final class com/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt { - public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; +public final class com/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$SearchBoxKt { + public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$SearchBoxKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3; -} - -public final class com/squareup/workflow1/traceviewer/util/JsonParserKt { - public static final field ROOT_ID Ljava/lang/String; + public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function2; } -public final class com/squareup/workflow1/traceviewer/util/UploadFileKt { - public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +public final class com/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$UploadFileKt { + public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$UploadFileKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3; } diff --git a/workflow-trace-viewer/build.gradle.kts b/workflow-trace-viewer/build.gradle.kts index cee4e4e65..6bfd41a3e 100644 --- a/workflow-trace-viewer/build.gradle.kts +++ b/workflow-trace-viewer/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) + implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) @@ -25,6 +26,7 @@ kotlin { implementation(compose.materialIconsExtended) implementation(libs.squareup.moshi.kotlin) implementation(libs.filekit.dialogs.compose) + implementation(libs.java.diff.utils) } } jvmTest { diff --git a/workflow-trace-viewer/docs/demo.gif b/workflow-trace-viewer/docs/demo.gif new file mode 100644 index 000000000..fef58817d Binary files /dev/null and b/workflow-trace-viewer/docs/demo.gif differ diff --git a/workflow-trace-viewer/docs/file_mode.gif b/workflow-trace-viewer/docs/file_mode.gif new file mode 100644 index 000000000..283f7f3da Binary files /dev/null and b/workflow-trace-viewer/docs/file_mode.gif differ diff --git a/workflow-trace-viewer/docs/live_mode.gif b/workflow-trace-viewer/docs/live_mode.gif new file mode 100644 index 000000000..b9308f2a8 Binary files /dev/null and b/workflow-trace-viewer/docs/live_mode.gif differ diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 968d8108f..e2d5c3b93 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -1,27 +1,39 @@ package com.squareup.workflow1.traceviewer +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition.PlatformDefault.x import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate -import com.squareup.workflow1.traceviewer.ui.FrameSelectTab import com.squareup.workflow1.traceviewer.ui.RightInfoPanel -import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch -import com.squareup.workflow1.traceviewer.util.RenderTrace +import com.squareup.workflow1.traceviewer.ui.control.DisplayDevices +import com.squareup.workflow1.traceviewer.ui.control.FileDump +import com.squareup.workflow1.traceviewer.ui.control.FrameNavigator +import com.squareup.workflow1.traceviewer.ui.control.SearchBox +import com.squareup.workflow1.traceviewer.ui.control.TraceModeToggleSwitch +import com.squareup.workflow1.traceviewer.ui.control.UploadFile import com.squareup.workflow1.traceviewer.util.SandboxBackground -import com.squareup.workflow1.traceviewer.util.UploadFile +import com.squareup.workflow1.traceviewer.util.parser.RenderTrace import io.github.vinceglb.filekit.PlatformFile /** @@ -31,14 +43,20 @@ import io.github.vinceglb.filekit.PlatformFile internal fun App( modifier: Modifier = Modifier ) { + var appWindowSize by remember { mutableStateOf(IntSize(0, 0)) } var selectedNode by remember { mutableStateOf(null) } - val workflowFrames = remember { mutableStateListOf() } + var frameSize by remember { mutableIntStateOf(0) } + var rawRenderPass by remember { mutableStateOf("") } var frameIndex by remember { mutableIntStateOf(0) } val sandboxState = remember { SandboxState() } + val nodeLocations = remember { mutableListOf>() } // Default to File mode, and can be toggled to be in Live mode. + var active by remember { mutableStateOf(false) } var traceMode by remember { mutableStateOf(TraceMode.File(null)) } var selectedTraceFile by remember { mutableStateOf(null) } + // frameIndex is set to -1 when app is in Live Mode, so we increment it by one to avoid off-by-one errors + val frameInd = if (traceMode is TraceMode.Live) frameIndex + 1 else frameIndex LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { @@ -47,47 +65,81 @@ internal fun App( } Box( - modifier = modifier + modifier = modifier.onSizeChanged { + appWindowSize = it + } ) { fun resetStates() { selectedTraceFile = null selectedNode = null frameIndex = 0 - workflowFrames.clear() + frameSize = 0 + rawRenderPass = "" + active = false + nodeLocations.clear() } // Main content SandboxBackground( + appWindowSize = appWindowSize, sandboxState = sandboxState, ) { // if there is not a file selected and trace mode is live, then don't render anything. - val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null - val readyForLiveTrace = traceMode is TraceMode.Live + val readyForFileTrace = TraceMode.validateFileMode(traceMode) + val readyForLiveTrace = TraceMode.validateLiveMode(traceMode) + if (readyForFileTrace || readyForLiveTrace) { + active = true RenderTrace( traceSource = traceMode, frameInd = frameIndex, - onFileParse = { workflowFrames.addAll(it) }, - onNodeSelect = { node, prevNode -> - selectedNode = NodeUpdate(node, prevNode) - }, - onNewFrame = { frameIndex += 1 } + onFileParse = { frameSize += it }, + onNodeSelect = { selectedNode = it }, + onNewFrame = { frameIndex += 1 }, + onNewData = { rawRenderPass += "$it," }, + storeNodeLocation = { node, loc -> nodeLocations[frameInd] += (node to loc) } ) } } - FrameSelectTab( - frames = workflowFrames, - currentIndex = frameIndex, - onIndexChange = { frameIndex = it }, - modifier = Modifier.align(Alignment.TopCenter) - ) - - RightInfoPanel( - selectedNode = selectedNode, + Column( modifier = Modifier - .align(Alignment.TopEnd) - ) + .align(Alignment.TopCenter) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (active) { + // Since we can jump from frame to frame, we fill in the map during each recomposition + if (nodeLocations.getOrNull(frameInd) == null) { + // frameSize has not been updated yet, so on the first frame, frameSize = nodeLocations.size = 0, + // and it will append a new map + while (nodeLocations.size <= frameSize) { + nodeLocations += mutableStateMapOf() + } + } + + val frameNodeLocations = nodeLocations[frameInd] + SearchBox( + nodes = frameNodeLocations.keys.toList(), + onSearch = { name -> + sandboxState.scale = 1f + val node = frameNodeLocations.keys.first { it.name == name } + val newX = (sandboxState.offset.x - frameNodeLocations.getValue(node).x + + appWindowSize.width / 2) + val newY = (sandboxState.offset.y - frameNodeLocations.getValue(node).y + + appWindowSize.height / 2) + sandboxState.offset = Offset(x = newX, y = newY) + }, + ) + + FrameNavigator( + totalFrames = frameSize, + currentIndex = frameIndex, + onIndexChange = { frameIndex = it }, + ) + } + } TraceModeToggleSwitch( onToggle = { @@ -96,13 +148,12 @@ internal fun App( frameIndex = 0 TraceMode.File(null) } else { - // TODO: TraceRecorder needs to be able to take in multiple clients if this is the case /* We set the frame to -1 here since we always increment it during Live mode as the list of frames get populated, so we avoid off by one when indexing into the frames. */ frameIndex = -1 - TraceMode.Live + TraceMode.Live() } }, traceMode = traceMode, @@ -120,6 +171,26 @@ internal fun App( modifier = Modifier.align(Alignment.BottomStart) ) } + + if (traceMode is TraceMode.Live && (traceMode as TraceMode.Live).device == null) { + DisplayDevices( + onDeviceSelect = { selectedDevice -> + traceMode = TraceMode.Live(selectedDevice) + }, + devices = listDevices(), + modifier = Modifier.align(Alignment.Center) + ) + + FileDump( + trace = rawRenderPass, + modifier = Modifier.align(Alignment.BottomStart) + ) + } + + RightInfoPanel( + selectedNode = selectedNode, + modifier = Modifier.align(Alignment.TopEnd) + ) } } @@ -134,5 +205,25 @@ internal class SandboxState { internal sealed interface TraceMode { data class File(val file: PlatformFile?) : TraceMode - data object Live : TraceMode + data class Live(val device: String? = null) : TraceMode + + companion object { + fun validateLiveMode(traceMode: TraceMode): Boolean { + return traceMode is Live && traceMode.device != null + } + + fun validateFileMode(traceMode: TraceMode): Boolean { + return traceMode is File && traceMode.file != null + } + } +} + +/** + * Allows users to select from multiple devices that are currently running. + */ +private fun listDevices(): List { + val process = ProcessBuilder("adb", "devices", "-l").start() + process.waitFor() + // We drop the header "List of devices attached" + return process.inputStream.bufferedReader().readLines().drop(1).dropLast(1) } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt index 30f000e28..d7e3001b0 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt @@ -31,6 +31,18 @@ internal data class Node( override fun hashCode(): Int { return id.hashCode() } + + companion object { + val nodeFields: List = listOf("Props", "State") + } +} + +internal fun Node.getNodeData(field: String): String { + return when (field.lowercase()) { + "props" -> props + "state" -> state + else -> throw IllegalArgumentException("Unknown field: $field") + } } internal fun Node.addChild(child: Node): Node { diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt index c97557757..c21c21833 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt @@ -1,12 +1,38 @@ package com.squareup.workflow1.traceviewer.model +import androidx.compose.ui.graphics.Color + /** * Represents the difference between the current and previous state of a node in the workflow trace. - * This will be what is passed as a state between UI to display the diff. + * This will be what is passed as a state between UI to display the diff. The states all have an + * associated color * - * If it's the first node in the frame, [previous] will be null and there is no difference to show. + * If it's the first node in the frame, [past] will be null and there is no difference to show. */ -internal class NodeUpdate( +internal data class NodeUpdate( val current: Node, - val previous: Node?, -) + val past: Node?, + val state: NodeState +) { + companion object { + fun create(current: Node, past: Node?, isAffected: Boolean): NodeUpdate { + val state = when { + !isAffected -> NodeState.UNCHANGED + past == null -> NodeState.NEW + current.props != past.props -> NodeState.PROPS_CHANGED + current.state != past.state -> NodeState.STATE_CHANGED + else -> NodeState.CHILDREN_CHANGED + } + + return NodeUpdate(current, past, state) + } + } +} + +internal enum class NodeState(val color: Color) { + NEW(Color(0x804CAF50)), // green + STATE_CHANGED(Color(0xFFE57373)), // red + PROPS_CHANGED(Color(0xFFFF8A65)), // orange + CHILDREN_CHANGED(Color(0x802196F3)), // blue + UNCHANGED(Color.LightGray.copy(alpha = 0.3f)), +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt deleted file mode 100644 index efe6f31c7..000000000 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.squareup.workflow1.traceviewer.ui - -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp -import com.squareup.workflow1.traceviewer.model.Node -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch - -/** - * A trace tab selector that allows devs to switch between different states within the provided trace. - */ -@Composable -internal fun FrameSelectTab( - frames: List, - currentIndex: Int, - onIndexChange: (Int) -> Unit, - modifier: Modifier = Modifier -) { - val lazyListState = rememberLazyListState() - if (currentIndex >= 0) { - LaunchedEffect(currentIndex) { - lazyListState.animateScrollToItem(currentIndex) - } - } - - Surface( - modifier = modifier, - color = Color.White, - ) { - LazyRow( - state = lazyListState, - modifier = Modifier - .padding(8.dp) - .pointerInput(Unit) { - coroutineScope { - awaitEachGesture { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Scroll) { - val scrollDeltaY = event.changes.first().scrollDelta.y - launch { - lazyListState.scroll(MutatePriority.Default) { - scrollBy(scrollDeltaY * 10f) - } - } - } - } - } - }, - ) { - items(frames.size) { index -> - Text( - text = "Frame ${index + 1}", - color = if (index == currentIndex) Color.Black else Color.LightGray, - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable { onIndexChange(index) } - .padding(10.dp) - ) - } - } - } -} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt index bcf09fddc..b71767e26 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -27,14 +28,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate -import kotlin.reflect.full.memberProperties +import com.squareup.workflow1.traceviewer.model.getNodeData +import com.squareup.workflow1.traceviewer.util.parser.computeAnnotatedDiff /** * A panel that displays information about the selected workflow node. @@ -55,6 +58,9 @@ internal fun RightInfoPanel( IconButton( onClick = { panelOpen = !panelOpen }, modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color.White) .padding(8.dp) .size(40.dp) .align(Alignment.Top) @@ -98,19 +104,33 @@ private fun NodePanelDetails( } item { Text( - text = "Workflow Details", - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(top = 8.dp, bottom = 8.dp) + text = "${node.current.parent} (ID: ${node.current.parentId})", + style = MaterialTheme.typography.subtitle2, + color = Color.Gray, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = "↳", + style = MaterialTheme.typography.subtitle1, + color = Color.Gray, + modifier = Modifier.padding(start = 8.dp) + ) + Text( + text = "${node.current.name} (ID: ${node.current.id})", + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(8.dp), + textAlign = TextAlign.Center ) } - val fields = Node::class.memberProperties + val fields = Node.nodeFields for (field in fields) { - val currVal = field.get(node.current).toString() - val pastVal = if (node.previous != null) field.get(node.previous).toString() else null + val currVal = node.current.getNodeData(field) + val pastVal = if (node.past != null) node.past.getNodeData(field) else null item { DetailCard( - label = field.name, + label = field, currValue = currVal, pastValue = pastVal ) @@ -149,19 +169,39 @@ private fun DetailCard( text = label, style = MaterialTheme.typography.h6, color = Color.Black, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Bold, ) if (!open) { return@Card } - Spacer(modifier = Modifier.height(4.dp)) if (pastValue != null) { Column { Text( - text = "Before:", - style = TextStyle(fontStyle = FontStyle.Italic), - color = Color.Black, + text = "Changes", + style = MaterialTheme.typography.subtitle1, + color = Color.Gray, + fontWeight = FontWeight.Medium + ) + Text( + text = computeAnnotatedDiff(pastValue, currValue), + style = MaterialTheme.typography.body2, + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + maxLines = 1, + overflow = TextOverflow.Clip + ) + + Text( + text = "Before", + style = MaterialTheme.typography.subtitle1, + color = Color.Gray, fontWeight = FontWeight.Medium ) Text( @@ -169,11 +209,13 @@ private fun DetailCard( style = MaterialTheme.typography.body2, color = Color.Black ) - Spacer(modifier = Modifier.height(8.dp)) + + Spacer(modifier = Modifier.height(16.dp)) + Text( - text = "After:", - style = TextStyle(fontStyle = FontStyle.Italic), - color = Color.Black, + text = "After", + style = MaterialTheme.typography.subtitle1, + color = Color.Gray, fontWeight = FontWeight.Medium ) Text( diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index d7bf164fd..e317c33c9 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -2,25 +2,41 @@ package com.squareup.workflow1.traceviewer.ui import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState 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.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.model.NodeState +import com.squareup.workflow1.traceviewer.model.NodeUpdate /** * Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree @@ -32,17 +48,20 @@ import com.squareup.workflow1.traceviewer.model.Node @Composable internal fun DrawTree( node: Node, - previousNode: Node?, + previousFrameNode: Node?, affectedNodes: Set, expandedNodes: MutableMap, - onNodeSelect: (Node, Node?) -> Unit, + onNodeSelect: (NodeUpdate) -> Unit, + storeNodeLocation: (Node, Offset) -> Unit, modifier: Modifier = Modifier, ) { Column( modifier - .padding(5.dp) - .border(1.dp, Color.Black) - .fillMaxSize(), + .padding(6.dp) + .fillMaxSize() + .then( + if (node.children.isNotEmpty()) Modifier.border(3.dp, Color.Black) else Modifier + ), horizontalAlignment = Alignment.CenterHorizontally, ) { val isAffected = affectedNodes.contains(node) @@ -53,33 +72,209 @@ internal fun DrawTree( val isExpanded = expandedNodes[node.id] == true DrawNode( - node, - previousNode, - isAffected, - isExpanded, - onNodeSelect, - onExpandToggle = { expandedNodes[node.id] = !expandedNodes[node.id]!! } + node = node, + nodePast = previousFrameNode, + isAffected = isAffected, + isExpanded = isExpanded, + onNodeSelect = onNodeSelect, + onExpandToggle = { expandedNodes[node.id] = !expandedNodes.getValue(node.id) }, + storeNodeLocation = storeNodeLocation ) - // Draws the node's children recursively. if (isExpanded) { + val (affectedChildren, unaffectedChildren) = node.children.values + .partition { affectedNodes.contains(it) } + + UnaffectedChildrenGroup( + node = node, + children = unaffectedChildren, + previousFrameNode = previousFrameNode, + affectedNodes = affectedNodes, + expandedNodes = expandedNodes, + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation + ) + + AffectedChildrenGroup( + children = affectedChildren, + previousFrameNode = previousFrameNode, + affectedNodes = affectedNodes, + expandedNodes = expandedNodes, + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation + ) + } + } +} + +/** + * Draws the group of unaffected children, which can be open and closed to expand/collapse them. + * + * If an unaffected children also has other children, it cannot be opened since the this group + * treats all nodes as one entity. The right click for the whole group overrides the right click for + * the individual nodes. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun UnaffectedChildrenGroup( + node: Node, + children: List, + previousFrameNode: Node?, + affectedNodes: Set, + expandedNodes: MutableMap, + onNodeSelect: (NodeUpdate) -> Unit, + storeNodeLocation: (Node, Offset) -> Unit +) { + if (children.isEmpty()) return + + val groupKey = "${node.id}_unaffected_group" + DisposableEffect(Unit) { + expandedNodes[groupKey] = false + onDispose {} + } + val isGroupExpanded = expandedNodes[groupKey] == true + + Box( + modifier = Modifier + .onPointerEvent(PointerEventType.Press) { + if (it.buttons.isSecondaryPressed) { + expandedNodes[groupKey] = !isGroupExpanded + } + } + ) { + if (!isGroupExpanded) { + Column( + modifier = Modifier + .background(Color.LightGray.copy(alpha = 0.3f), shape = RoundedCornerShape(4.dp)) + .border(1.dp, Color.Gray) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "${node.name}'s", color = Color.DarkGray) + Text( + text = "${children.size} unaffected children", + color = Color.DarkGray, + fontSize = 12.sp + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DrawChildrenInGroups( + children = children, + previousFrameNode = previousFrameNode, + affectedNodes = affectedNodes, + expandedNodes = expandedNodes, + unaffected = true, + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation + ) + } + } + } +} + +/** + * Draws the group of affected children + */ +@Composable +private fun AffectedChildrenGroup( + children: List, + previousFrameNode: Node?, + affectedNodes: Set, + expandedNodes: MutableMap, + onNodeSelect: (NodeUpdate) -> Unit, + storeNodeLocation: (Node, Offset) -> Unit +) { + if (children.isEmpty()) return + + DrawChildrenInGroups( + children = children, + previousFrameNode = previousFrameNode, + affectedNodes = affectedNodes, + expandedNodes = expandedNodes, + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation + ) +} + +/** + * Draws the children in a grid manner, to avoid horizontal clutter and make better use of space. + * + * Unaffected children group would call this with `unaffected = true`, which means that simple/nested + * nodes don't matter since we can't open nested ones, so we just simply group in 5's + */ +@Composable +private fun DrawChildrenInGroups( + children: List, + previousFrameNode: Node?, + affectedNodes: Set, + expandedNodes: MutableMap, + onNodeSelect: (NodeUpdate) -> Unit, + storeNodeLocation: (Node, Offset) -> Unit, + unaffected: Boolean = false, +) { + // Split children into those with children (nested) and those without + var (nestedChildren, simpleChildren) = children.partition { it.children.isNotEmpty() } + + // Just reset the lists so we chunk everything in the unaffected group + if (unaffected) { + nestedChildren = emptyList() + simpleChildren = children + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Draw simple children in a grid at the top + if (simpleChildren.isNotEmpty()) { + val groupedSimpleChildren = simpleChildren.chunked(5) + + groupedSimpleChildren.forEach { group -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Top + ) { + group.forEach { childNode -> + DrawTree( + node = childNode, + previousFrameNode = previousFrameNode?.children?.get(childNode.id), + affectedNodes = affectedNodes, + expandedNodes = expandedNodes, + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation + ) + } + } + } + } + + // Draw nested children in a single row at the bottom + if (nestedChildren.isNotEmpty()) { Row( - horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.Top ) { - /* - We pair up the current node's children with previous frame's children. - In the edge case that the current frame has additional children compared to the previous - frame, we replace with null and will check before next recursive call. - */ - node.children.forEach { (index, childNode) -> - val prevChildNode = previousNode?.children?.get(index) + nestedChildren.forEach { childNode -> DrawTree( node = childNode, - previousNode = prevChildNode, + previousFrameNode = previousFrameNode?.children?.get(childNode.id), affectedNodes = affectedNodes, expandedNodes = expandedNodes, - onNodeSelect = onNodeSelect + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation ) } } @@ -94,35 +289,89 @@ internal fun DrawTree( @Composable private fun DrawNode( node: Node, - previousNode: Node?, + nodePast: Node?, isAffected: Boolean, isExpanded: Boolean, - onNodeSelect: (Node, Node?) -> Unit, + onNodeSelect: (NodeUpdate) -> Unit, onExpandToggle: (Node) -> Unit, + storeNodeLocation: (Node, Offset) -> Unit ) { - Box( - modifier = Modifier - .background(if (isAffected) Color.Green else Color.Transparent) - .onPointerEvent(PointerEventType.Press) { - if (it.buttons.isPrimaryPressed) { - onNodeSelect(node, previousNode) - } else if (it.buttons.isSecondaryPressed) { - onExpandToggle(node) + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val nodeUpdate = NodeUpdate.create( + current = node, + past = nodePast, + isAffected = isAffected + ) + + Box { + Box( + modifier = Modifier + .hoverable(interactionSource) + .background(nodeUpdate.state.color) + .clickable { + onNodeSelect(nodeUpdate) } - } - .padding(10.dp) - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + .onPointerEvent(PointerEventType.Press) { + if (it.buttons.isSecondaryPressed) { + onExpandToggle(node) + } + } + .padding(16.dp) + .onGloballyPositioned { coords -> + val offsetToTopLeft = coords.positionInRoot() + val offsetToCenter = Offset( + x = offsetToTopLeft.x + coords.size.width / 2, + y = offsetToTopLeft.y + coords.size.height / 2 + ) + storeNodeLocation(node, offsetToCenter) + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.wrapContentSize() ) { - if (node.children.isNotEmpty()) { - Text(text = if (isExpanded) "▼" else "▶") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (node.children.isNotEmpty()) { + Text(text = if (isExpanded) "▼" else "▶") + } + Text(text = node.name) } - Text(text = node.name) + Text(text = "ID: ${node.id}") } - Text(text = "ID: ${node.id}") + } + + if (isHovered) { + NodeTooltip( + nodeState = nodeUpdate.state, + modifier = Modifier + .align(Alignment.TopEnd) + .background(nodeUpdate.state.color) + ) } } } + +/** + * A tooltip that appears on hover showing the node state type + */ +@Composable +private fun NodeTooltip( + nodeState: NodeState, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier + .wrapContentSize() + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.3f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + text = nodeState.name, + color = Color.White, + fontSize = 12.sp + ) +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/DisplayDevices.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/DisplayDevices.kt new file mode 100644 index 000000000..b28d0fb50 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/DisplayDevices.kt @@ -0,0 +1,67 @@ +package com.squareup.workflow1.traceviewer.ui.control + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Only give back the specific emulator device, i.e. "emulator-5554" + */ +private val emulatorRegex = Regex("""\bemulator-\d+\b""") + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun DisplayDevices( + onDeviceSelect: (String) -> Unit, + devices: List, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (devices.isEmpty()) { + Text( + text = "No device available. Boot up a new device and restart the visualizer", + modifier = Modifier.align(Alignment.Center) + ) + return@Box + } + + Column { + devices.forEach { device -> + key(device) { + Card( + onClick = { + emulatorRegex.find(device)?.value?.let { emulator -> + onDeviceSelect(emulator) + } + }, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, Color.Gray), + modifier = Modifier.padding(4.dp), + elevation = 2.dp + ) { + Text( + text = device, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + } + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FileDump.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FileDump.kt new file mode 100644 index 000000000..73c30a174 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FileDump.kt @@ -0,0 +1,61 @@ +package com.squareup.workflow1.traceviewer.ui.control + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults.buttonColors +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.graphics.Color +import androidx.compose.ui.unit.dp +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.buffer +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Composable +internal fun FileDump( + trace: String, + modifier: Modifier = Modifier +) { + var clicked by remember { mutableStateOf(false) } + Button( + modifier = modifier.padding(16.dp), + shape = CircleShape, + colors = buttonColors(Color.Black), + onClick = { + clicked = true + writeToFile(trace) + } + ) { + val text = if (clicked) { + "Trace saved to Downloads" + } else { + "Save trace to file" + } + Text( + text = text, + color = Color.White + ) + } +} + +private fun writeToFile(trace: String) { + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + val home = System.getProperty("user.home") + val path = "$home/Downloads/workflow-trace_$timestamp.json".toPath() + + FileSystem.SYSTEM.sink(path).use { sink -> + sink.buffer().use { bufferedSink -> + bufferedSink.writeUtf8("[") + bufferedSink.writeUtf8(trace.dropLast(1)) // Fenceposting final comma + bufferedSink.writeUtf8("]") + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FrameNavigator.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FrameNavigator.kt new file mode 100644 index 000000000..305fa9ff9 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FrameNavigator.kt @@ -0,0 +1,141 @@ +package com.squareup.workflow1.traceviewer.ui.control + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ArrowDropDown +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A frame navigator that shows the current frame number with dropdown selection + * and left/right navigation arrows. + */ +@Composable +internal fun FrameNavigator( + totalFrames: Int, + currentIndex: Int, + onIndexChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + var dropdownExpanded by remember { mutableStateOf(false) } + + Surface( + modifier = modifier, + color = Color.White, + elevation = 2.dp, + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Previous frame button + IconButton( + onClick = { + if (currentIndex > 0) { + onIndexChange(currentIndex - 1) + } + }, + enabled = currentIndex > 0 + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Previous frame", + tint = if (currentIndex > 0) Color.Black else Color.LightGray + ) + } + + Box { + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { dropdownExpanded = true } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Frame ${currentIndex + 1}", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Select frame", + tint = Color.Black + ) + } + + DropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + modifier = Modifier + .background(Color.White) + .width(150.dp) + .heightIn(max = 350.dp) + ) { + (0 until totalFrames).forEach { index -> + DropdownMenuItem( + onClick = { + onIndexChange(index) + dropdownExpanded = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Frame ${index + 1}", + color = if (index == currentIndex) Color.Black else Color.LightGray, + fontWeight = if (index == currentIndex) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + + IconButton( + onClick = { + if (currentIndex < totalFrames - 1) { + onIndexChange(currentIndex + 1) + } + }, + enabled = currentIndex < totalFrames - 1 + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Next frame", + tint = if (currentIndex < totalFrames - 1) Color.Black else Color.LightGray + ) + } + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/SearchBox.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/SearchBox.kt new file mode 100644 index 000000000..2d6d36cac --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/SearchBox.kt @@ -0,0 +1,86 @@ +package com.squareup.workflow1.traceviewer.ui.control + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.squareup.workflow1.traceviewer.model.Node + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SearchBox( + nodes: List, + onSearch: (String) -> Unit, + modifier: Modifier = Modifier +) { + var searchText by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + + DockedSearchBar( + modifier = modifier, + inputField = { + SearchBarDefaults.InputField( + query = searchText, + onQueryChange = { searchText = it }, + onSearch = { + expanded = false + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text("search for a node...") }, + trailingIcon = { + IconButton( + onClick = { + expanded = false + } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear search" + ) + } + } + ) + }, + colors = SearchBarColors(Color.White, Color.Black), + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + val relevantNodes = nodes.filter { it.name.contains(searchText, ignoreCase = true) } + Column { + relevantNodes.take(5).forEach { node -> + key(node.id) { + ListItem( + headlineContent = { Text(node.name) }, + modifier = Modifier + .clickable { + onSearch(node.name) + expanded = false + }, + colors = ListItemDefaults.colors( + containerColor = Color.White, + headlineColor = Color.Black + ) + ) + } + } + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/TraceModeToggleSwitch.kt similarity index 95% rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/TraceModeToggleSwitch.kt index 0c88df899..e2e101294 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/TraceModeToggleSwitch.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.traceviewer.ui +package com.squareup.workflow1.traceviewer.ui.control import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/UploadFile.kt similarity index 94% rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/UploadFile.kt index 6daae3691..725347f36 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/UploadFile.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.traceviewer.util +package com.squareup.workflow1.traceviewer.ui.control import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape @@ -19,7 +19,7 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher * contains information pulled from workflow traces */ @Composable -public fun UploadFile( +internal fun UploadFile( resetOnFileSelect: (PlatformFile?) -> Unit, modifier: Modifier = Modifier, ) { diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt index a0534a342..283c502a5 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt @@ -8,9 +8,11 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize import com.squareup.workflow1.traceviewer.SandboxState /** @@ -22,6 +24,7 @@ import com.squareup.workflow1.traceviewer.SandboxState */ @Composable internal fun SandboxBackground( + appWindowSize: IntSize, sandboxState: SandboxState, modifier: Modifier = Modifier, content: @Composable () -> Unit, @@ -35,20 +38,35 @@ internal fun SandboxBackground( sandboxState.offset += translation } } - .pointerInput(Unit) { + .pointerInput(appWindowSize) { // Zooming capabilities: watches for any scroll events and immediately consumes changes. // - This is AI generated. awaitEachGesture { val event = awaitPointerEvent() if (event.type == PointerEventType.Scroll) { - val scrollDelta = event.changes.first().scrollDelta.y + val pointerInput = event.changes.first() + val pointerOffsetToCenter = Offset( + // For some reason using 1.5 made zooming more natural than 2 + x = pointerInput.position.x - appWindowSize.width / (3 / 2), + y = pointerInput.position.y - appWindowSize.height / 2 + ) + val scrollDelta = pointerInput.scrollDelta.y // Applies zoom factor based on the actual delta change rather than just the act of scrolling // This helps to normalize mouse scrolling and touchpad scrolling, since touchpad will // fire a lot more scroll events. val factor = 1f + (-scrollDelta * 0.1f) val minWindowSize = 0.1f - val maxWindowSize = 10f - sandboxState.scale = (sandboxState.scale * factor).coerceIn(minWindowSize, maxWindowSize) + val maxWindowSize = 2f + val oldScale = sandboxState.scale + val newScale = (oldScale * factor).coerceIn(minWindowSize, maxWindowSize) + val scaleRatio = newScale / oldScale + + val newOrigin = sandboxState.offset - pointerOffsetToCenter + val scaledView = newOrigin * scaleRatio + val resetViewOffset = scaledView + pointerOffsetToCenter + sandboxState.offset = resetViewOffset + sandboxState.scale = newScale + event.changes.forEach { it.consume() } } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 61fbd74d9..9fc13eee5 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -11,12 +11,16 @@ import kotlinx.coroutines.withContext import okio.IOException import java.net.Socket -internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String) -> Unit) { +/** + * Maintains the channel, which acts as buffer for information read from the socket, as the data are + * being processed. + */ +internal suspend fun streamRenderPassesFromDevice(device: String, parseOnNewRenderPass: (String) -> Unit) { val renderPassChannel: Channel = Channel(Channel.BUFFERED) coroutineScope { launch { try { - pollSocket(onNewRenderPass = renderPassChannel::send) + pollSocket(device = device, onNewRenderPass = renderPassChannel::send) } finally { renderPassChannel.close() } @@ -39,10 +43,10 @@ internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String) * @param onNewRenderPass is called from an arbitrary thread, so it is important to ensure that the * caller is thread safe */ -private suspend fun pollSocket(onNewRenderPass: suspend (String) -> Unit) { +private suspend fun pollSocket(device: String, onNewRenderPass: suspend (String) -> Unit) { withContext(Dispatchers.IO) { try { - runForwardingPortThroughAdb { port -> + runForwardingPortThroughAdb(device) { port -> Socket("localhost", port).useWithCancellation { socket -> val reader = socket.getInputStream().bufferedReader() do { @@ -88,9 +92,9 @@ private suspend fun Socket.useWithCancellation(block: suspend (Socket) -> Unit) * If block throws or returns on finish, the port forwarding is removed via adb (best effort). */ @Suppress("BlockingMethodInNonBlockingContext") -private suspend inline fun runForwardingPortThroughAdb(block: (port: Int) -> Unit) { +private suspend inline fun runForwardingPortThroughAdb(device: String, block: (port: Int) -> Unit) { val process = ProcessBuilder( - "adb", "forward", "tcp:0", "localabstract:workflow-trace" + "adb", "-s", device, "forward", "tcp:0", "localabstract:workflow-trace" ).start() // The adb forward command will output the port number it picks to connect. diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt deleted file mode 100644 index 55d5d00c5..000000000 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.squareup.workflow1.traceviewer.util - -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import com.squareup.moshi.JsonAdapter -import com.squareup.workflow1.traceviewer.TraceMode -import com.squareup.workflow1.traceviewer.model.Node -import com.squareup.workflow1.traceviewer.ui.DrawTree - -/** - * Handles parsing the trace's after JsonParser has turned all render passes into frames. Also calls - * the UI composables to render the full trace. - * - * This handles either File or Live trace modes, and will parse equally - */ -@Composable -internal fun RenderTrace( - traceSource: TraceMode, - frameInd: Int, - onFileParse: (List) -> Unit, - onNodeSelect: (Node, Node?) -> Unit, - onNewFrame: () -> Unit, - modifier: Modifier = Modifier -) { - var isLoading by remember(traceSource) { mutableStateOf(true) } - var error by remember(traceSource) { mutableStateOf(null) } - val frames = remember { mutableStateListOf() } - val fullTree = remember { mutableStateListOf() } - val affectedNodes = remember { mutableStateListOf>() } - - // Updates current state with the new data from trace source. - fun addToStates(frame: List, tree: List, affected: List>) { - frames.addAll(frame) - fullTree.addAll(tree) - affectedNodes.addAll(affected) - isLoading = false - onFileParse(frame) - } - - // Handles the result of parsing a trace, either from file or live. Live mode includes callback - // for when a new frame is received. - fun handleParseResult( - parseResult: ParseResult, - onNewFrame: (() -> Unit)? = null - ) { - when (parseResult) { - is ParseResult.Failure -> { - error = parseResult.error.toString() - } - - is ParseResult.Success -> { - addToStates( - frame = parseResult.trace, - tree = parseResult.trees, - affected = parseResult.affectedNodes - ) - onNewFrame?.invoke() - } - } - } - - LaunchedEffect(traceSource) { - when (traceSource) { - is TraceMode.File -> { - checkNotNull(traceSource.file) { - "TraceMode.File should have a non-null file to parse." - } - val parseResult = parseFileTrace(traceSource.file) - handleParseResult(parseResult) - } - - is TraceMode.Live -> { - val adapter: JsonAdapter> = createMoshiAdapter() - streamRenderPassesFromDevice { renderPass -> - val currentTree = fullTree.lastOrNull() - val parseResult = parseLiveTrace(renderPass, adapter, currentTree) - handleParseResult(parseResult, onNewFrame) - } - error = "Socket has already been closed or is not available." - } - } - } - - if (error != null) { - Text("Error parsing: $error") - return - } - - if (!isLoading) { - val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null - DrawTree( - node = fullTree[frameInd], - previousNode = previousFrame, - affectedNodes = affectedNodes[frameInd], - expandedNodes = remember(frameInd) { mutableStateMapOf() }, - onNodeSelect = onNodeSelect, - ) - } -} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/DiffUtils.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/DiffUtils.kt new file mode 100644 index 000000000..542b42dac --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/DiffUtils.kt @@ -0,0 +1,228 @@ +package com.squareup.workflow1.traceviewer.util.parser + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import com.github.difflib.text.DiffRow.Tag +import com.github.difflib.text.DiffRowGenerator +import com.squareup.workflow1.traceviewer.util.parser.DiffStyles.buildStringWithStyle + +/** + * Matching for the type name of a field. This is used to pull out the name to compare + * Loading() -> Loading; Idle() -> Idle; CheckoutAppletWorkflow() -> CheckoutAppletWorkflow + */ +private val stateRegex = Regex("""^(\w+)\(""") + +/** + * Generates a field-level word-diff for each node's states. + * + */ +internal fun computeAnnotatedDiff( + past: String, + current: String +): AnnotatedString { + val diffGenerator = DiffRowGenerator.create() + .showInlineDiffs(true) + .inlineDiffByWord(true) + .mergeOriginalRevised(true) + .oldTag { _ -> "--" } + .newTag { _ -> "++" } + .build() + + val pastName = extractTypeName(past) + val currentName = extractTypeName(current) + val pastFields = getFieldsAsList(past) + val currentFields = getFieldsAsList(current) + val diffRows = diffGenerator.generateDiffRows(pastFields, currentFields) + + var existsDiff = false + return buildAnnotatedString { + // A full change in the type means all internal data will be changed, so it's easier to just + // generalize and show the diff in the type's name + if (pastName != currentName) { + buildStringWithStyle( + style = DiffStyles.DELETE, + text = "$pastName(...)", + builder = this + ) + append(" → ") + buildStringWithStyle( + style = DiffStyles.INSERT, + text = "$currentName(...)", + builder = this + ) + return@buildAnnotatedString + } + + diffRows.forEach { row -> + val tag = row.tag!! + // The 'mergeOriginalRevised' flag changes the semantics of the data, but the API still returns + // the same components + val fullDiff = row.oldLine + + /* + Tag.INSERT and Tag.DELETE only happens when there is a difference in number of rows, i.e.: + Tag(["a"],["a","b"]) == INSERT + and + Tag(["a","b"],["a"]) == DELETE + but + Tag([""],["a"]) == CHANGE + */ + when (tag) { + Tag.CHANGE -> { + existsDiff = true + parseChangedDiff(fullDiff).forEach { (style, text) -> + buildStringWithStyle( + style = style, + text = text, + builder = this + ) + } + append("\n\n") + } + + Tag.INSERT -> { + existsDiff = true + buildStringWithStyle( + text = fullDiff.replace("++", ""), + style = DiffStyles.INSERT, + builder = this + ) + append("\n\n") + } + + Tag.DELETE -> { + existsDiff = true + buildStringWithStyle( + text = fullDiff.replace("--", ""), + style = DiffStyles.DELETE, + builder = this + ) + append("\n\n") + } + + Tag.EQUAL -> { + // NoOp + } + } + } + + if (!existsDiff) { + buildStringWithStyle( + style = DiffStyles.NO_CHANGE, + text = "No Diff", + builder = this + ) + } + } +} + +/** + * Parses the full diff within Tag.CHANGED to give back a list of operations to perform + */ +private fun parseChangedDiff(fullDiff: String): List> { + val operations = buildList { + var i = 0 + while (i < fullDiff.length) { + when { + fullDiff.startsWith("--", i) -> { + val end = fullDiff.indexOf("--", i + 2) + if (end != -1) { + val removed = fullDiff.substring(i + 2, end) + add(DiffStyles.DELETE to removed) + i = end + 2 + } + } + + fullDiff.startsWith("++", i) -> { + val end = fullDiff.indexOf("++", i + 2) + if (end != -1) { + val added = fullDiff.substring(i + 2, end) + add(DiffStyles.INSERT to added) + i = end + 2 + } + } + + else -> { + val nextTagStart = listOf( + fullDiff.indexOf("--", i), + fullDiff.indexOf("++", i) + ).filter { it >= 0 }.minOrNull() ?: fullDiff.length + add(DiffStyles.UNCHANGED to fullDiff.substring(i, nextTagStart)) + i = nextTagStart + } + } + } + } + + return operations +} + +internal object DiffStyles { + val DELETE = SpanStyle(background = Color.Red.copy(alpha = 0.3f)) + val INSERT = SpanStyle(background = Color.Green.copy(alpha = 0.3f)) + val NO_CHANGE = SpanStyle(background = Color.LightGray) + val UNCHANGED = SpanStyle() + + fun buildStringWithStyle( + style: SpanStyle, + text: String, + builder: AnnotatedString.Builder + ) { + builder.pushStyle(style) + builder.append(text) + builder.pop() + } +} + +/** + * Pull out each "key=value" pair within the field data by looking for a comma. Since plenty of data + * include nesting, doing .split or simple regex won't suffice. + * + * Manually iterates through the fields and changes the depth of the current comma accordingly + */ +private fun getFieldsAsList(field: String): List { + val fields = mutableListOf() + val currentField = StringBuilder() + var depth = 0 + // We skip past the field's Type's name + var i = field.indexOf('(') + 1 + + while (i < field.length) { + val char = field[i] + when (char) { + '(', '[', '{' -> { + depth++ + currentField.append(char) + } + + ')', ']', '}' -> { + depth-- + currentField.append(char) + } + + ',' -> { + if (depth == 0) { // end of key=value pair + fields += currentField.toString().trim() + currentField.clear() + i++ // skip space in between key value pairs, i.e. "key1=value1, key2=value2" + } else { // nested list, so we ignore + currentField.append(char) + } + } + + else -> currentField.append(char) + } + i++ + } + + // Just append whatever is left, since there are no trailing commas + if (currentField.isNotBlank()) fields += currentField.toString().trim() + return fields +} + +private fun extractTypeName(field: String): String { + // If regex doesn't match, that means it's likely "kotlin.Unit" or "0" + return stateRegex.find(field)?.groupValues?.get(1) ?: field +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/JsonParser.kt similarity index 93% rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/JsonParser.kt index abe6acaf0..89b8d614d 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/JsonParser.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.traceviewer.util +package com.squareup.workflow1.traceviewer.util.parser import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi @@ -14,9 +14,9 @@ import kotlin.reflect.typeOf /* The root workflow Node uses an ID of 0, and since we are filtering childrenByParent by the - parentId, the root node has a parent of -1 ID. This is reflected seen inside android-register + parentId, the root node will have -1 as a fake parent ID. This is reflected inside android-register. */ -const val ROOT_ID: String = "-1" +internal const val ROOT_ID: String = "-1" /** * Parses a given file's JSON String into a list of [Node]s with Moshi adapters. Each of these nodes @@ -153,8 +153,13 @@ internal fun mergeFrameIntoMainTree( } internal sealed interface ParseResult { - class Success(val trace: List, val trees: List, affectedNodes: List>) : ParseResult { + class Success( + val trace: List, + val trees: List, + affectedNodes: List> + ) : ParseResult { val affectedNodes = affectedNodes.map { it.toSet() } } + class Failure(val error: Throwable) : ParseResult } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/TraceParser.kt new file mode 100644 index 000000000..3cd9849bb --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/TraceParser.kt @@ -0,0 +1,152 @@ +package com.squareup.workflow1.traceviewer.util.parser + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +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.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.squareup.moshi.JsonAdapter +import com.squareup.workflow1.traceviewer.TraceMode +import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.model.NodeUpdate +import com.squareup.workflow1.traceviewer.ui.DrawTree +import com.squareup.workflow1.traceviewer.util.streamRenderPassesFromDevice + +/** + * Handles parsing the trace's after JsonParser has turned all render passes into frames. Also calls + * the UI composables to render the full trace. + * + * This handles either File or Live trace modes, and will parse equally + */ +@Composable +internal fun RenderTrace( + traceSource: TraceMode, + frameInd: Int, + onFileParse: (Int) -> Unit, + onNodeSelect: (NodeUpdate) -> Unit, + onNewFrame: () -> Unit, + onNewData: (String) -> Unit, + storeNodeLocation: (Node, Offset) -> Unit, + modifier: Modifier = Modifier +) { + key(traceSource) { + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + val frames = remember { mutableStateListOf() } + val fullTree = remember { mutableStateListOf() } + val affectedNodes = remember { mutableStateListOf>() } + + // Updates current state with the new data from trace source. + fun addToStates( + frame: List, + tree: List, + affected: List> + ) { + frames.addAll(frame) + fullTree.addAll(tree) + affectedNodes.addAll(affected) + onFileParse(frame.size) + } + + // Handles the result of parsing a trace, either from file or live. Live mode includes callback + // for when a new frame is received. + fun handleParseResult( + parseResult: ParseResult, + rawRenderPass: String? = null, + onNewFrame: (() -> Unit)? = null + ) { + when (parseResult) { + is ParseResult.Failure -> { + error = parseResult.error.toString() + } + + is ParseResult.Success -> { + addToStates( + frame = parseResult.trace, + tree = parseResult.trees, + affected = parseResult.affectedNodes + ) + // Only increment the frame index and add the raw data during Live tracing mode. + onNewFrame?.invoke() + rawRenderPass?.let { onNewData(it) } + } + } + isLoading = false + } + + LaunchedEffect(traceSource) { + when (traceSource) { + is TraceMode.File -> { + checkNotNull(traceSource.file) { + "TraceMode.File should have a non-null file to parse." + } + val parseResult = parseFileTrace(traceSource.file) + handleParseResult(parseResult) + } + + is TraceMode.Live -> { + checkNotNull(traceSource.device) { + "TraceMode.Live requires a selected device" + } + val adapter: JsonAdapter> = createMoshiAdapter() + streamRenderPassesFromDevice(traceSource.device) { rawRenderPass -> + val currentTree = fullTree.lastOrNull() + val parseResult = parseLiveTrace(rawRenderPass, adapter, currentTree) + handleParseResult(parseResult, rawRenderPass, onNewFrame) + } + error = "Socket has already been closed or is not available." + } + } + } + + // This will only happen in the initial switch to Live Mode, where a socket error bubbled up and + // the lambda call to parse the data was immediately cancelled, meaning handleParseResult was never + // called to set isLoading to false. + if (isLoading && error != null) { + Text("Socket Error: $error") + return + } + + // This meant that there was an exception, but it was stored in ParseResult and read in + // handleParseResult method. Since there is no parsed data, this likely means there was a moshi + // parsing error. + if (error != null && frames.isEmpty()) { + Text("Malformed File: $error") + return + } + + if (!isLoading) { + val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null + DrawTree( + node = fullTree[frameInd], + previousFrameNode = previousFrame, + affectedNodes = affectedNodes[frameInd], + expandedNodes = remember(frameInd) { mutableStateMapOf() }, + onNodeSelect = onNodeSelect, + storeNodeLocation = storeNodeLocation + ) + + // This error happens when there has already been previous data parsed, but some exception bubbled + // up again, meaning it has to be a socket closure in Live mode. + error?.let { + Text( + text = "Socket closed: $error", + fontSize = 20.sp, + modifier = modifier.background(Color.White).padding(20.dp) + ) + } + } + } +} diff --git a/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt b/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt index 434aac6ba..0d7c890a5 100644 --- a/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt +++ b/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt @@ -1,6 +1,7 @@ package com.squareup.workflow1.traceviewer.util import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.util.parser.mergeFrameIntoMainTree import java.util.LinkedHashMap import kotlin.test.Test import kotlin.test.assertEquals